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.
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.
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:
Dismissible. There's a way to hide it without moving the pointer or the focus. In practice that's the Escape key.
Hoverable. If hover opens it, you can move the mouse onto the tooltip without it disappearing.
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.
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.
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.
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.
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().
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.
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.
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.
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.
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.
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.
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.