Skip to content
Uxelith
Sign in
Freev1.0.0Actions

Tooltip

A small hint that springs out of the side facing its element on hover or keyboard focus. Group a toolbar's tooltips and neighbours open at once while one bubble glides between them. Dismissable and hoverable (WCAG 1.4.13).

pnpm dlx shadcn add …/r/tooltip.json
Licence
MIT
Packages
3
Updated
10 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/tooltip.json
  2. It adds these packages for you:

    clsxtailwind-mergemotion
  3. The source lands in your components folder as tooltip.tsx. It's yours: change anything.

Usage

example.tsx
import { Tooltip, TooltipGroup } from "@/components/tooltip"

export function Toolbar() {
  return (
    <TooltipGroup>
      <div role="toolbar" aria-label="Formatting" className="flex gap-1">
        <Tooltip content="Bold (⌘B)">
          <button type="button" aria-label="Bold">B</button>
        </Tooltip>
        <Tooltip content="Italic (⌘I)">
          <button type="button" aria-label="Italic">I</button>
        </Tooltip>
      </div>
    </TooltipGroup>
  )
}

Source

One file, 192 lines, nothing hidden. Read it before you install it.

tooltip.tsx
"use client";

import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";

export interface TooltipProps extends Omit<React.ComponentProps<"span">, "content"> {
  /** The hint. Keep it short and non-essential: touch screens never see it. */
  content: React.ReactNode;
  /** One focusable element, e.g. an icon button. It is described by the tooltip. */
  children: React.ReactElement<{ "aria-describedby"?: string }>;
  side?: "top" | "bottom" | "left" | "right";
  /** Milliseconds the pointer must rest before it opens. Keyboard focus opens it at once. */
  delay?: number;
}

// Motion presets (ADR-010).
const SPRING_SNAPPY = { type: "spring", visualDuration: 0.2, bounce: 0 } as const;
const SPRING_SMOOTH = { type: "spring", visualDuration: 0.35, bounce: 0.15 } as const;

// How long after a tooltip closes its neighbours open without waiting.
const WARM_MS = 500;
// A short grace before closing, so moving to a neighbour swaps the tooltip in one step.
const GRACE_MS = 80;

const placement = {
  top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
  bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
  left: "right-full top-1/2 -translate-y-1/2 mr-2",
  right: "left-full top-1/2 -translate-y-1/2 ml-2",
} as const;

const arrow = {
  top: "left-1/2 top-full -translate-x-1/2 -translate-y-1/2",
  bottom: "left-1/2 bottom-full -translate-x-1/2 translate-y-1/2",
  left: "top-1/2 left-full -translate-x-1/2 -translate-y-1/2",
  right: "top-1/2 right-full translate-x-1/2 -translate-y-1/2",
} as const;

// It grows out of the side that faces its trigger.
const from = {
  top: { y: 6, originX: 0.5, originY: 1 },
  bottom: { y: -6, originX: 0.5, originY: 0 },
  left: { x: 6, originX: 1, originY: 0.5 },
  right: { x: -6, originX: 0, originY: 0.5 },
} as const;

type Group = { active: string | null; open: (id: string) => void; close: (id: string) => void; isWarm: () => boolean; layoutId: string };
const GroupContext = React.createContext<Group | null>(null);

/**
 * Wrap a row of tooltips (a toolbar): once one is open, the next opens at
 * once, and the same bubble glides from trigger to trigger instead of
 * blinking out and in.
 */
export function TooltipGroup({ children }: { children: React.ReactNode }) {
  const [active, setActive] = React.useState<string | null>(null);
  const warmUntil = React.useRef(0);
  const layoutId = React.useId();
  const value = React.useMemo<Group>(
    () => ({
      active,
      layoutId,
      open: (id) => setActive(id),
      close: (id) => {
        warmUntil.current = performance.now() + WARM_MS;
        // Only close if a neighbour hasn't taken over meanwhile.
        setActive((current) => (current === id ? null : current));
      },
      isWarm: () => performance.now() < warmUntil.current,
    }),
    [active, layoutId],
  );
  return (
    <GroupContext.Provider value={value}>
      <LayoutGroup id={layoutId}>{children}</LayoutGroup>
    </GroupContext.Provider>
  );
}

/**
 * A small hint that appears beside an element on hover or keyboard focus,
 * springing out of the side that faces it.
 *
 * - The trigger is described by the tooltip (`aria-describedby`), and the
 *   description is always in the page, so screen readers hear it as soon as
 *   focus lands.
 * - Meets WCAG 1.4.13: Escape dismisses it, and the pointer can move onto it
 *   without it closing.
 * - Inside a `TooltipGroup`, neighbours open without a delay and one bubble
 *   glides between them.
 * - Positioned with CSS on the side you choose; it doesn't flip at screen
 *   edges, so pick a side with room. Instant under reduced motion.
 */
export function Tooltip({ content, children, side = "top", delay = 300, className, ...props }: TooltipProps) {
  const id = React.useId();
  const group = React.useContext(GroupContext);
  const reduceMotion = useReducedMotion();
  const [ownOpen, setOwnOpen] = React.useState(false);
  const open = group ? group.active === id : ownOpen;
  const timer = React.useRef<number | undefined>(undefined);

  const setOpen = React.useCallback(
    (next: boolean) => {
      if (!group) setOwnOpen(next);
      else if (next) group.open(id);
      else group.close(id);
    },
    [group, id],
  );

  const show = (after: number) => {
    window.clearTimeout(timer.current);
    const warm = group && (group.active !== null || group.isWarm());
    if (warm || after === 0) setOpen(true);
    else timer.current = window.setTimeout(() => setOpen(true), after);
  };
  const hide = (after = 0) => {
    window.clearTimeout(timer.current);
    if (after) timer.current = window.setTimeout(() => setOpen(false), after);
    else setOpen(false);
  };

  React.useEffect(() => {
    if (!open) return;
    const onKey = (event: KeyboardEvent) => {
      if (event.key === "Escape") setOpen(false);
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [open, setOpen]);

  React.useEffect(() => () => window.clearTimeout(timer.current), []);

  const describedBy = [children.props["aria-describedby"], id].filter(Boolean).join(" ");
  const instant = reduceMotion ? { duration: 0 } : undefined;
  const { originX, originY, ...offset } = from[side];

  return (
    <span
      className={cn("relative inline-flex", className)}
      data-state={open ? "open" : "closed"}
      onPointerEnter={(event) => {
        if (event.pointerType === "mouse") show(delay);
      }}
      onPointerLeave={() => hide(group ? GRACE_MS : 0)}
      onFocus={(event) => {
        // Keyboard focus only; a click's focus shouldn't pop a hint over what was just clicked.
        if (event.target.matches(":focus-visible")) show(0);
      }}
      onBlur={() => hide()}
      {...props}
    >
      {React.cloneElement(children, { "aria-describedby": describedBy })}
      {/* What assistive technology reads: always present, never shown. */}
      <span id={id} role="tooltip" className="sr-only">
        {content}
      </span>
      <AnimatePresence initial={false}>
        {open && (
          <motion.span
            key="tip"
            aria-hidden="true"
            layoutId={group?.layoutId}
            className={cn(
              "absolute z-50 block w-max max-w-64 rounded-md bg-foreground px-2.5 py-1.5 text-xs font-medium leading-snug text-background shadow-lg text-pretty",
              placement[side],
            )}
            style={{ originX, originY, borderRadius: 6 }}
            initial={{ opacity: 0, scale: 0.85, ...offset }}
            animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
            exit={{ opacity: 0, scale: 0.9, ...offset, transition: instant ?? { duration: 0.1 } }}
            transition={instant ?? SPRING_SNAPPY}
          >
            <motion.span
              key={id}
              layout="position"
              className="relative z-10 block"
              initial={group ? { opacity: 0, filter: "blur(2px)" } : false}
              animate={{ opacity: 1, filter: "blur(0px)" }}
              transition={instant ?? SPRING_SMOOTH}
            >
              {content}
            </motion.span>
            <span className={cn("absolute size-2 rotate-45 rounded-[1px] bg-foreground", arrow[side])} />
          </motion.span>
        )}
      </AnimatePresence>
    </span>
  );
}

Props

PropTypeDefaultDescription
contentReactNode—The hint. Keep it short and non-essential: touch screens never see it.
childrenReactElement—One focusable element; it is described by the tooltip.
side"top" | "bottom" | "left" | "right""top"Where it appears. It doesn't flip at screen edges.
delaynumber300Milliseconds the pointer rests before it opens. Keyboard focus opens it at once.
TooltipGroupcomponent—Wrap a row of tooltips: neighbours skip the delay and one bubble glides between them.

Accessibility

  • The trigger is described by the tooltip with aria-describedby, kept alongside any description it already has; the description is always in the page, so it is read as soon as focus lands.
  • Opens on keyboard focus, not on a click's focus; Escape closes it.
  • The pointer can move onto the hint without it closing (WCAG 1.4.13).
  • Never put essential information in a tooltip: touch users can't open it.
  • Under prefers-reduced-motion it appears and moves without animation.

Changelog

  1. v1.0.0Latest

    First release.

More in Actions

All 4 in Actions
  • ActionsFree

    Copy Button

    Copies text to the clipboard: the icon morphs into a tick (or a cross that shakes if it failed) and a visible label reads "Copied". Announces the result and falls back where the Clipboard API is missing.

  • ActionsFree

    Gradient Button

    A call-to-action button with a tasteful gradient: neighbouring hues, a light that follows the pointer and a sheen that passes now and then. The outline style is quiet until a beam of light runs round its border.

  • ActionsFree

    Magnetic Button

    A button with a gentle magnetic field: it leans toward the pointer as you come near, a few pixels at most, its label following a little further for depth, and settles back without a wobble.