A naive accessible CSS tooltips implementation

A naive accessible CSS tooltips implementation

• Reading time: 6 minutes

Looking for a deep rabbit hole? A tooltip system covering all edge cases is exactly what you are looking for.

That said, we will work on the opposite here: how to implement the most basic accessible tooltip system in pure HTML/CSS. The title HTML attribute will not be covered, but that would in fact be the absolute winner in a "simplest tooltip system" contest.

If you want to skip the explanations and get directly to the code, head to the full CSS implementation section.

What do we need? #

Our system will leverage both the data-attributes HTML specification and the WAI standard. Meaning, a lot of aria-label and data-tooltip are to be seen below.

First, the HTML markup #

We'll create a simple aria-label on the desired element: a button containing an SVG icon.

<button aria-label="RSS Feed">
  <svg aria-hidden="true">
    <use href="/public/images/icons/rss.svg#icon" />
  </svg>
</button>

Do not forget to set an aria-hidden="true" attribute on decorative icons. Otherwise, they will be announced by screen readers regardless of their uselessness.

Now that we have an accessible button, a data-tooltip attribute needs to be added in order to flag elements on which we want to display a tooltip.

<button aria-label="RSS Feed" data-tooltip="">
  <svg aria-hidden="true">
    <use href="/public/images/icons/rss.svg#icon" />
  </svg>
</button>

Everything is in place; we'll go on with the CSS.

Then, a minimalist stylesheet #

[aria-label][data-tooltip] {
  position: relative;
}

[aria-label][data-tooltip]::after {
  --offset: 8px;

  content: attr(aria-label);
  position: absolute;
  top: calc(100% + var(--offset));
  left: 50%;
  transform: translateX(-50%);
  width: max-content;
  padding: 6px 10px;
  color: black;
  background-color: white;
  font-size: 15px;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 160ms;
  z-index: 10;
}

These rules are laying the foundations of our tooltip system. --offset variable can be adjusted to fit your need. Same for padding, color, background-color, font-size and transition properties.

A last required set of rules needs to be defined in order to make everything work.

@media not (hover: hover) {
  [aria-label][data-tooltip]::after {
    content: none;
  }
}

[aria-label][data-tooltip]:is(:active, :focus:not(:focus-visible))::after {
  content: none;
}

@media (hover: hover) {
  [aria-label][data-tooltip]:is(:hover, :focus-visible)::after {
    opacity: 1;
  }
}

The @media not (hover: hover) media query disables the tooltip system on touch devices.

:is(:active, :focus:not(:focus-visible)) also disables the tooltip on an active or focused element but ignore keyboard-focused elements.

The last rule wrapped inside the @media (hover: hover) simply makes the tooltip associated with the hovered element visible.

We're good to go: we now have a really basic system displaying a bottom tooltip on hovered elements having both an aria-label & data-tooltip attributes.

Going further #

As our tooltips are always displayed at the bottom, we may face problems: tooltips can be shown outside the viewport. It can easily be circumvented by adding a direction to our data-tooltip attributes when needed.

Supposing our button is displayed in the footer of your website, add a top value in the existing data-tooltip attribute.

<button aria-label="RSS Feed" data-tooltip="top">
  <svg aria-hidden="true">
    <use href="/public/images/icons/rss.svg#icon" />
  </svg>
</button>

The following CSS rules handle displaying tooltips in all directions.

[aria-label][data-tooltip="top"]::after {
  bottom: calc(100% + var(--offset));
  top: auto;
}

[aria-label][data-tooltip="left"]::after {
  right: calc(100% + var(--offset));
  left: auto;
}

[aria-label][data-tooltip="right"]::after {
  left: calc(100% + var(--offset));
  right: auto;
}

[aria-label][data-tooltip="top-left"]::after {
  top: unset;
  bottom: calc(100% + var(--offset));
  right: 0;
  left: unset;
  transform: none;
}

[aria-label][data-tooltip="top-right"]::after {
  top: unset;
  bottom: calc(100% + var(--offset));
  left: 0;
  transform: none;
}

[aria-label][data-tooltip="bottom-left"]::after {
  top: calc(100% + var(--offset));
  bottom: unset;
  left: 0;
  transform: none;
}

[aria-label][data-tooltip="bottom-right"]::after {
  top: calc(100% + var(--offset));
  bottom: unset;
  right: 0;
  transform: none;
}

It also relies on the --offset custom property providing a uniform positioning across tooltips displayed in every direction.

This is it, a complete naive tooltip system with ~85 lines of CSS. It is framework agnostic and won't require any maintenance.

And of course, the caveats #

With these 85 lines, we are far from the Radix UI or Base UI implementation.

We have indeed no collision detection, automatic positioning, or accessibility announcements when displayed.

That said, both collision and positioning should not be vital, as you should keep the length of your tooltip to the shortest possible. Be concise; nobody wants to read a whole novel when hovering over a button or a link. Tooltips should be used to give extra context on an element, not to explain everything about it.

The full CSS implementation #

In case you want to copy/paste the full implementation, here it is:

[aria-label][data-tooltip] {
  position: relative;
}

[aria-label][data-tooltip]::after {
  --offset: 8px;

  content: attr(aria-label);
  position: absolute;
  top: calc(100% + var(--offset));
  left: 50%;
  transform: translateX(-50%);
  width: max-content;
  padding: 6px 10px;
  color: black;
  background-color: white;
  font-size: 15px;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 160ms;
  z-index: 10;
}

@media not (hover: hover) {
  [aria-label][data-tooltip]::after {
    content: none;
  }
}

[aria-label][data-tooltip]:is(:active, :focus:not(:focus-visible))::after {
  content: none;
}

@media (hover: hover) {
  [aria-label][data-tooltip]:is(:hover, :focus-visible)::after {
    opacity: 1;
  }
}

[aria-label][data-tooltip="top"]::after {
  bottom: calc(100% + var(--offset));
  top: auto;
}

[aria-label][data-tooltip="left"]::after {
  right: calc(100% + var(--offset));
  left: auto;
}

[aria-label][data-tooltip="right"]::after {
  left: calc(100% + var(--offset));
  right: auto;
}

[aria-label][data-tooltip="top-left"]::after {
  top: unset;
  bottom: calc(100% + var(--offset));
  right: 0;
  left: unset;
  transform: none;
}

[aria-label][data-tooltip="top-right"]::after {
  top: unset;
  bottom: calc(100% + var(--offset));
  left: 0;
  transform: none;
}

[aria-label][data-tooltip="bottom-left"]::after {
  top: calc(100% + var(--offset));
  bottom: unset;
  left: 0;
  transform: none;
}

[aria-label][data-tooltip="bottom-right"]::after {
  top: calc(100% + var(--offset));
  bottom: unset;
  right: 0;
  transform: none;
}