Accessible Tooltip in React and Tailwind CSS Without a Library

6 min read

TL;DR Give the bubble role="tooltip", point the trigger at it with aria-describedby, and open it on keyboard focus as well as hover. Close it on Escape without moving focus, keep a padded bridge so the mouse can move onto it, skip touch taps, and never put links inside. For icon rails, a CSS-only version with group-hover and group-focus-visible is enough.

On this page
  1. What an accessible tooltip has to do
  2. The CSS-only version for icon rails
  3. A Tooltip component with Escape, delay and a hover bridge
  4. Five bugs that break tooltips
  5. Do you need a library?
  6. FAQ
  7. Should the tooltip text be the label or the description?
  8. Can I put a link inside a tooltip?
  9. Does this work with Tailwind v3?
  10. Where can I see these tooltips in a real app shell?

A tooltip in React needs three things to be accessible: it opens on keyboard focus as well as on hover, the element that triggers it points at it with aria-describedby, and pressing Escape closes it without moving the mouse or the focus. You don't need a library for any of that. This post builds one CSS-only tooltip for icon rails and one small client component (about 80 lines) that also handles Escape, a hover delay, touch screens and the gap between the button and the bubble.

I make qodesign, a set of animated React + TypeScript + Tailwind components. Our Reflow Sidebar folds into a 76 px icon rail, and every icon in the rail shows a tooltip on hover or keyboard focus, so this post comes out of building that. The code here is a simplified version written for this post, not the component's source.

What an accessible tooltip has to do

The rules come from two places. WCAG 2.2 success criterion 1.4.13 Content on Hover or Focus says content that appears on hover or focus has to be:

  1. Dismissible. There's a way to hide it without moving the pointer or the focus. In practice that's the Escape key.
  2. Hoverable. If hover opens it, you can move the mouse onto the tooltip without it disappearing.
  3. Persistent. It stays until the hover or focus goes away, the user dismisses it, or it stops being true.

The ARIA Authoring Practices tooltip pattern adds the markup: the bubble has role="tooltip", the trigger references it with aria-describedby, the tooltip itself never takes focus, and Escape closes it. That page is marked as work in progress, but those parts have been stable for years. MDN's page on the tooltip role says the same: give the trigger its own accessible name and point at the tip with aria-describedby.

Two more rules follow from "it never takes focus". A tooltip can't hold links or buttons; if it needs them, it's a popover or a dialog. And the native title attribute isn't a replacement, because it doesn't show up when you tab to the element.

The CSS-only version for icon rails

When the tooltip text is the button's name, like "Inbox" on an inbox icon, you can put the text inside the button. Screen readers read it as the button's name, and CSS shows it on hover and on keyboard focus. This is how the rail in our sidebar works.

tsx
import type { ReactNode } from 'react';

type RailIconButtonProps = {
  label: string;
  children: ReactNode;
};

export function RailIconButton({ label, children }: RailIconButtonProps) {
  return (
    <button
      type="button"
      className="group relative grid size-10 place-items-center rounded-lg text-zinc-300 hover:bg-white/5 focus-visible:outline-2 focus-visible:outline-lime-400"
    >
      <span aria-hidden="true">{children}</span>
      <span
        role="tooltip"
        className="pointer-events-none absolute left-full top-1/2 ml-3 -translate-y-1/2 whitespace-nowrap rounded-md border border-white/10 bg-zinc-800 px-2.5 py-1.5 text-sm font-medium text-zinc-100 opacity-0 shadow-lg transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100 motion-reduce:transition-none"
      >
        {label}
      </span>
    </button>
  );
}

This is a Server Component, since it has no state. group-hover and group-focus-visible show the bubble when the button is hovered or focused from the keyboard. Using focus-visible instead of focus means a mouse click doesn't leave a tooltip stuck on screen.

The trade-off is that it doesn't meet all of 1.4.13. There's no Escape key, and pointer-events-none means the bubble disappears when the mouse moves onto it. For one-word labels next to an icon that's often an acceptable call, because the bubble doesn't cover anything important. For anything longer, use the component below.

A Tooltip component with Escape, delay and a hover bridge

This one is for tooltips that add information the trigger doesn't already say, like "Anyone with the link can see it" on a Publish button. It takes the trigger as a render prop so it can pass aria-describedby straight to your button.

tsx
'use client';

import { useEffect, useId, useRef, useState, type ReactNode } from 'react';

type Side = 'top' | 'bottom';

export type TriggerProps = { 'aria-describedby': string };

type TooltipProps = {
  content: ReactNode;
  side?: Side;
  delay?: number;
  children: (trigger: TriggerProps) => ReactNode;
};

// The padding is the gap between trigger and bubble. It belongs to the
// tooltip box, so moving the mouse across it never counts as leaving.
const place: Record<Side, string> = {
  top: 'bottom-full pb-2',
  bottom: 'top-full pt-2',
};

export function Tooltip({ content, side = 'top', delay = 300, children }: TooltipProps) {
  const id = useId();
  const [open, setOpen] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

  const show = (wait: number) => {
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setOpen(true), wait);
  };

  const hide = () => {
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setOpen(false), 100);
  };

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== 'Escape') return;
      clearTimeout(timer.current);
      setOpen(false);
    };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [open]);

  useEffect(() => () => clearTimeout(timer.current), []);

  return (
    <span
      className="relative inline-flex"
      onPointerEnter={(e) => {
        if (e.pointerType === 'mouse') show(delay);
      }}
      onPointerLeave={(e) => {
        if (e.pointerType === 'mouse') hide();
      }}
      onFocus={(e) => {
        if (e.target instanceof HTMLElement && e.target.matches(':focus-visible')) show(0);
      }}
      onBlur={hide}
    >
      {children({ 'aria-describedby': id })}
      <span
        className={`absolute left-1/2 z-50 -translate-x-1/2 ${place[side]} ${
          open ? 'visible opacity-100' : 'invisible opacity-0'
        } transition-opacity duration-150 motion-reduce:transition-none`}
      >
        <span
          id={id}
          role="tooltip"
          className="block w-max max-w-xs rounded-md bg-zinc-900 px-2.5 py-1.5 text-sm text-white shadow-lg"
        >
          {content}
        </span>
      </span>
    </span>
  );
}

What each part is for:

  • useId gives a stable id that matches between the server render and hydration, so aria-describedby never points at the wrong element.
  • aria-describedby is always set, even while the tooltip is hidden. Screen readers still read a description from a hidden element, so the button announces "Publish, button, Anyone with the link can see it" whether or not the bubble is visible.
  • The 300 ms delay stops tooltips flashing up as the mouse crosses the page. Keyboard focus opens it at once, because someone who tabbed there is already looking.
  • The padding on the outer span is the hover bridge. It sits between the button and the bubble and belongs to the tooltip, so moving the mouse onto the bubble never fires pointerleave. That's the "hoverable" rule. The 100 ms close delay covers the rest.
  • The Escape listener only exists while the tooltip is open, and it closes the bubble without touching focus. That's the "dismissible" rule.
  • pointerType === 'mouse' skips touch. On phones a tap fires pointer events too, and without this check the tooltip would open on tap and stay open.
  • invisible hides it from the mouse and from layout, and motion-reduce:transition-none drops the fade for people who asked for less motion. There's more on that in prefers-reduced-motion in React.

Using it looks like this:

tsx
'use client';

import { Tooltip } from './Tooltip';

export function PublishButton() {
  return (
    <Tooltip content="Anyone with the link can see it">
      {(trigger) => (
        <button
          type="button"
          {...trigger}
          className="rounded-lg bg-lime-400 px-4 py-2 font-medium text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lime-400"
        >
          Publish
        </button>
      )}
    </Tooltip>
  );
}

PublishButton needs 'use client' because it passes a function to Tooltip, and a Server Component can't pass functions as props to a Client Component. You can still render PublishButton from any server page.

Five bugs that break tooltips

  1. The tooltip is cut off. A parent with overflow-hidden or overflow-auto clips anything positioned outside it, which is common in sidebars and tables. Move the tooltip up the tree, or render the bubble into document.body with createPortal from react-dom and position it from the trigger's getBoundingClientRect().
  2. It never shows on a disabled button. A disabled button can't take keyboard focus, so keyboard users never see its tooltip. Use aria-disabled="true" and ignore the click in your handler instead, or put the explanation in visible text.
  3. It shows up twice. If the tooltip repeats the button's aria-label, screen readers say the same word twice. Either use the CSS-only version, where the text is the name, or make the tooltip say something new.
  4. It sticks on phones. That's the touch tap firing hover. The pointerType check above fixes it, and anything essential shouldn't live only in a tooltip, because touch users won't see it. A hover flip card has the same problem, and 3D Flip Card Animation in React and Tailwind CSS shows the click version to fall back on so phone users still see the back.
  5. Hydration warnings. Ids made with Math.random() differ between server and client. useId is the fix; we covered the general case in fixing hydration errors in Next.js.

Do you need a library?

For the tooltips above, no. Libraries like Radix earn their place when you need collision handling, a bubble that flips sides when it hits the edge of the screen, or tooltips inside scrolling containers. If you're already using shadcn/ui, its tooltip is built on Radix and handles those cases. There's more on keeping shadcn animations accessible in this post on reduced motion and focus.

If you're prompting Cursor, v0 or Lovable for tooltips, name the parts AI tools leave out: role="tooltip", aria-describedby on the trigger, open on focus-visible as well as hover, close on Escape, a hover bridge between trigger and bubble, skip touch, and no links inside the tooltip. Most first drafts only do hover.

FAQ

Should the tooltip text be the label or the description?

If the trigger has no visible text, like an icon button, the tooltip text is usually its name, so put it inside the button or use aria-labelledby. If the trigger already has a name, like a Publish button, the tooltip is extra information, so use aria-describedby.

No. A tooltip never takes focus, so keyboard users can't reach anything inside it. Use a popover or a small dialog that opens on click instead.

Does this work with Tailwind v3?

Yes. Every class here exists in Tailwind v3.4 and later, including group-focus-visible, size-10 and motion-reduce. One difference: in v3, add focus-visible:outline next to focus-visible:outline-2 so the ring is solid. In v4, outline-2 sets the style for you.

Where can I see these tooltips in a real app shell?

The Reflow Sidebar collapses into an icon rail with a tooltip on every icon, the unread badge turns into a dot and the plan card becomes a credits ring. There's a walkthrough in our React reflow sidebar guide, and if you're using the shadcn sidebar, see collapsible icon mode with a cookie in Next.js.