Tooltip
A hint bubble that reveals supplementary text on hover and on keyboard focus. Decoupled from any particular trigger — attach it to whatever control you pass. It paints the engine-derived overlay surface shared with the menu and popover, sits flush against its anchor, and dismisses on Escape.
Basic
A Tooltip.Trigger reveals the Tooltip.Popup after a short delay on hover, and immediately on keyboard focus. Compose the trigger from a Button via render so it keeps the button's look, hover, and focus.
"use client";
import { Button, Tooltip } from "@stridge/noctis";
export default function TooltipBasic() {
return (
<Tooltip.Root>
<Tooltip.Trigger render={<Button variant="secondary">Hover or focus me</Button>} />
<Tooltip.Popup>Tooltips reveal a short hint on hover and on keyboard focus.</Tooltip.Popup>
</Tooltip.Root>
);
}
Use a tooltip to explain why something exists or how it behaves — not to restate a label that's already visible. It is supplementary by design, so the information it carries must also live somewhere a touch user and a screen reader can reach.
Naming an icon-only control
A tooltip's most common job is to name an icon-only button. Give the control an aria-label (the accessible name screen readers announce), and let the tooltip carry the same label as a visible hint for sighted pointer and keyboard users.
"use client";
import { Button, Tooltip } from "@stridge/noctis";
import { Settings } from "lucide-react";
export default function TooltipIconButton() {
return (
<Tooltip.Root>
<Tooltip.Trigger render={<Button variant="ghost" iconOnly aria-label="Settings" startIcon={Settings} />} />
<Tooltip.Popup>Settings</Tooltip.Popup>
</Tooltip.Root>
);
}
Shortcut hints
Pair a label with its keyboard shortcut so every hover doubles as a reminder of the binding. Compose Kbd inside the popup; it sits on the same overlay surface as a menu, so a ⌘S reads identically in either place. Keep the binding itself live on the control (aria-keyshortcuts) — the tooltip is the visible echo, not the wiring.
"use client";
import { Button, Kbd, Tooltip } from "@stridge/noctis";
import { Save } from "lucide-react";
export default function TooltipShortcutHint() {
return (
<Tooltip.Root>
<Tooltip.Trigger render={<Button variant="ghost" iconOnly aria-label="Save" startIcon={Save} />} />
<Tooltip.Popup>
<span className="flex items-center gap-2">
Save
<Kbd keys="Mod+S" />
</span>
</Tooltip.Popup>
</Tooltip.Root>
);
}
Rich text
A hint can run to a sentence or two and hold inline InlineCode. The bubble fills to a sensible max width before wrapping. Keep it to text and inline marks — a tooltip never holds a focusable or interactive element; reach for a Popover when the content needs a link, a button, or its own focus.
"use client";
import { Button, InlineCode, Tooltip } from "@stridge/noctis";
export default function TooltipRichText() {
return (
<Tooltip.Root>
<Tooltip.Trigger render={<Button variant="secondary">Why disabled?</Button>} />
<Tooltip.Popup>
Saving is paused while a sync is in flight. It resumes once <InlineCode>git status</InlineCode> reports a clean
tree.
</Tooltip.Popup>
</Tooltip.Root>
);
}
Positioning
The bubble opens above the trigger, centered, by default. Steer it with side and align (and sideOffset / alignOffset) on Tooltip.Popup — alignment is direction-aware, so inline-start/inline-end flip under RTL. The bubble flips to the opposite side to avoid a viewport collision, and collisionPadding (default 8) keeps it off the edges.
"use client";
import { Button, Tooltip } from "@stridge/noctis";
export default function TooltipPositioning() {
return (
<div className="flex flex-wrap items-center gap-3">
<Tooltip.Root>
<Tooltip.Trigger render={<Button variant="secondary">Above</Button>} />
<Tooltip.Popup side="top">Opens above the trigger.</Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger render={<Button variant="secondary">Below</Button>} />
<Tooltip.Popup side="bottom">Opens below the trigger.</Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger render={<Button variant="secondary">After</Button>} />
<Tooltip.Popup side="inline-end" align="center">
Opens on the inline-end side — flips under RTL.
</Tooltip.Popup>
</Tooltip.Root>
</div>
);
}
Arrow
Noctis tooltips sit flush by default — a caret only adds noise most of the time. Where a pointer back to the trigger genuinely helps (a cursor-tracked or free-floating hint), compose Tooltip.Arrow inside the popup. It reads the bubble's own surface fill and border and mirrors automatically per side, RTL included; pair it with a slightly larger sideOffset so the tip meets the trigger.
"use client";
import { Button, Tooltip } from "@stridge/noctis";
const SIDES = [
{ side: "top", label: "Top" },
{ side: "bottom", label: "Bottom" },
{ side: "inline-start", label: "Start" },
{ side: "inline-end", label: "End" },
] as const;
export default function TooltipArrow() {
return (
<div className="flex flex-wrap items-center gap-3">
{SIDES.map(({ side, label }) => (
<Tooltip.Root key={side}>
<Tooltip.Trigger render={<Button variant="secondary">{label}</Button>} />
<Tooltip.Popup side={side} sideOffset={8}>
<Tooltip.Arrow />
Points back at its trigger.
</Tooltip.Popup>
</Tooltip.Root>
))}
</div>
);
}
Shared delay
Wrap a cluster of tooltips in a Tooltip.Provider to share one open delay. This is the warmup/cooldown grace window: the first hint waits out the delay, then while any tooltip in the group stays open — and for a short cooldown (timeout) after the last one closes — the next opens instantly. Scrubbing across a toolbar feels immediate after the first hint, without each control waiting out its own delay.
"use client";
import { Button, Tooltip } from "@stridge/noctis";
import { Bold, Italic, Underline } from "lucide-react";
const TOOLS = [
{ label: "Bold", icon: Bold },
{ label: "Italic", icon: Italic },
{ label: "Underline", icon: Underline },
];
export default function TooltipSharedDelay() {
return (
<Tooltip.Provider delay={400}>
<div className="flex items-center gap-1">
{TOOLS.map((tool) => (
<Tooltip.Root key={tool.label}>
<Tooltip.Trigger
render={<Button variant="ghost" iconOnly aria-label={tool.label} startIcon={tool.icon} />}
/>
<Tooltip.Popup>{tool.label}</Tooltip.Popup>
</Tooltip.Root>
))}
</div>
</Tooltip.Provider>
);
}
Keyboard
| Key | Action |
|---|---|
| Tab | Move focus to the trigger — the tooltip opens immediately (no hover delay). |
| Esc | Close the tooltip while keeping focus on the trigger. |
Moving focus away from the trigger (a further Tab, or a click elsewhere) closes the tooltip. A tooltip is supplementary by design: it never traps focus and holds nothing interactive, so it can't be the only place a piece of information or an action lives.
Disabled triggers and touch
Two cases need care, both because the tooltip never gets its open signal:
- Disabled controls don't fire pointer or focus events, so a tooltip attached directly to one never opens — which is a shame, since explaining why a control is disabled is one of the most useful things a tooltip does. Attach the trigger to a focusable wrapper
<span tabIndex={0}>and drop the control's pointer events, so the span catches the hover and keyboard focus while the button still reads as disabled. - Touch has no hover and no persistent focus, so tooltips effectively don't show. Never let a tooltip be the only place information or an action lives; route anything touch-critical to visible text or a
Popover.
"use client";
import { Button, Tooltip } from "@stridge/noctis";
export default function TooltipDisabledTrigger() {
return (
<Tooltip.Root>
{/*
* A disabled control fires no pointer or focus events, so the tooltip would never open on it.
* Attach the trigger to a focusable wrapper span and drop the button's pointer events, so the
* span catches the hover and keyboard focus while the control still reads as disabled.
*/}
<Tooltip.Trigger
render={
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- the wrapper is the tooltip's focusable host for the disabled button
<span tabIndex={0} className="inline-flex rounded-md" />
}
>
<Button disabled className="pointer-events-none">
Publish
</Button>
</Tooltip.Trigger>
<Tooltip.Popup>Resolve the failing checks before you can publish.</Tooltip.Popup>
</Tooltip.Root>
);
}
Anatomy
Compose a tooltip from its parts. Tooltip.Root owns the open state (it accepts every Base UI Tooltip.Root prop — open, defaultOpen, onOpenChange, disabled, trackCursorAxis).
Tooltip.Provider— shares one open/close delay across a group of tooltips; mount it once high in the tree. Optional.Tooltip.Root— owns the open state; renders no element of its own.disableHoverablePopupdefaults totruefor the plain bubble.Tooltip.Trigger— the anchored control. Style it directly or compose another control throughrender. The hoverdelaydefaults to 300ms.Tooltip.Popup— the floating, animated bubble holding the hint. Props:side(defaulttop),align(defaultcenter),sideOffset(default 6),alignOffset,collisionPadding(default 8),arrowPadding,collisionBoundary,sticky.Tooltip.Arrow— an opt-in caret composed inside the popup; absent by default (the bubble is flush).
Every rendered part carries a data-slot (tooltip-trigger, tooltip-popup, tooltip-arrow) for host-side styling. The resolved-position attributes (data-side, data-align) land on both the internal positioner and the popup; pair a slot with the Base UI state attributes (data-open, data-closed, data-instant) to target a part mid-transition.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every tooltip in that region retunes — e.g. .dense { --noctis-tooltip-popup-max-inline-size: 14rem; } narrows every bubble opened beneath it. The bubble's fill, border, and shadow come from the composed Surface (the engine-derived overlay surface), not a minted token. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that only forward to Base UI list just the props they pass through.
Tooltip.Provider
No props of its own — forwards to the underlying Base UI part.
Tooltip.Root
No props of its own — forwards to the underlying Base UI part.