How to Build an Accessible Tooltip in React

Build an accessible React tooltip that appears on hover and focus, announces itself with role tooltip, and closes on Escape.

6 min read

An accessible React tooltip appears when the user hovers over or focuses a control, and disappears on Escape or when focus leaves. The tooltip itself never takes focus, and a screen reader hears it through the trigger's description.

The state and the id

The tooltip needs two things: a boolean for whether it is showing, and a unique id so the trigger can reference the popup. The useId hook supplies the id.

App.jsxApp.jsx
import { useId, useState } from "react";
 
const id = useId();
const [open, setOpen] = useState(false);

The id is stable across renders and unique per component instance, so several tooltips on a page never collide. The open state is the single source of truth for visibility.

The trigger and the popup

The trigger is a button that shows and hides the tooltip through mouse and keyboard events, and the popup renders only while open.

App.jsxApp.jsx
function Tooltip({ label, children }) {
  const id = useId();
  const [open, setOpen] = useState(false);
 
  return (
    <button className="relative" aria-describedby={open ? id : undefined}
      onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)}
      onFocus={() => setOpen(true)} onBlur={() => setOpen(false)}
      onKeyDown={(event) => { if (event.key === "Escape") setOpen(false); }}>
      {children}
      {open && <span role="tooltip" id={id}>{label}</span>}
    </button>
  );
}

The aria-describedby attribute points the trigger at the tooltip only while it is open, so screen readers announce the label at the right moment. onFocus and onBlur cover keyboard users, onMouseEnter and onMouseLeave cover pointer users, and Escape dismisses the tooltip while focus stays on the button.

Position it with CSS

The popup needs to float above the trigger. A small absolute positioning rule places it just above the button.

csscss
.tooltip {
  position: absolute;
  bottom: calc(100% + 6px);
  left: 50%;
  transform: translateX(-50%);
  white-space: nowrap;
}

The class keeps the tooltip above the trigger and centered. Because the button is relatively positioned, the absolutely positioned popup anchors to it rather than the page. Keep the tooltip close to its trigger and avoid letting it cover the control it describes, so the relationship between them stays obvious.

Keep tooltips non-interactive

A tooltip should never contain buttons or links. Focus stays on the trigger, so interactive content inside a tooltip is unreachable for keyboard users and should move into a dialog instead.

The WAI-ARIA tooltip pattern is still marked as a work in progress, so its core contract stays small: role tooltip on the popup and aria-describedby on the trigger. Everything else is the hover, focus, and Escape behavior this component already adds, and a small hover delay keeps tooltips from flickering during casual mouse movement.

Why this beats the title attribute

The native title attribute does none of this. It ignores keyboard focus, cannot be styled, and appears on its own schedule. A real tooltip is just a small state machine around the semantics described in ARIA in React.

When several widgets need this behavior, rebuilding each one stops being worth it. The headless UI components approach packages exactly this logic so you never hand write it twice.

Rune AI

Rune AI

Key Insights

  • Show the tooltip on mouse hover and keyboard focus.
  • Give the popup role tooltip and a unique id.
  • Point the trigger at it with aria-describedby.
  • Close on Escape, blur, and mouse leave.
  • Keep focus on the trigger, never inside the tooltip.
RunePowered by Rune AI

Frequently Asked Questions

Why not just use the title attribute?

The title attribute is invisible to keyboard users, cannot be styled, and appears inconsistently across browsers. A real tooltip works for hover, focus, and screen readers.

Does the tooltip itself receive focus?

No. Focus stays on the trigger, and the tooltip is announced through aria-describedby. Interactive content inside a tooltip is the wrong pattern and belongs in a dialog instead.

How do I avoid two tooltips sharing an id?

Generate the id with the useId hook, which returns a value unique to the component instance.

Conclusion

An accessible tooltip is a small popup that appears on hover and focus, names itself with role tooltip, links to its trigger with aria-describedby, and closes on Escape or when focus leaves. A few lines of React state wire the whole thing.