MayankRSS

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:

All of this means it’s okay to require JavaScript for once. We still won’t use shadow DOM because we want our tooltips to be styleable and accessible.

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.

Desired API

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.

<tool-tip text="Save file">
<button>💾</button>
</tool-tip>

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.

<tool-tip text="Save file">
<button>
<span aria-hidden="true">💾</span>
<span class="visually-hidden">Save file</span>
</button>
</tool-tip>

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.

<tool-tip text="Deletion might take a minute" aria="description">
<button>Delete project</button>
</tool-tip>

Let’s start

With an API in mind, we can start working towards it.

First up, we’ll store the trigger and tooltip as instance variables.

  1. trigger is the element we are wrapping around. It’s straighforward to get this because of our API design.
  2. The tooltip itself 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 text attribute and be hidden by default. It will also be hidden from assistive technologies using aria-hidden, because we don’t want duplicate announcements.
class Tooltip extends HTMLElement {
connectedCallback() {
this.trigger = this.firstElementChild;
this.tooltip = document.createElement('tool-tip-text');
this.tooltip.textContent = this.getAttribute('text');
this.tooltip.setAttribute('aria-hidden', 'true');
this.tooltip.hidden = true;
document.body.insertBefore(this.tooltip, null);
this.setupEventHandlers();
}
setupEventHandlers() {}
}
customElements.define('tool-tip', Tooltip);

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.

setupEventHandlers() {
if (matchMedia('(hover: hover)').matches) {
this.trigger.addEventListener('pointerenter', this.show.bind(this));
this.trigger.addEventListener('pointerleave', this.hide.bind(this));
}
this.trigger.addEventListener('focus', this.show.bind(this));
this.trigger.addEventListener('blur', this.hide.bind(this));
}
show() {
this.tooltip.hidden = false;
}
hide() {
this.tooltip.hidden = true;
}

At this point, we’ve got the “skeleton” wired up. Let’s add some styles, shall we?

Styling

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.

tool-tip {
display: contents;
}

Worth noting that this rule is critical, so it should probably go in a real CSS file, or even in an inline style attribute.

<tool-tip style="display: contents">
...
</tool-tip>

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).

tool-tip-text {
position: absolute;
inset-inline-start: 0;
inset-block-start: 0;
max-inline-size: min(90vi, 30ch);
z-index: 999;
}

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.

@layer tool-tip-text {
tool-tip-text {
/* ... any cosmetic 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”.

tool-tip-text[hidden] {
display: none !important;
}

Sweet! Now let’s move onto positioning.

Positioning

This is one of the yuckiest parts about building tooltips. Maybe CSS anchor positioning will help us out one day, but today we have to rely on a third-party JavaScript library called 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.

import { computePosition } from '@floating-ui/dom';
//
async show() {
this.tooltip.hidden = false;
const { x, y } = await computePosition(this.trigger, this.tooltip, { /*...*/ });
this.tooltip.style.transform = `translate(${x}px,${y}px)`;
}

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.

Delay

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.

async show() {
this.tooltipTriggered = 'true';
await new Promise((resolve) => setTimeout(resolve, 100));
if (!this.tooltipTriggered) return;
this.tooltip.hidden = false;
//
}
async hide() {
delete this.tooltipTriggered;
await new Promise((resolve) => setTimeout(resolve, 100));
if (this.tooltipTriggered) return;
this.tooltip.hidden = true;
}

Accessibility

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 aria-hidden.

connectedCallback() {
//
if (this.getAttribute('aria') === 'description') {
this.tooltip.id = uniqueId();
this.trigger.setAttribute('aria-describedby', this.tooltip.id);
}
}

where the id is generated using this silly helper:

const uniqueId = (() => {
let count = 0;
return () => `__tt-${++count}`;
})();

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.

setupEventHandlers() {
//
this.tooltip.addEventListener('pointerenter', this.show.bind(this));
this.tooltip.addEventListener('pointerleave', this.hide.bind(this));
document.addEventListener('keydown', function ({ key, ctrlKey }) {
if (key === 'Escape' || ctrlKey) {
this.hide();
}
}.bind(this));
}

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.

tool-tip-text::before {
content: '';
position: absolute;
inset: -5px; /* adjust as you please */
z-index: -1;
}

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).

Live demo

Tooltips as icon labels:

Tooltips as supplementary text:

You can find the full source code on GitHub.

Next up: Making tooltips work on touchscreen.