# Tooltip

A short text label for an icon button, with its keyboard shortcut, that waits 400ms and then opens neighbours instantly while you scan a toolbar.

Tier: free. Type: component. Category: overlays. Tags: keyboard, motion.

## Install

```bash
npx shadcn@latest add @pixilui/tooltip
```

One-time setup: add the registry to `components.json`:

```json
{
  "registries": {
    "@pixilui": "https://pixilui.com/r/{name}.json"
  }
}
```

Files: `components/pixilui/tooltip.tsx`. npm: `@base-ui/react`, `motion`.

## Preview

Live preview: https://pixilui.com/components/tooltip

Knobs:

- `side`: `top`, `bottom`
- `state`: `open`, `closed`

Signature: Run the pointer along a toolbar: the first label waits 400ms, then every neighbour appears instantly, like one label sliding along.

## Code

`components/pixilui/tooltip.tsx`

```tsx
"use client";

import {
  cloneElement,
  createContext,
  forwardRef,
  useCallback,
  useContext,
  useId,
  useState,
  useSyncExternalStore,
  type ComponentPropsWithoutRef,
  type ReactElement,
  type ReactNode,
} from "react";
import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps } from "motion/react";
import { cn } from "./lib/utils";
import { instantTransition, motionTokens, reducedTransition } from "./motion-tokens";

export type TooltipSide = "top" | "bottom" | "left" | "right";
export type TooltipAlign = "start" | "center" | "end";

export type TooltipProps = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
  /** The element the tooltip describes, usually an icon Button. Give it its own aria-label; the tooltip is a visual label. */
  children: ReactElement<{ "aria-describedby"?: string; "aria-label"?: string }>;
  /** The text. Plain text by design: a tooltip never holds links or buttons. */
  label: string;
  /** A keyboard shortcut shown after the label in mono, such as "⌘K". */
  shortcut?: string;
  /** Preferred side of the trigger. It flips when there is no room. */
  side?: TooltipSide;
  /** Alignment along that side. */
  align?: TooltipAlign;
  /** Gap between the trigger and the tooltip, in px. */
  offset?: number;
  /** Controlled open state, for tours and tests. Pair with onOpenChange. */
  open?: boolean;
  /** Starting open state when uncontrolled. */
  defaultOpen?: boolean;
  /** Runs whenever the tooltip opens or closes. */
  onOpenChange?: (open: boolean) => void;
  /** Where the tooltip portals to. Defaults to document.body. Pass an element (from a callback ref kept in state) to keep it inside a region; null waits for it. */
  container?: HTMLElement | null;
};

/** Hover this long before a tooltip opens. */
const OPEN_DELAY = 400;
/** A neighbour opened within this long of the last close opens at once. */
const GROUP_TIMEOUT = 300;

const TooltipGroupContext = createContext(false);

export type TooltipProviderProps = {
  /** The tooltips that share the instant re-open, such as one toolbar. */
  children?: ReactNode;
};

/**
 * Groups tooltips: after one closes, moving to a neighbour within 300ms opens it at once
 * instead of waiting 400ms again. A lone Tooltip groups itself.
 */
export function TooltipProvider({ children }: TooltipProviderProps) {
  return (
    <BaseTooltip.Provider delay={OPEN_DELAY} timeout={GROUP_TIMEOUT}>
      <TooltipGroupContext.Provider value>{children}</TooltipGroupContext.Provider>
    </BaseTooltip.Provider>
  );
}

const coarseQuery = "(pointer: coarse)";
const subscribeCoarse = (onChange: () => void) => {
  const list = window.matchMedia(coarseQuery);
  list.addEventListener("change", onChange);
  return () => list.removeEventListener("change", onChange);
};
const readCoarse = () => window.matchMedia(coarseQuery).matches;
const readCoarseOnServer = () => false;

export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(function Tooltip(
  {
    children,
    label,
    shortcut,
    side = "top",
    align = "center",
    offset = 6,
    open: openProp,
    defaultOpen = false,
    onOpenChange,
    container,
    className,
    ...rest
  },
  forwardedRef,
) {
  const reduced = useReducedMotion() ?? false;
  const coarse = useSyncExternalStore(subscribeCoarse, readCoarse, readCoarseOnServer);
  const grouped = useContext(TooltipGroupContext);
  const [innerOpen, setInnerOpen] = useState(defaultOpen);
  const open = (openProp ?? innerOpen) && !coarse;
  const descriptionId = useId();

  const setOpen = useCallback(
    (next: boolean) => {
      setInnerOpen(next);
      onOpenChange?.(next);
    },
    [onOpenChange],
  );

  // Base UI leaves tooltips out of the accessibility tree, so the text is also wired to the
  // trigger as a description, unless it would only repeat the trigger's own name.
  const text = shortcut ? `${label} (${shortcut})` : label;
  const ownName = children.props["aria-label"];
  const describe = ownName !== label && ownName !== text;
  const describedBy = describe ? [children.props["aria-describedby"], descriptionId].filter(Boolean).join(" ") : children.props["aria-describedby"];
  const description = describe ? (
    <span id={descriptionId} hidden>
      {text}
    </span>
  ) : null;

  // Touch screens never hover, so render the trigger alone. The description stays for screen readers.
  if (coarse) {
    return (
      <>
        {cloneElement(children, { "aria-describedby": describedBy })}
        {description}
      </>
    );
  }

  const enter = reduced
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
    : {
        initial: { opacity: 0, scale: motionTokens.scale.enterFrom },
        animate: { opacity: 1, scale: 1 },
        exit: { opacity: 0, transition: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } },
      };

  const tooltip = (
    <BaseTooltip.Root open={open} onOpenChange={(next) => setOpen(next)}>
      <BaseTooltip.Trigger render={children} aria-describedby={describedBy} />
      <AnimatePresence>
        {open ? (
          <BaseTooltip.Portal keepMounted container={container}>
            <BaseTooltip.Positioner side={side} align={align} sideOffset={offset} className="z-50">
              <BaseTooltip.Popup
                ref={forwardedRef}
                data-slot="tooltip"
                data-state={open ? "open" : "closed"}
                className={cn(
                  "flex max-w-64 origin-(--transform-origin) items-baseline gap-2 rounded-control bg-surface-inverted px-2.5 py-1.5 font-body text-12 font-medium text-inverted shadow-raised",
                  className,
                )}
                render={(props, state) => (
                  <motion.div
                    {...(props as HTMLMotionProps<"div">)}
                    {...enter}
                    // A grouped re-open is instant, like the first tooltip never closed.
                    transition={
                      state.instant
                        ? instantTransition
                        : reduced
                          ? reducedTransition
                          : { duration: motionTokens.duration.quick, ease: motionTokens.ease.enter }
                    }
                  />
                )}
                {...rest}
              >
                <span className="min-w-0 text-pretty">{label}</span>
                {shortcut ? (
                  <kbd data-slot="tooltip-shortcut" className="shrink-0 font-mono text-12 font-normal text-inverted/60">
                    {shortcut}
                  </kbd>
                ) : null}
              </BaseTooltip.Popup>
            </BaseTooltip.Positioner>
          </BaseTooltip.Portal>
        ) : null}
      </AnimatePresence>
    </BaseTooltip.Root>
  );

  return (
    <>
      {grouped ? tooltip : <TooltipProvider>{tooltip}</TooltipProvider>}
      {description}
    </>
  );
});
```

## Usage

When to use:

- Naming icon buttons in a composer, a toolbar or a table row: Attach files, Dictate, Retry run.
- Teaching the keyboard shortcut for an action people repeat.

When not to use:

- Anything a person must read to proceed: write it on the page, because touch screens never show tooltips.
- Rich content, links or buttons: use a popover.
- Validation or status: use the field's error or a toast.

```tsx
import { useState } from "react";
import { ArrowUp, AtSign, Mic, Paperclip } from "lucide-react";
import { Button } from "@/components/pixilui/button";
import { Tooltip, TooltipProvider } from "@/components/pixilui/tooltip";

// The first option of each knob is the demo's default; the docs preview starts there.
export const knobs = {
  side: ["top", "bottom"],
  state: ["open", "closed"],
} as const;

const tools = [
  { label: "Attach files", shortcut: "⌘U", icon: <Paperclip strokeWidth={1.5} /> },
  { label: "Mention a teammate", shortcut: "@", icon: <AtSign strokeWidth={1.5} /> },
  { label: "Dictate", icon: <Mic strokeWidth={1.5} /> },
];

export default function TooltipDemo({ side = "top", state = "open" }: KnobValues<typeof knobs>) {
  // Tooltips portal into this stage, so they keep the preview's theme and accent.
  const [stage, setStage] = useState<HTMLDivElement | null>(null);

  return (
    <div ref={setStage} className="relative w-full max-w-lg px-2 py-16">
      <TooltipProvider key={`${side}-${state}`}>
        <div className="flex flex-col gap-3 rounded-panel bg-surface p-3 shadow-raised hairline">
          <p className="px-1.5 pt-1 text-14 text-muted">Ask the support agent about ticket 2291</p>
          <div className="flex items-center gap-1">
            {tools.map((tool, index) => (
              <Tooltip key={tool.label} label={tool.label} shortcut={tool.shortcut} side={side} container={stage} defaultOpen={state === "open" && index === 0}>
                <Button variant="ghost" size="sm" aria-label={tool.label} icon={tool.icon} />
              </Tooltip>
            ))}
            <Tooltip label="Send" shortcut="⌘↵" side={side} container={stage}>
              <Button size="sm" aria-label="Send" icon={<ArrowUp strokeWidth={1.5} />} className="ms-auto rounded-pill" />
            </Tooltip>
          </div>
        </div>
      </TooltipProvider>
    </div>
  );
}
```

## Examples

None.

## API

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `ReactElement<{ "aria-describedby"?: string; "aria-label"?: string }>` |  | The element the tooltip describes, usually an icon Button. Give it its own aria-label; the tooltip is a visual label. |
| `label` (required) | `string` |  | The text. Plain text by design: a tooltip never holds links or buttons. |
| `shortcut` | `string` |  | A keyboard shortcut shown after the label in mono, such as "⌘K". |
| `side` | `"top" \| "bottom" \| "left" \| "right"` | `"top"` | Preferred side of the trigger. It flips when there is no room. |
| `align` | `"start" \| "center" \| "end"` | `"center"` | Alignment along that side. |
| `offset` | `number` | `6` | Gap between the trigger and the tooltip, in px. |
| `open` | `boolean` |  | Controlled open state, for tours and tests. Pair with onOpenChange. |
| `defaultOpen` | `boolean` | `false` | Starting open state when uncontrolled. |
| `onOpenChange` | `(open: boolean) => void` |  | Runs whenever the tooltip opens or closes. |
| `container` | `HTMLElement \| null` |  | Where the tooltip portals to. Defaults to document.body. Pass an element (from a callback ref kept in state) to keep it inside a region; null waits for it. |

Also accepts every native `<div>` attribute, and a ref to it.

## Keyboard

- `Tab`: Focusing the trigger with the keyboard opens its tooltip; moving on closes it.
- `Esc`: Closes the tooltip and leaves focus on the trigger.

## Accessibility

- The tooltip is a visual label; the trigger keeps its own aria-label, which should match the tooltip text.
- Base UI leaves tooltips out of the accessibility tree, so the component wires the text (and shortcut) to the trigger with aria-describedby, and skips it when it would only repeat the trigger's name.
- On coarse pointers it renders nothing: touch has no hover, so the label must never be the only place information lives.
- Text only by design, so nothing focusable is ever trapped inside it.

## Motion

- Opens after 400ms of hover, scaling from 0.96 out of the trigger in 150ms on the enter ease, with transform-origin following its side.
- Within 300ms of one tooltip closing, the next opens at once with no animation, so scanning a toolbar feels like one label moving.
- Closes in 120ms. Reduced motion: fade only.

## Responsive

- Wraps past 256px; keep labels to a few words.
- Flips to the other side when there is no room.
- Absent on touch screens by design.

## Performance

- Nothing is mounted until a tooltip opens; one Base UI provider per group shares the timers.

## Notes for AI

- Wrap the trigger: <Tooltip label="Attach files" shortcut="⌘U"><Button aria-label="Attach files" icon={...} /></Tooltip>.
- Wrap a toolbar in TooltipProvider so neighbours re-open instantly; a lone Tooltip groups itself.
- label is a plain string; for anything richer use popover.
- Never put information only in a tooltip: it does not exist on touch screens.

## Related

- [Popover](https://pixilui.com/components/popover/markdown): A small panel anchored to a control for a closer look or a quick change: a run's budget, a model's settings, who can see a thread.
- [Button](https://pixilui.com/components/button/markdown): A button whose label can change without a jump: the width springs to the new words while they swap in place.
