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.
Install#
npx shadcn@latest add @pixilui/tooltipOnce per app, add the registry to 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
- 1
Install the npm packages.
npm install @base-ui/react motion clsx tailwind-merge - 2
Add the items it depends on, the same way.
- 3
Copy each file to the path shown.
"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} </> ); }); - 4
Check that your global stylesheet imports the foundation, after Tailwind.
@import "tailwindcss"; @import "../components/pixilui/foundation.css";
Adds components/pixilui/tooltip.tsx. Needs @base-ui/react, motion, clsx, tailwind-merge.
Preview#
Run the pointer along a toolbar: the first label waits 400ms, then every neighbour appears instantly, like one label sliding along.
Code#
"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.
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#
| Prop | Type | Default | Description |
|---|---|---|---|
childrenrequired | 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. | |
labelrequired | 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 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
- 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.