Tooltip
A short, supplementary label shown near its trigger on hover or focus — a transient hint alongside what you are already doing, not something you interact with.
Your plan includes 500 build minutes per cycle.
Usage#
import { Tooltip, TooltipContent, TooltipTrigger } from "neelam-ui";
<Tooltip>
<TooltipTrigger>per cycle</TooltipTrigger>
<TooltipContent>Resets on the 1st of each month.</TooltipContent>
</Tooltip>Sides#
side picks which edge of the trigger the bubble sits on — "top" by default.
It is a preference, not a guarantee: the position is clamped to the viewport, so
a tooltip near an edge shifts rather than getting cut off.
Timing#
Hover waits delayDuration milliseconds (300 by default). Focus shows
immediately. That asymmetry is deliberate: a keyboard user has committed to
the element by focusing it, whereas a pointer sweeping across the screen has
not, and the hover delay exists to absorb exactly that.
<Tooltip delayDuration={0}>…</Tooltip>The trigger is a span#
TooltipTrigger renders <span tabIndex={0}>, not a <button>. A tooltip
often annotates something that is not actionable — a truncated label, a status
icon — and claiming button semantics for content with no click behaviour would
misdescribe it. The tabIndex is what makes it reachable by keyboard at all,
which WAI-ARIA requires: a tooltip must not be mouse-only.
Never put essential information in a tooltip
Tooltips are unavailable on touch, where there is no hover. Anything the user must have to complete the task belongs in the page — a description under a field, not a hint beside it.
Annotating something already interactive#
Putting a real <a> or <button> inside TooltipTrigger's <span> seems
like the obvious way to add a tooltip to an existing control — but it creates
two separate focusable elements for what is really one control: Tab would
stop on the outer <span tabIndex={0}>, then stop again on the <button>
inside it.
Pass asChild instead. It merges the trigger's hover/focus handling and
aria-describedby directly onto your single child element rather than
wrapping it, so that element stays the one and only focusable node it always
was:
<Tooltip>
<TooltipTrigger asChild>
<a href="/settings" aria-label="Settings">
<SettingsIcon />
</a>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>This is exactly how Sidebar's icon-collapsed
SidebarMenuButton and Toolbar's
ToolbarButton attach their own tooltips.
The child must forward its own ref
asChild merges this trigger's ref with the child's — pass a real DOM
element or a component built with forwardRef, the same requirement any
asChild-style API has.
Why it portals#
TooltipContent renders into document.body. TooltipTrigger is inline so a
tooltip can annotate a word in running text — but the bubble is a <div>, and a
<div> inside a <p> is invalid HTML no matter how it is positioned. The
portal fixes that where it can actually be fixed: where the element really lives
in the DOM.
It is also popover="manual" rather than "auto". Top-layer rendering still
comes from the browser — so a tooltip inside a scrolling card is not clipped —
but light dismissal does not, because a tooltip closes on mouse-leave, blur,
Escape, or a scroll, none of which "auto"'s outside-click handling is the
right mechanism for.
Keyboard#
| Key | Behaviour |
|---|---|
| Tab | Moves focus to the trigger, which shows the tooltip immediately. |
| Escape | Dismisses the tooltip, including one opened by hover while focus is elsewhere. |
Accessibility#
- The bubble is
role="tooltip", and the trigger points at it witharia-describedby— so it is announced as a description of the trigger rather than replacing its name. - Focus shows the tooltip with no delay, satisfying the requirement that it be keyboard-reachable rather than hover-only.
Escapedismisses it (WCAG 1.4.13, Content on Hover or Focus), even when the tooltip was opened by the pointer and focus is somewhere else entirely.- The arrow is
aria-hidden; it is decoration, not content. - The tooltip never takes focus itself, so it cannot become a keyboard trap.
API reference#
Tooltip#
| Prop | Type | Default |
|---|---|---|
defaultOpen | boolean | false |
delayDurationMilliseconds to wait before showing on hover. Focus shows immediately — see `DECISIONS.md`. Defaults to `300`. | number | 300 |
onOpenChange | ((open: boolean) => void) | — |
open | boolean | — |
sideWhich side of the trigger to show on. Defaults to `"top"`. | enum | top |
TooltipTrigger#
Renders a <span tabIndex={0}> and accepts every native <span> attribute;
aria-describedby is wired to the bubble for you. Its one prop of its own is
asChild (boolean, default false) — see above
for what it does and when to reach for it.
TooltipContent#
Adds no props of its own beyond the native <div> attributes. It is positioned
and portalled for you; className styles the bubble.