Building a tooltip component

A foundational overview of how to build a color-adaptive and accessible tooltip custom element.

In this post I want to share my thoughts on how to build a color-adaptive and accessible <tool-tip> custom element. Try the demo and view the source!

A tooltip is shown working across a variety of examples and color schemes

If you prefer video, here's a YouTube version of this post:

Overview

A tooltip is a non-modal, non-blocking, non-interactive overlay containing supplemental information to user interfaces. It is hidden by default and becomes unhidden when an associated element is hovered or focused. A tooltip can't be selected or interacted with directly. Tooltips are not replacements for labels or other high value information, a user should be able to fully complete their task without a tooltip.

Do: always label your inputs.
Don't: rely on tooltips instead of labels

Toggletip vs Tooltip

Like many components, there are varying descriptions of what a tooltip is, for example in MDN, WAI ARIA, Sarah Higley, and Inclusive Components. I like the separation between tooltips and toggletips. A tooltip should contain non-interactive supplemental information, while a toggletip can contain interactivity and important information. The primary reason for the divide is accessibility, how are users expected to navigate to the popup and have access to the information and buttons within. Toggletips get complex quickly.

Here's a video of a toggletip from the Designcember site; an overlay with interactivity that a user can pin open and explore, then close with light dismiss or the escape key:

This GUI Challenge went the route of a tooltip, looking to do almost everything with CSS, and here's how to build it.

Markup

I chose to use a custom element <tool-tip>. Authors don't need to make custom elements into web components if they don't want to. The browser will treat <foo-bar> just like a <div>. You could think of a custom element like a classname with less specificity. There's no JavaScript involved.

<tool-tip>A tooltip</tool-tip>

This is like a div with some text inside. We can tie into the accessibility tree of capable screen readers by adding [role="tooltip"].

<tool-tip role="tooltip">A tooltip</tool-tip>

Now, to screen readers, it's recognized as a tooltip. See in the following example how the first link element has a recognized tooltip element in its tree and the second does not? The second one doesn't have the role. In the styles section we'll improve upon this tree view.

A
screenshot of Chrome DevTools Accessibility Tree representing the HTML. Shows a
link with text 'top ; Has tooltip: Hey, a tooltip!' that's focusable. Inside of
it is static text of 'top' and a tooltip element.

Next we need the tooltip to not be focusable. If a screen reader doesn't understand the tooltip role it will allow users to focus the <tool-tip> to read the contents, and the user experience doesn't need this. Screen readers will append the content to the parent element and as such, it doesn't need focus to be made accessible. Here we can use inert to ensure no users will accidentally find this tooltip content in their tab flow:

<tool-tip inert role="tooltip">A tooltip</tool-tip>

Another screenshot of Chrome DevTools Accessibility Tree, this time the
tooltip element is missing.

I then chose to use attributes as the interface to specify the position of the tooltip. By default all the <tool-tip>s will assume a "top" position, but the position can be customized on an element by adding tip-position:

<tool-tip role="tooltip" tip-position="right ">A tooltip</tool-tip>

A
screenshot of a link with a tooltip to the right saying 'A tooltip'.

I tend to use attributes instead of classes for things like this so that the <tool-tip> can't have multiple positions assigned to it at the same time. There can be only one or none.

Finally, place <tool-tip> elements inside of the element you wish to provide a tooltip for. Here I share the alt text with sighted users by placing an image and a <tool-tip> inside of a <picture> element:

<picture>
  <img alt="The GUI Challenges skull logo" width="100" src="...">
  <tool-tip role="tooltip" tip-position="bottom">
    The <b>GUI Challenges</b> skull logo
  </tool-tip>
</picture>

A
screenshot of an image with a tooltip that says 'The GUI Challenges skull
logo'.

Here I place a <tool-tip> inside of an <abbr> element:

<p>
  The <abbr>HTML <tool-tip role="tooltip" tip-position="top">Hyper Text Markup Language</tool-tip></abbr> abbr element.
</p>

A
screenshot of a paragraph with the acronym HTML underlined and a tooltip above
it saying 'Hyper Text Markup Language'.

Accessibility

Since I've chosen to build tooltips and not toggletips, this section is much simpler. First, let me outline what our desired user experience:

  1. In constrained spaces or cluttered interfaces, hide supplemental messages.
  2. When a user hovers, focuses or uses touch to interact with an element, reveal the message.
  3. When hover, focus or touch ends, hide the message again.
  4. Lastly, ensure any motion is reduced if a user has specified a preference for reduced motion.

Our goal is on demand supplemental messaging. A sighted mouse or keyboard user can hover to reveal the message, reading it with their eyes. A non-sighted screen reader user can focus to reveal the message, audibly receiving it through their tool.

Screenshot of MacOS VoiceOver reading a link with a tooltip

In the previous section we covered the accessibility tree, the tooltip role and inert, what's left is to test it and verify the user experience appropriately reveals the tooltip message to the user. Upon testing, it's unclear as to which part of the audible message is a tooltip. It can be seen while debugging in the accessibility tree too, the link text of "top" is run together, without hesitation, with "Look, tooltips!". The screen reader doesn't break or identify the text as tooltip content.

A
screenshot of the Chrome DevTools Accessibility Tree where the link text says
'top Hey, a tooltip!'.

Add a screen reader only pseudo-element to the <tool-tip> and we can add our own prompt text for non-sighted users.

&::before {
  content: "; Has tooltip: ";
  clip: rect(1px, 1px, 1px, 1px);
  clip-path: inset(50%);
  height: 1px;
  width: 1px;
  margin: -1px;
  overflow: hidden;
  padding: 0;
  position: absolute;
}

Below you can see the updated accessibility tree, which now has a semicolon after the link text and a prompt for the tooltip "Has tooltip: ".

An updated screenshot of the Chrome DevTools Accessibility Tree where the
link text has improved phrasing, 'top ; Has tooltip: Hey, a tooltip!'.

Now, when a screen reader user focuses the link, it says "top" and takes a small pause, then announces "has tooltip: look, tooltips". This gives a screen reader user a couple nice UX hints. The hesitation gives a nice separation between the link text and the tooltip. Plus, when "has tooltip" is announced, a screen reader user can easily cancel it if they've already heard it before. It's very reminiscent to hovering and unhovering quickly, as you've already seen the supplemental message. This felt like nice UX parity.

Styles

The <tool-tip> element will be a child of the element it's representing supplemental messaging for, so let's first start with the essentials for the overlay effect. Take it out of document flow with position absolute:

tool-tip {
  position: absolute;
  z-index: 1;
}

If the parent is not a stacking context, the tooltip will position itself to the nearest one that is, which isn't what we want. There's a new selector on the block that can help, :has():

Browser Support

  • 105
  • 105
  • 121
  • 15.4

Source

:has(> tool-tip) {
  position: relative;
}

Don't worry too much about the browser support. First, remember these tooltips are supplementary. If they don't work it should be fine. Second, in the JavaScript section we'll deploy a script to polyfill the functionality we need for browsers without :has() support.

Next, let's make the tooltips non-interactive so they don’t steal pointer events from their parent element:

tool-tip {
  …
  pointer-events: none;
  user-select: none;
}

Then, hide the tooltip with opacity so we can transition the tooltip with a crossfade:

tool-tip {
  opacity: 0;
}

:has(> tool-tip):is(:hover, :focus-visible, :active) > tool-tip {
  opacity: 1;
}

:is() and :has() do the heavy lifting here, making tool-tip containing parent elements aware of user interactivity as to toggle the visibility of a child tooltip. Mouse users can hover, keyboard and screen reader users can focus, and touch users can tap.

With the show and hide overlay working for sighted users, it's time to add some styles for theming, positioning and adding the triangle shape to the bubble. The following styles begin using custom properties, building upon where we are so far but also adding shadows, typography and colors so it looks like a floating tooltip:

A
screenshot of the tooltip in dark mode, floating over the link 'block-start'.

tool-tip {
  --_p-inline: 1.5ch;
  --_p-block: .75ch;
  --_triangle-size: 7px;
  --_bg: hsl(0 0% 20%);
  --_shadow-alpha: 50%;

  --_bottom-tip: conic-gradient(from -30deg at bottom, rgba(0,0,0,0), #000 1deg 60deg, rgba(0,0,0,0) 61deg) bottom / 100% 50% no-repeat;
  --_top-tip: conic-gradient(from 150deg at top, rgba(0,0,0,0), #000 1deg 60deg, rgba(0,0,0,0) 61deg) top / 100% 50% no-repeat;
  --_right-tip: conic-gradient(from -120deg at right, rgba(0,0,0,0), #000 1deg 60deg, rgba(0,0,0,0) 61deg) right / 50% 100% no-repeat;
  --_left-tip: conic-gradient(from 60deg at left, rgba(0,0,0,0), #000 1deg 60deg, rgba(0,0,0,0) 61deg) left / 50% 100% no-repeat;

  pointer-events: none;
  user-select: none;

  opacity: 0;
  transform: translateX(var(--_x, 0)) translateY(var(--_y, 0));
  transition: opacity .2s ease, transform .2s ease;

  position: absolute;
  z-index: 1;
  inline-size: max-content;
  max-inline-size: 25ch;
  text-align: start;
  font-size: 1rem;
  font-weight: normal;
  line-height: normal;
  line-height: initial;
  padding: var(--_p-block) var(--_p-inline);
  margin: 0;
  border-radius: 5px;
  background: var(--_bg);
  color: CanvasText;
  will-change: filter;
  filter:
    drop-shadow(0 3px 3px hsl(0 0% 0% / var(--_shadow-alpha)))
    drop-shadow(0 12px 12px hsl(0 0% 0% / var(--_shadow-alpha)));
}

/* create a stacking context for elements with > tool-tips */
:has(> tool-tip) {
  position: relative;
}

/* when those parent elements have focus, hover, etc */
:has(> tool-tip):is(:hover, :focus-visible, :active) > tool-tip {
  opacity: 1;
  transition-delay: 200ms;
}

/* prepend some prose for screen readers only */
tool-tip::before {
  content: "; Has tooltip: ";
  clip: rect(1px, 1px, 1px, 1px);
  clip-path: inset(50%);
  height: 1px;
  width: 1px;
  margin: -1px;
  overflow: hidden;
  padding: 0;
  position: absolute;
}

/* tooltip shape is a pseudo element so we can cast a shadow */
tool-tip::after {
  content: "";
  background: var(--_bg);
  position: absolute;
  z-index: -1;
  inset: 0;
  mask: var(--_tip);
}

/* top tooltip styles */
tool-tip:is(
  [tip-position="top"],
  [tip-position="block-start"],
  :not([tip-position]),
  [tip-position="bottom"],
  [tip-position="block-end"]
) {
  text-align: center;
}

Theme adjustments

The tooltip only has a few colors to manage as the text color is inherited from the page via the system keyword CanvasText. Also, since we've made custom properties to store the values, we can update only those custom properties and let the theme handle the rest:

@media (prefers-color-scheme: light) {
  tool-tip {
    --_bg: white;
    --_shadow-alpha: 15%;
  }
}

A
side by side screenshot of the light and dark versions of the tooltip.

For the light theme, we adapt the background to white and make the shadows much less strong by adjusting their opacity.

Right to left

In order to support right to left reading modes, a custom property will store the value of the document direction into a value of -1 or 1 respectively.

tool-tip {
  --isRTL: -1;
}

tool-tip:dir(rtl) {
  --isRTL: 1;
}

This can be used to assist in positioning the tooltip:

tool-tip[tip-position="top"]) {
  --_x: calc(50% * var(--isRTL));
}

As well as assist in where the triangle is:

tool-tip[tip-position="right"]::after {
  --_tip: var(--_left-tip);
}

tool-tip[tip-position="right"]:dir(rtl)::after {
  --_tip: var(--_right-tip);
}

Lastly, can also be used for logical transforms on translateX():

--_x: calc(var(--isRTL) * -3px * -1);

Tooltip positioning

Position the tooltip logically with the inset-block or inset-inline properties to handle both the physical and logical tooltip positions. The following code shows how each of the four positions are styled for both left-to-right and right-to-left directions.

Top and block-start alignment

A
screenshot showing the placement difference between left-to-right top position
and right-to-left top position.

tool-tip:is([tip-position="top"], [tip-position="block-start"], :not([tip-position])) {
  inset-inline-start: 50%;
  inset-block-end: calc(100% + var(--_p-block) + var(--_triangle-size));
  --_x: calc(50% * var(--isRTL));
}

tool-tip:is([tip-position="top"], [tip-position="block-start"], :not([tip-position]))::after {
  --_tip: var(--_bottom-tip);
  inset-block-end: calc(var(--_triangle-size) * -1);
  border-block-end: var(--_triangle-size) solid transparent;
}

Right and inline-end alignment

A
screenshot showing the placement difference between left-to-right right position
and right-to-left inline-end position.

tool-tip:is([tip-position="right"], [tip-position="inline-end"]) {
  inset-inline-start: calc(100% + var(--_p-inline) + var(--_triangle-size));
  inset-block-end: 50%;
  --_y: 50%;
}

tool-tip:is([tip-position="right"], [tip-position="inline-end"])::after {
  --_tip: var(--_left-tip);
  inset-inline-start: calc(var(--_triangle-size) * -1);
  border-inline-start: var(--_triangle-size) solid transparent;
}

tool-tip:is([tip-position="right"], [tip-position="inline-end"]):dir(rtl)::after {
  --_tip: var(--_right-tip);
}

Bottom and block-end alignment

A
screenshot showing the placement difference between left-to-right bottom
position and right-to-left block-end position.

tool-tip:is([tip-position="bottom"], [tip-position="block-end"]) {
  inset-inline-start: 50%;
  inset-block-start: calc(100% + var(--_p-block) + var(--_triangle-size));
  --_x: calc(50% * var(--isRTL));
}

tool-tip:is([tip-position="bottom"], [tip-position="block-end"])::after {
  --_tip: var(--_top-tip);
  inset-block-start: calc(var(--_triangle-size) * -1);
  border-block-start: var(--_triangle-size) solid transparent;
}

Left and inline-start alignment

A
screenshot showing the placement difference between left-to-right left position
and right-to-left inline-start position.

tool-tip:is([tip-position="left"], [tip-position="inline-start"]) {
  inset-inline-end: calc(100% + var(--_p-inline) + var(--_triangle-size));
  inset-block-end: 50%;
  --_y: 50%;
}

tool-tip:is([tip-position="left"], [tip-position="inline-start"])::after {
  --_tip: var(--_right-tip);
  inset-inline-end: calc(var(--_triangle-size) * -1);
  border-inline-end: var(--_triangle-size) solid transparent;
}

tool-tip:is([tip-position="left"], [tip-position="inline-start"]):dir(rtl)::after {
  --_tip: var(--_left-tip);
}

Animation

So far we've only toggled the visibility of the tooltip. In this section we'll first animate opacity for all users, as it's a generally safe reduced motion transition. Then we'll animate the transform position so the tooltip appears to slide out from the parent element.

A safe and meaningful default transition

Style the tooltip element to transition opacity and transform, like this:

tool-tip {
  opacity: 0;
  transform: translateX(var(--_x, 0)) translateY(var(--_y, 0));
  transition: opacity .2s ease, transform .2s ease;
}

:has(> tool-tip):is(:hover, :focus-visible, :active) > tool-tip {
  opacity: 1;
  transition-delay: 200ms;
}

Adding motion to the transition

For each of the sides a tooltip can appear on, if the user is ok with motion, slightly position the translateX property by giving it a small distance to travel from:

@media (prefers-reduced-motion: no-preference) {
  :has(> tool-tip:is([tip-position="top"], [tip-position="block-start"], :not([tip-position]))):not(:hover):not(:focus-visible):not(:active) tool-tip {
    --_y: 3px;
  }

  :has(> tool-tip:is([tip-position="right"], [tip-position="inline-end"])):not(:hover):not(:focus-visible):not(:active) tool-tip {
    --_x: -3px;
  }

  :has(> tool-tip:is([tip-position="bottom"], [tip-position="block-end"])):not(:hover):not(:focus-visible):not(:active) tool-tip {
    --_y: -3px;
  }

  :has(> tool-tip:is([tip-position="left"], [tip-position="inline-start"])):not(:hover):not(:focus-visible):not(:active) tool-tip {
    --_x: 3px;
  }
}

Notice this is setting the "out" state, as the "in" state is at translateX(0).

JavaScript

In my opinion, the JavaScript is optional. This is because none of these tooltips should be required reading to accomplish a task in your UI. So if the tooltips completely fail, it should be no big deal. This also means we can treat the tooltips as progressively enhanced. Eventually all browsers will support :has() and this script can completely go away.

The polyfill script does two things, and does so only if the browser doesn't support :has(). First, check for :has() support:

if (!CSS.supports('selector(:has(*))')) {
  // do work
}

Next, find the parent elements of <tool-tip>s and give them a classname to work with:

if (!CSS.supports('selector(:has(*))')) {
  document.querySelectorAll('tool-tip').forEach(tooltip =>
    tooltip.parentNode.classList.add('has_tool-tip'))
}

Next, inject a set of styles that use that classname, simulating the :has() selector for the exact same behavior:

if (!CSS.supports('selector(:has(*))')) {
  document.querySelectorAll('tool-tip').forEach(tooltip =>
    tooltip.parentNode.classList.add('has_tool-tip'))

  let styles = document.createElement('style')
  styles.textContent = `
    .has_tool-tip {
      position: relative;
    }
    .has_tool-tip:is(:hover, :focus-visible, :active) > tool-tip {
      opacity: 1;
      transition-delay: 200ms;
    }
  `
  document.head.appendChild(styles)
}

That's it, now all browsers will happily show the tooltips if :has() is not supported.

Conclusion

Now that you know how I did it, how would you‽ 🙂 I'm really looking forward to the popup API for making toggletips easier, top layer for no z-index battles, and the anchor API for positioning things in the window better. Until then, I'll be making tooltips.

Let's diversify our approaches and learn all the ways to build on the web.

Create a demo, tweet me links, and I'll add it to the community remixes section below!

Community remixes

Nothing to see here yet.

Resources