(Almost) Pure CSS Tooltips

Display mode

Back to Articles

Recently I've found myself needing to insert endnotes and/or links to references when writing articles for this blog, if I want to cover some incidental detail of a topic under discussion without distracting from the main piece. I've used the word "endnote" here as the end of the post is a good place to put these asides, collated in one place out of the way; however, this can cause an issue for the diligent reader who'd like to look over an endnote at the time it's referenced. Having scrolled (or having been linked) to the bottom of the page, it can be problematic to come back up to where one was and continue with the main article.

To that end, I've instead started using inline tooltips for asides, which appear when the referential link is hovered over by the mouse (or tapped by the mobile user); this allows the aside to enrich the article without distracting from its flow.

Screenshot of an inline tooltip
Figure 1: An inline tooltip in the first part of my ActivityPub article series[1]Imran Nazar, "HTTP Signatures in PHP and OpenSSL", May 2024

We can get almost all the way to a tooltip like this with CSS alone; let's have a look at how that works.

CSS for tooltip positioning

The most salient piece of CSS being used for these tooltips is the position rule: if an element on the page is positioned with the absolute value, it will anchor itself based on an offset from the nearest positioning context. That context can be the whole page, but more often it's a parent element that's been given the relative position value; the nearest relatively positioned parent to an absolutely positioned element will provide that context.

HTML for a positioned tooltip

<p>We discuss a topic, and then provide a reference <cite>
 <sup>[1]</sup>
 <mark><em>The Lord of the Rings</em> (Harper/Collins), pg 293</mark>
</cite> which can make for further reading.</p>

CSS for the tooltip

cite {
  position: relative;
}
cite sup {
  cursor: pointer;
}
cite mark {
  display: none;
  position: absolute;
  top: 0.5em;
  left: 0;
}
cite:hover mark {
  display: block;
}

Display patches

This gets us most of the way to a working tooltip already, but there are a few things we can do to help with the display:

Putting these patches together gets us to the tooltips being used on this page, for which the CSS looks as follows:

cite {
  position: relative;
}
cite sup {
  cursor: pointer;
}
cite mark {
  visibility: hidden;
  position: absolute;
  top: 0.5em;
  left: 0;
  z-index: 2;

  /* Additional rules to add some nicety */
  color: var(--g-text);
  background: var(--g-bg);
  border: 4px solid var(--g-border);
  border-radius: 4px;
  padding: 12px;
  min-width: 30vw;
}
cite:hover mark {
  visibility: visible;
}

@media not screen {
  cite mark {
    visibility: visible;
  }
}

Except that's not accessible

For some screenreaders, this may be enough: the visibility rule puts the content of the tooltip into the page without having it initially visible. For Voiceover on macOS, however, the content needs to be rendered somewhere on the page so it can be read out.

Historically, the canonical way to have something render offscreen on a page without using display or visibility has been to position it off either the left or right edge of the screen. This still works, but positioning something off the right of the page is liable to push the scrollbar out, so we use negative left values to shunt the content off to the left; this places it in the DOM, visible to screenreaders, but not visible until we reset the left value.

cite mark {
  position: absolute;
  top: 0.5em;
  left: -9999px;
}
cite:hover mark {
  left: 0;
}

With this positioning in place, Voiceover correctly picks up on the tooltip's presence in the page without having the tooltip render for sighted users, until the sup is hovered.

Screen capture of Chrome and Voiceover
Figure 2: Voiceover reading a section of my ActivityPub article

Positioning on the right

The only remaining issue with these tooltips is what happens when the sup trigger for the tooltip lies towards the right of the screen (anywhere past 70% across from the left of the viewport). In this case, bringing up the tooltip causes it to render with its min-width of 30% of the viewport, causing the right edge to be placed past the right edge of the content, and causing horizontal scroll.

Unfortunately, this is where we have to diverge from a pure-CSS solution, as the following doesn't exist in CSS as it stands:

Hypothetical selector for elements towards the right of the screen

cite[position-right > 70vw] mark {
  left: auto;
  right: 0;
}

Instead, we must resort to JavaScript which can determine the rendered position of any tooltip triggers on the page, after the page has loaded. We can use the querySelectorAll method of the document to query for matching elements, and each matching element has a getBoundingClientRect method which will provide its position and size on the page.

Once we've determined whether the matching element is far enough to the right, we can add or remove a class to the element as appropriate. This can be done by using the corresponding methods of the element's classList.

JavaScript: Tooltip positioner function

window.onload = function() {
  const tooltipPositioner = () => {
    const threshold = window.innerWidth * 0.7;
    document.querySelectorAll('cite').forEach(el => {
      el.classList[
        el.getBoundingClientRect().x > threshold
          ? 'add'
          : 'remove'
      ]('right');
    });
  };

  tooltipPositioner();
};

CSS: Rule for tooltips on the right

cite.right mark {
  left: auto;
  right: 0;
}

Repositioning after resize

One consideration that needs to be made now that some tooltips can have a different behaviour for "is on the right of the screen" is what happens if the user resizes their browser window, and causes some tooltip triggers to move across the viewport as the content reflows. Fortunately, JavaScript offers the ResizeObserver which allows for a function to be run whenever an element resizes; as our positioning code is already in a function, setting up the observer against resizes on the document's main tag is fairly simple:

const tooltipPositioner = () => {
  ...
};
tooltipPositioner();
(new ResizeObserver(tooltipPositioner)).observe(
  document.querySelector('main')
);

It should be noted that the ResizeObserver runs every time the element changes size: if the user is resizing their browser window, this might fire a hundred times before the size of the window settles. For our purposes, as this is the only JavaScript running on the page, performance isn't a particular concern; if this tooltip code were to be used as part of a heavier framework, one may wish to use throttling mechanisms to ensure that the code is only run every so often while still remaining responsive to resize events.

But we almost got away without using any JS at all. With the new popover API rapidly rolling out to browsers as of the time of writing, it may soon be the case that even the CSS used here can be trimmed back.