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.json

Usage

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

  1. Compound parts. Provider carries an app-wide delayDuration; Root owns the open state, hover timers, stable ids, and the side prop; 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.
  2. 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.
  3. Side-aware positioning. side accepts "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.
  4. Controlled and uncontrolled. Pass open for fully controlled state, or defaultOpen for uncontrolled. The component picks the mode from whichever prop is defined on mount, the React tradition for value / defaultValue pairs. onOpenChange fires in either mode.
  5. 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.
  6. ARIA correctness. The Trigger carries aria-describedby pointing at the Content's id while open; the Content carries role="tooltip". Screen readers announce the tooltip text on focus without treating it as a dialog.
  7. Animated reveal. The Content uses Framer Motion AnimatePresence so it unmounts cleanly after its exit transition. Opacity + scale + a 4 px directional slide run through SPRINGS.smooth so timing matches the rest of the library.

Props

Tooltip.Provider

PropTypeDefaultDescription
delayDurationnumber700Default delay in ms before a nested Root opens on hover / focus.
childrenReactNodeAny subtree containing Tooltip.Root instances.

Tooltip.Root

PropTypeDefaultDescription
openbooleanControlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseInitial open state — uncontrolled mode only.
onOpenChange(open: boolean) => voidFires whenever the open state flips, in either mode.
delayDurationnumberProvider value, else 700Per-instance override of the open delay.
side"top" | "right" | "bottom" | "left""top"Which side of the Trigger the Content appears on.
classNamestringMerged onto the rendered <div> container.
...restHTMLAttributes<HTMLDivElement>Any other <div> prop.

Tooltip.Trigger

PropTypeDefaultDescription
classNamestringMerged onto the rendered <button>.
onMouseEnter / onMouseLeave / onFocus / onBlurevent handlersRun before the timer logic; call event.preventDefault() to suppress.
...restButtonHTMLAttributes<HTMLButtonElement>Any other <button> prop.

Tooltip.Content

PropTypeDefaultDescription
classNamestringMerged onto the rendered floating <div>.
...restHTMLAttributes<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-describedby on 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 from dialog.
  • 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-accent and an inset offset so keyboard focus is visible even on a borderless trigger.
  • The Content carries pointer-events: none so 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-motion still 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 over radix-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.