Building a tooltip using web components
Previously, I’ve covered the many constraints of web components that make it difficult to use them like framework components. I’ve been thinking more about a good use case for web components, and I may have found one: tooltips.
Tooltips are very different from normal components in a few ways:
- They don’t need to be pre-rendered on the server.
- They are an optional, non-critical enhancement.
- They don’t have many portability concerns because they don’t add any markup to the same tree.
There are quite a few accessibility considerations and UX details that go into making a good tooltip. Ultimately this post is just a starting point to determine if this goal is achievable with web components. In a real application, you’d want to get feedback from your users and make adjustments accordingly.
If you only care about the code, you can find a link at the end alongside the demo.
I always like to start from the outside, asking myself “How would I want to use this?”
Tooltips should only contain plain text content, so using an attribute is perfect. And we can wrap the custom element around the “trigger” element. This feels intuitive to me.
Remember: we want our tooltips to be non-critical, so the trigger element should still have an accessible name (“Save file”), regardless of whether there is a tooltip. Let’s use some visually hidden text for that. Generally, this text should match the tooltip text.
If the trigger element already has a visible label, then we might want to instead use a supplementary tooltip, which we can associate with the trigger using
aria-describedby. To opt into this mode, we can use another custom attribute.
With an API in mind, we can start working towards it.
First up, we’ll store the trigger and tooltip as instance variables.
triggeris the element we are wrapping around. It’s straighforward to get this because of our API design.
tooltipitself is a new element that we’ll create and add at the end of the
<body>(to avoid stacking context issues). It will contain the text from the
textattribute and be
hiddenby default. It will also be hidden from assistive technologies using
aria-hidden, because we don’t want duplicate announcements.
Now let’s set up our event handlers. We want our tooltips to be shown on hover as well as focus, so we’ll need two sets of event listeners. We also don’t want to accidentally trigger the tooltip when scrolling on touch devices, so we’ll use the
hover media query.
At this point, we’ve got the “skeleton” wired up. Let’s add some styles, shall we?
The very first thing we need is to make sure our custom element doesn’t participate in layout; the trigger should behave as if it was a direct child of the
tool-tip’s parent. This is easily achievable using
display: contents. There are serious accessibility concerns when using
display: contents, but it should be fine in this case, because
tool-tip is a generic element which does not hold any semantic information.
Worth noting that this rule is critical, so it should probably go in a real CSS file, or even in an inline style attribute.
Now for the actual
tool-tip-text, its styles are not critical so they can be added dynamically (e.g. as constructed stylesheets). We’ll use absolute positioning, initially place it in a corner, and give it a reasonable max width so it doesn’t become too wide or overflow beyond the screen. Let’s also add a z-index for good measure (although since we’re placing it directly under
<body>, we shouldn’t need a very high number).
While we’re here, we can add any cosmetic styles — I won’t be covering those here. Besides, these styles are fully customizeable because of our deliberate decision to only use light DOM. We can also use
@layer to make it easier to override these styles.
The only other important thing I want to mention is that it’s probably a good idea to reinforce
hidden styles, because its User Agent styles are “less specific than a moderate sneeze”.
Sweet! Now let’s move onto positioning.
floating-ui. This library is the culmination of half a decade of hard work, and one of its maintainers wrote about it some time ago. It includes functionality that I have never seen elsewhere and covers many edge cases that we’d most definitely forget if we were doing this by hand.
I won’t go into too much detail here, or we’ll be here all day long. But just to give a high-level overview, we’ll pass our trigger and tooltip elements to floating-ui and it will give us x and y coordinates that we can plug into a CSS
transform applied as an inline style.
Not bad! At this point, our tooltip should correctly appear near the trigger on hover and focus.
But we’re not done yet. Currently our tooltip has some usability and accessibility issues.
It’s generally a good idea to add a small delay before triggering the tooltip. This improves user experience by making the action of triggering the tooltip feel more intentional. Without this delay, our users might accidentally trigger random tooltips when the mouse is being moved across the page.
It’s pretty straightforward to add a delay using
setTimeout, but we want to also account for the case where a tooltip could be hidden before this
setTimeout completes. We can store the true state in an instance variable or data attribute, and early return if the state has changed after the delay.
Right from the beginning, we decided that our tooltip will be hidden from assistive technologies. We didn’t use
role='tooltip' because of poor support. When used for labeling, the trigger element should already have its own label. When used for supplementary descriptions, we’ll use
aria-describedby with a unique id. Notably, this still works even though the tooltip has
where the id is generated using this silly helper:
We still need to satisfy WCAG SC 1.4.13: our tooltip needs to be “dismissable”, “hoverable”, and “persistent” (currently we are failing the first two).
To achieve “dismissable”, we can hide our tooltips on Esc keypresses. To achieve “hoverable”, we can add event handlers to keep the tooltip visible when it’s hovered.
Note that we are adding our keydown listener on
document because it needs to work regardless of where the focus is. However, this could potentially interfere with other components that also listen for the Escape key (such as a dialog which may contain a tooltip). To somewhat alleviate this, we’ll also dismiss the tooltip when Control key is pressed. Additionally, the other components could be changed to check for the presence of
tool-tip:not([hidden]) in their keydown handlers.
We’ll also need account for any “offset” between the tooltip and the trigger. Almost every tooltip I’ve seen out in the wild has a gap between itself and the trigger. We don’t want our tooltip to be accidentally dismissed when the mouse is being moved from the trigger to the tooltip. We can fix this using an invisible pseudo-element to effectively increase the size of the tooltip, ensuring that the pointer events still work within the gap.
Here’s a video demonstration showing all these changes in action:
Further accessibility improvements
While our tooltip technically meets accessibility requirements, it’s only a start. There are many improvements we can still make. I highly recommend reading Sarah Higley’s excellent article if you’re interested in learning more.
My goal with this article was to see if it’s feasible to build an accessible base for tooltips using web components, and that does seem to be the case for the most part. I only covered the
tool-tip custom element, but
tool-tip-text could also be easily enhanced, for adding things like a close button (as suggested in Sarah’s article).
Tooltips as icon labels:
Tooltips as supplementary text:
You can find the full source code on GitHub.
Next up: Making tooltips work on touchscreen.