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.

View as markdownFreeMITSince 0.1.0

Install#

npx shadcn@latest add @pixilui/tooltip

Once per app, add the registry to components.json:

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

Or install from the URL, no namespace needed: npx shadcn@latest add https://pixilui.com/r/tooltip.json

Adds components/pixilui/tooltip.tsx. Needs @base-ui/react, motion, clsx, tailwind-merge.

Preview#

Side
State
Theme
Accent

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
"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#

Use it when

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

Use something else when

  • 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.
Example
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>
  );
}

API#

PropTypeDefaultDescription
childrenrequiredReactElement<{ "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.
labelrequiredstringThe text. Plain text by design: a tooltip never holds links or buttons.
shortcutstringA 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.
offsetnumber6Gap between the trigger and the tooltip, in px.
openbooleanControlled open state, for tours and tests. Pair with onOpenChange.
defaultOpenbooleanfalseStarting open state when uncontrolled.
onOpenChange(open: boolean) => voidRuns whenever the tooltip opens or closes.
containerHTMLElement | nullWhere 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 takes 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#

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