React Tooltip: When 38 aria-label Attributes Beat a Library
38 aria-label attributes, zero tooltip libraries. What this codebase actually uses to name icon-only buttons, and the one place a real tooltip earns its keep.
A React tooltip library earns its place when you need rich, positioned, keyboard-dismissible hover content — a chart legend, a truncated-text preview, a rich-media hint. It is the wrong tool for the far more common case: naming an icon-only button. Grep this codebase's 91 components and there are 38 aria-label attributes and zero tooltip components, zero tooltip libraries, and exactly one place a native tooltip shows up at all. That split is the actual answer to "how do I add a tooltip in React," most of the time.
What "needs a tooltip" usually means
Every icon-only control in this codebase — the download button on a product card, the carousel arrows, the social links in the footer, the wishlist heart — looks like it needs a tooltip, because a bare icon carries no text. TemplateGallery's carousel arrows are typical:
// src/components/molecules/TemplateGallery.tsx
<button
aria-label="Previous screenshot"
onClick={() => go(index - 1)}
type="button"
>
<svg aria-hidden="true" ...>{/* chevron path */}</svg>
</button>
aria-label gives the button an accessible name a screen reader announces — "Previous screenshot, button" — without putting visible text in the 40px circle the design calls for. That's the whole requirement met, with no visual hover state, no JavaScript, and no library. SocialLinks.tsx does the identical thing for the footer's Discord/X/GitHub icons, reading the label straight off the data file: aria-label={link.ariaLabel}. DownloadMenu's icon variant is conditional on it: aria-label={isIcon ? label : undefined} — the label only applies when the button is rendering as a bare icon; the text-button variant already has visible words, so adding a redundant aria-label there would be noise.
The one file that does show a tooltip
WishlistButton.tsx is the only component in this codebase that pairs aria-label with a native title attribute on an interactive element:
// src/components/molecules/WishlistButton.tsx
<button
type="button"
aria-pressed={saved}
aria-label={saved ? `Remove ${entry.name} from saved` : `Save ${entry.name}`}
title={saved ? `Remove ${entry.name} from saved` : `Save ${entry.name}`}
disabled={!hydrated}
...
>
The browser's native title attribute is a tooltip — hover a heart icon long enough and the OS draws a small yellow box with the string. It's free, and it's exactly what most "how do I add a tooltip" tutorials reach for first. It's also why title sits alongside aria-label here rather than replacing it: title is not read by every screen reader on focus, it's invisible on a touch screen (there's no hover), and there's no way to trigger it with the keyboard to preview it before acting. aria-label covers the accessible-name requirement unconditionally; title is the sighted-mouse-user's bonus, not the accessibility mechanism.
The other nine title= occurrences in this codebase are not tooltips at all — they're the accessible name required on every <iframe> (PreviewModal's title={`${title} preview`}) or a plain <img title> mirroring existing alt text. Grepping for title= and assuming "tooltip" would have overcounted by nine.
When you actually need a library
None of the above is a tooltip in the "hover to see supplementary content" sense — it's an accessible-name problem with a one-attribute fix. A real tooltip earns its keep when:
- The content is genuinely supplementary, not a label — a chart data point's exact value, a truncated string's full text, a keyboard shortcut hint next to a button that already has visible text.
- It needs to be dismissed with
Escapeand repositioned when it would overflow the viewport — collision detection is the actual hard part of a tooltip implementation, and it's what a library like Radix or Floating UI is for. - It has to work identically on focus and hover — a pure-CSS
:hovertooltip is invisible to keyboard users, which is the single most common tooltip bug on the web.
This codebase has none of those cases yet. Every icon that needs a name has one via aria-label, and nothing currently shows numeric or truncated data that would benefit from a hover preview. If that changes — a dashboard chart is the obvious future candidate — reaching for a positioned-tooltip library at that point is the right call, not a rejection of libraries on principle. We measure that hand-rolled-vs-library trade for this codebase's other 91 components here; the short version is the same: build the six lines an icon button needs, reach for a library the day you need real collision detection.
A minimal accessible tooltip, if you do build one by hand
For the narrow case of a short, non-interactive hint on a focusable element, aria-describedby is the correct primitive — not title, and not a <div> that only appears on :hover:
function IconWithHint({ hint, children }: { hint: string; children: ReactNode }) {
const id = useId();
return (
<span className="group relative inline-block">
<button aria-describedby={id} type="button">
{children}
</button>
<span
id={id}
role="tooltip"
className="invisible absolute ... group-hover:visible group-focus-within:visible"
>
{hint}
</span>
</span>
);
}
aria-describedby — not aria-labelledby — because a tooltip adds description, it doesn't replace the button's own accessible name. group-focus-within:visible alongside group-hover:visible is what makes it appear for keyboard focus, not just a mouse hover; drop that half and the tooltip becomes invisible to exactly the users native title already fails.
What a tooltip library actually buys you, itemized
When the day comes that this codebase does need one — a dashboard chart is the likeliest candidate — the decision isn't "library vs. no library" in the abstract, it's which specific problem justifies the dependency:
| Problem | Hand-rolled cost | What a library (Radix, Floating UI) solves |
|---|---|---|
| Positioning near a viewport edge | Manual getBoundingClientRect() math, recomputed on scroll/resize | Collision detection built in, recalculated automatically |
Portal rendering (escaping overflow: hidden ancestors) | Manual createPortal plumbing | Handled by default |
| Delay groups (multiple tooltips sharing one hover-intent timer) | Custom shared state across instances | A documented API (Provider/delayDuration) |
| Show-on-focus and show-on-hover parity | Easy to get half right, as the CSS-only version above shows | Consistent by construction |
| Touch-device fallback | You decide: tap-to-show, or omit on touch | Documented behavior, still requires a decision either way |
Every one of those is a real cost when the content is genuinely supplementary and positioning has to survive an unpredictable viewport. None of them is a cost this codebase is currently paying, because nothing here needs a tooltip in that sense yet — every icon-only control just needs a name, and aria-label gives it one for free.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Icon-only button, no aria-label, no visible text | Screen reader announces "button" with no name | aria-label="What it does" |
| Reaching for a tooltip library to label an icon | Extra dependency, extra client JS, for a one-attribute problem | aria-label on the button itself |
title attribute as the only accessible name | Not read on focus by every screen reader; invisible on touch | Pair with aria-label, or use it as a bonus only |
CSS :hover-only tooltip | Invisible to keyboard-only users | Add :focus-within (or :focus) alongside :hover |
aria-labelledby on a hint that supplements existing text | Overwrites the accessible name instead of adding to it | aria-describedby for supplementary content |
| Tooltip content that includes interactive elements (a link, a button) | Screen readers and libraries alike handle this inconsistently | Use a popover/menu pattern instead — tooltips are for text |
Frequently asked questions
Is aria-label enough, or should icon buttons also have visible text?
aria-label is enough for accessibility compliance, but visible text is friendlier when there's room for it — this codebase's DownloadMenu uses a text-and-icon button in its non-icon variant for exactly that reason, reserving the bare-icon form for tight spaces like a card's corner.
Why not just use the title attribute everywhere and skip the library entirely?
Because title support for screen readers is inconsistent (some announce it on focus, many don't), it never appears on a touch device, and it can't be triggered by keyboard to preview before activating a button. It's a reasonable bonus for sighted mouse users, which is exactly how this codebase's one usage of it is scoped — never the sole mechanism for an accessible name.
Do native HTML tooltips (the title attribute) have any real advantages?
Zero JavaScript, zero layout cost, and they respect the OS's tooltip delay and styling automatically. They're the right choice when the hint is genuinely optional flavor text for mouse users and something else (usually aria-label) already carries the accessible name — which is the WishlistButton pattern above.
What's the actual bundle cost of skipping a tooltip library?
We haven't shipped one, so there's no number to report from a removal — the honest framing is the 38 aria-label attributes this measured this firing are the entire "tooltip" surface of the app today, at zero added kilobytes. Astro vs. Next.js has the comparable exercise for react-dom itself, if the question is really "what does a dependency cost."
Templates where this pattern already ships
ASoc Apex Admin is a large multi-purpose admin — 5 dashboards and a full ecommerce back office — with the icon-button density where this pattern matters most. ASoc Clover Admin is CRM-focused, spanning sales, finance and team dashboards plus email and chat, another surface dense with icon-only actions. ASoc Pulse Admin rounds out the set as a commerce-ops dashboard with its own 5-dashboard back office.
Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates. For the same hand-rolled-vs-library question applied across the whole component tree, read when hand-rolling 91 components actually wins, and for the matching WAI-ARIA-pattern audit on this codebase's own components, React tabs: six requirements, three missing and the React accordion fix that shipped with this post.
