affectUI › Components › Feedback
Tooltip
A small dark label for one control – shows on hover after a short wait and at once under keyboard focus, neighbours follow instantly, an optional shortcut inside, flips to stay on screen.
Free and open source (MIT). Install: npx shadcn@latest add https://www.affectui.com/r/tooltip.json · Import from @/components/ui/tooltip
When to use
- The name of an icon-only control, with its shortcut: search ⌘K, filter, pin, share
- The full text of a label that is cut off with an ellipsis – a run name, a file name, a long branch
- One short extra line about a control that already has a visible or accessible name
When not to use
- Anything you can click, select or copy inside it – use the Popover, which takes the focus and stays open
- A list of actions – use the Dropdown Menu
- Information someone needs to finish a task, or the only label of a control: touch screens never show tooltips – put it on the page, or in the control's aria-label
- A status after an action (Copied, Saved) that should be read out – use the Toaster
Usage
import { Search01Icon } from "@hugeicons/core-free-icons";
import { Tooltip } from "@/components/ui/tooltip";
import { IconButton } from "@/components/ui/icon-button";
export function SearchTool({ onSearch }: { onSearch: () => void }) {
return (
// One child element: the Tooltip clones it and adds its handlers, ref and aria-describedby
<Tooltip content="Search runs" shortcut="⌘K" side="bottom">
{/* title="" leaves out the Icon Button's own browser tooltip */}
<IconButton variant="secondary" icon={Search01Icon} label="Search runs" title="" onClick={onSearch} />
</Tooltip>
);
}Props
| Name | Type | Default | Description |
|---|---|---|---|
content | ReactNode | The label – a few words, wraps at 240px. Text only: never a link or button inside. Empty or null renders the child alone | |
shortcut | string | A keyboard shortcut after the label, in a small key chip, e.g. "⌘K" or "⇧⌘S" | |
children | ReactElement | Exactly one element that takes a ref and focus – a button, a link, an Icon Button. Its own handlers and ref keep working; they run first, and preventDefault in one stops the tooltip's | |
side | "top" | "bottom" | "left" | "right" | "top" | Where it shows; flips to the other side when that has more room, and goes above or under when there is no room beside it |
align | "start" | "center" | "end" | "center" | Which edges line up; above and under, start is the left (the right in RTL). Pushed inside the window when it would stick out |
offset | number | 6 | Gap to the trigger in px |
delay | number | 500 | How long the pointer rests on the trigger before it shows, in ms. Keyboard focus, and a neighbour within 300ms of the last one, show it at once |
disabled | boolean | false | Never shows it – e.g. when the label is not cut off |
open | boolean | Controls it from outside, with onOpenChange; leave it out and it opens on its own | |
onOpenChange | (open: boolean) => void | Called whenever it wants to open or close | |
className | string | A class for the label |
Keyboard
- Tab: Focusing the trigger by keyboard shows it at once; moving on hides it
- Esc: Hides it, before anything around it (a dialog, a panel) reacts; it stays hidden until the focus or pointer leaves
- Enter / Space: Clicking the trigger hides it
Accessibility
- role="tooltip"; while it shows, the trigger points to it with aria-describedby (added to any it already has) – a description, never the name
- The name stays on the trigger: an icon-only button needs its own aria-label (the Icon Button's label). When the tooltip only repeats that name, screen readers just get the shortcut from it, so nothing is read twice
- Opens under keyboard focus, never after a click; Escape hides it without moving the focus (WCAG 1.4.13)
- The pointer can move from the trigger onto the label without it closing, so it can be read under a screen magnifier
- Touch has no hover, so a tap never opens it – never put information only in a tooltip
- Only one shows at a time on the page
Responsive
- It lives in the top layer (popover), so a card's overflow or z-index never cuts it; it follows its trigger on scroll and resize
- Always on screen: it flips sides and is pushed inside the window; long text wraps at 240px (never wider than the window)
- A label in another script reads in its own direction (dir="auto")
- Dark mode built in: a light label on dark pages – switches when an ancestor (usually <html>) has the class dark or data-theme="dark"
Requirements
- React 19
Related
- Icon Button – A square button that is only an icon – ghost, outlined or solid, two sizes, a pressed state for toggles and its label as the tooltip
- Popover – A small floating card attached to its trigger – a title and quiet line, any content, flips to stay on screen, closes on a press outside or Escape and hands the focus back
- Dropdown Menu – Actions and choices behind any trigger – icons, shortcuts, checks, red items and submenus, a gliding highlight, type-ahead and the full menu keyboard
- Toaster – Calm toasts from anywhere – a stack that fans out on hover, promise toasts that morph from spinner to check, swipe to dismiss