Skip to main content
ASoc
Tutorial

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.

The ASoc Team9 min read

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 Escape and 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 :hover tooltip 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:

ProblemHand-rolled costWhat a library (Radix, Floating UI) solves
Positioning near a viewport edgeManual getBoundingClientRect() math, recomputed on scroll/resizeCollision detection built in, recalculated automatically
Portal rendering (escaping overflow: hidden ancestors)Manual createPortal plumbingHandled by default
Delay groups (multiple tooltips sharing one hover-intent timer)Custom shared state across instancesA documented API (Provider/delayDuration)
Show-on-focus and show-on-hover parityEasy to get half right, as the CSS-only version above showsConsistent by construction
Touch-device fallbackYou decide: tap-to-show, or omit on touchDocumented 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

MistakeSymptomFix
Icon-only button, no aria-label, no visible textScreen reader announces "button" with no namearia-label="What it does"
Reaching for a tooltip library to label an iconExtra dependency, extra client JS, for a one-attribute problemaria-label on the button itself
title attribute as the only accessible nameNot read on focus by every screen reader; invisible on touchPair with aria-label, or use it as a bonus only
CSS :hover-only tooltipInvisible to keyboard-only usersAdd :focus-within (or :focus) alongside :hover
aria-labelledby on a hint that supplements existing textOverwrites the accessible name instead of adding to itaria-describedby for supplementary content
Tooltip content that includes interactive elements (a link, a button)Screen readers and libraries alike handle this inconsistentlyUse 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.

Keep reading

Tutorial7 min read

React UI Libraries: When Hand-Rolling 91 Components Actually Wins

This storefront ships zero UI-library dependencies across 91 hand-rolled components. The real tradeoff — bundle cost against accessibility coverage you skip rebuilding — measured against our own code.

Read more
Tutorial9 min read

Robots.txt and Sitemap.xml in Next.js: One Declared Date, 420 Routes

The STOREFRONT_COPY_REVISED fix for a sitemap that told Google 111 pages changed on a date nothing did, plus the noindex-vs-disallow split this codebase actually uses.

Read more