Mayank RSS

Building a tooltip using the Popover API and 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.

As for shadow DOM, I was a bit hesitant at first, but it ultimately turned out to be a good fit because:

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.

Code snippet
<tool-tip content="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 or not there is a tooltip. Let’s use some visually hidden text for that. Generally, this text should match the tooltip text.

Code snippet
<tool-tip content="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. From an API perpsective, I would just like to tell my custom element to handle it for me when an arbitrary type="description is set.

Code snippet
<tool-tip type="description" content="A file needs to be selected before deleting.">
  <button aria-disabled="true">Delete file</button>
</tool-tip>

Let’s start

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

Next, 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: it’s the first and only child of our custom element.
  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.
Code snippet
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.setAttribute("popover", "manual");
    this.shadowRoot.appendChild(this.tooltip);

    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.

Code snippet
setupEventHandlers() {
  if (matchMedia('(hover: hover)').matches) {
    this.trigger.addEventListener('pointerenter', () => this.show());
    this.trigger.addEventListener('pointerleave', () => this.hide());
  }

  this.trigger.addEventListener('focus', () => this.show());
  this.trigger.addEventListener('blur', () => this.hide());
}

show() {
  this.tooltip.showPopover();
}

hide() {
  this.tooltip.hidePopover();
}

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.

Code snippet
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.

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

Code snippet
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.

Code snippet
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”.

Code snippet
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.

Code snippet
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.

Code snippet
async show() {
  this.tooltipTriggered = 'true';
  await new Promise((resolve) => setTimeout(resolve, 100));
  if (!this.tooltipTriggered) return;

  this.tooltip.showPopover();

  // …
}

async hide() {
  delete this.tooltipTriggered;
  await new Promise((resolve) => setTimeout(resolve, 100));
  if (this.tooltipTriggered) return;

  this.tooltip.hidePopover();
}

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.

Code snippet
connectedCallback() {
  // …

  if (this.getAttribute('aria') === 'description') {
    this.trigger.setAttribute('aria-description', this.tooltip.getAttribute("text"));
  }
}

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.

Code snippet
setupEventHandlers() {
  // …

  this.tooltip.addEventListener('pointerenter', () => this.show());
  this.tooltip.addEventListener('pointerleave', () => this.hide());

  document.addEventListener('keydown', ({ key, ctrlKey }) => {
    if (key === 'Escape' || ctrlKey) {
      this.hide();
    }
  });
}

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(:popover-open) 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.

Code snippet
tool-tip-text::before {
  box-sizing: border-box;
  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:

Deletion might take a minute

You can find the full source code on GitHub.

Next up: Making tooltips work on touchscreen.