Tooltip
A short floating label surfaced on hover or focus of a Trigger. Composed as a Radix-style compound — Provider, Root, Trigger, Content. The Content positions itself on the side of the Trigger that Root specifies, with a small gap and a directional slide-in. Closes immediately on un-hover, blur, or Escape.
Preview
Installation
npx shadcn@latest add https://craftbits.dev/r/tooltip.jsonUsage
Tooltip is a compound — Provider carries a default delayDuration for every nested Root; Root owns the open-state machine, the hover timers, and the side prop; Trigger is the hover/focus target; Content is the floating label that mounts only while open.
import { Tooltip } from "@craft-bits/core";
<Tooltip.Provider delayDuration={400}>
<Tooltip.Root side="top">
<Tooltip.Trigger>Save</Tooltip.Trigger>
<Tooltip.Content>Save your changes (Cmd-S)</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>Take control of the open state with open + onOpenChange:
"use client";
import { useState } from "react";
import { Tooltip } from "@craft-bits/core";
const [open, setOpen] = useState(false);
<Tooltip.Root open={open} onOpenChange={setOpen}>
{/* ... */}
</Tooltip.Root>Understanding the component
- Compound parts. Provider carries an app-wide
delayDuration; Root owns the open state, hover timers, stable ids, and thesideprop; Trigger renders a real<button type="button">so keyboard users get focus + Enter activation for free; Content mounts only while open and floats relative to the Trigger via absolute positioning. The compound mirrors Radix conventions so the API feels familiar. - Hover with intent.
delayDuration(default 700 ms) keeps the tooltip from flashing on accidental hovers. Leaving the Trigger closes immediately — no close delay, because a tooltip is a label, not a panel the user needs to slide into. - Side-aware positioning.
sideaccepts"top","right","bottom", or"left"(default"top"). Content is absolute-positioned inside the Root container with a translate + offset tuned to clear the Trigger and leave a small gap. The Content also slides in from the Trigger — top-side slides up from below, bottom-side slides down from above, etc. - Controlled and uncontrolled. Pass
openfor fully controlled state, ordefaultOpenfor uncontrolled. The component picks the mode from whichever prop is defined on mount, the React tradition forvalue/defaultValuepairs.onOpenChangefires in either mode. - Focus parity. Focusing the Trigger via keyboard opens the tooltip immediately (no
delayDuration) — the user signalled intent, no need to wait. Blurring closes immediately. Escape closes too, satisfying WAI-ARIA's tooltip dismissal requirement. - ARIA correctness. The Trigger carries
aria-describedbypointing at the Content's id while open; the Content carriesrole="tooltip". Screen readers announce the tooltip text on focus without treating it as a dialog. - Animated reveal. The Content uses Framer Motion
AnimatePresenceso it unmounts cleanly after its exit transition. Opacity + scale + a 4 px directional slide run throughSPRINGS.smoothso timing matches the rest of the library.
Props
Tooltip.Provider
| Prop | Type | Default | Description |
|---|---|---|---|
delayDuration | number | 700 | Default delay in ms before a nested Root opens on hover / focus. |
children | ReactNode | — | Any subtree containing Tooltip.Root instances. |
Tooltip.Root
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state. Pair with onOpenChange. |
defaultOpen | boolean | false | Initial open state — uncontrolled mode only. |
onOpenChange | (open: boolean) => void | — | Fires whenever the open state flips, in either mode. |
delayDuration | number | Provider value, else 700 | Per-instance override of the open delay. |
side | "top" | "right" | "bottom" | "left" | "top" | Which side of the Trigger the Content appears on. |
className | string | — | Merged onto the rendered <div> container. |
...rest | HTMLAttributes<HTMLDivElement> | — | Any other <div> prop. |
Tooltip.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the rendered <button>. |
onMouseEnter / onMouseLeave / onFocus / onBlur | event handlers | — | Run before the timer logic; call event.preventDefault() to suppress. |
...rest | ButtonHTMLAttributes<HTMLButtonElement> | — | Any other <button> prop. |
Tooltip.Content
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the rendered floating <div>. |
...rest | HTMLAttributes<HTMLDivElement> | — | Any other <div> prop. |
Accessibility
- The Trigger is a real
<button type="button">so keyboard users get Enter / Space activation for free and screen readers announce it as "button". aria-describedbyon the Trigger points at the Content's id while open, so screen readers announce the tooltip text alongside the trigger label on focus.- The Content carries
role="tooltip"— the standard ARIA role for a non-modal floating label, distinct fromdialog. - Focusing the Trigger via keyboard opens the tooltip immediately (no
delayDuration) — keyboard users signalled intent and shouldn't wait on a hover timer designed for mouse paths. - Escape closes from anywhere on the page while the tooltip is open, satisfying WAI-ARIA's tooltip dismissal requirement.
- Focus ring uses
--cb-accentand an inset offset so keyboard focus is visible even on a borderless trigger. - The Content carries
pointer-events: noneso it can never intercept clicks meant for the page behind it — a tooltip is read-only chrome. - Motion is short and uses opacity + scale + a small directional slide, all compositor-friendly. Users with
prefers-reduced-motionstill get the open / closed states — only the spring is muted, never the affordance.
Credits
- Extracted from:
algoflashcards(src/platform/ui/tooltip.tsx). The original was a thin wrapper overradix-ui's Tooltip primitive. The craft-bits version drops the runtime dependency on radix-tooltip and rebuilds the Provider, the open-state machine, the hover-intent timer, side-aware positioning, and the Escape-to-dismiss listener in plain React so the component ships without an extra peer.