Components

Getting started

Components144

More

A name and shortcut for an icon. The first one waits; after that they glide from trigger to trigger like one label travelling along the toolbar.

Used forIcon toolbarsDense tablesNavigation railsDisabled actions

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/tooltip.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • Icon toolbars

    Names and shortcuts for Bold, Italic, Link.

  • Dense tables

    Explaining a truncated value or an icon column.

  • Navigation rails

    Labels for a collapsed sidebar.

  • Disabled actions

    Saying why something can't be done yet.

What it handles for you

A row of tooltips that each wait and fade on their own feels like flicker. Shared warm state makes them one surface that moves.

Instead of wiring upRadix TooltipTooltipProvider delay groupsfloating-ui
  1. Warm glide

    Within 450ms of the last one, the next shows at once and slides over from its position.

  2. Placement

    Flips to the other side and shifts to stay 8px inside the window.

  3. Shortcuts

    A kbd after the label, dimmed.

  4. Accessible

    role="tooltip" and aria-describedby on the trigger; focus-visible shows it.

Overview

Tooltips usually fade in one by one, each with its own delay, so running along a toolbar flickers. Here the first one rises off its trigger after a short rest; while it is warm, the next one shows at once and glides over from where the last one was. A press, Escape or leaving hides it, keyboard focus shows it, and touch never does.

States

first
After 500ms on the trigger it rises 6px from the side it sits on, out of a 4px blur, from 0.96 (320ms).
warm
The next trigger's tooltip shows at once and glides from the previous position (300ms).
hidden
Leaving, a press or Escape fades it (140ms).

Usage

usage.tsx
import { Tooltip } from "@/components/ui/tooltip";<Tooltip content="Archive" shortcut="E">  <button aria-label="Archive"><ArchiveIcon /></button></Tooltip>

Props

PropTypeDescription
childrenReactElementThe trigger; it gets aria-describedby while showing.
contentReactNodeThe text.
shortcutstringA key or chord after the text.
side / alignSide / Aligndefault "top" / "center"Where it sits.
delaynumberdefault 500Rest before the first one shows.
disabledbooleandefault falseNever show.

Accessibility and motion

  • Placement, presence and dismissal live in floating.ts, shared by tooltip, popover, dropdown and context menus: they all flip, shift, enter and leave the same way.
  • Touch never shows a tooltip: put anything important in the label itself.
  • Reduced motion: it fades, no glide.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs ui-interaction-motion.

Source

components/tooltip.tsx
"use client";import * as React from "react";import { createPortal } from "react-dom";import { enterStyle, themeVars, useFloating, usePresence, type Align, type Side } from "./floating";import { EASE_OUT, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * TOOLTIP: a name for an icon, and its shortcut * *   first    after a short rest (500ms) it rises off the trigger *            out of a blur, from the side it sits on *   warm     moving to the next trigger while one is showing: *            no wait, and the new tooltip glides over from where *            the last one was, so a toolbar reads as one label *            travelling along it *   leave    a press, Escape or leaving hides it at once * * Keyboard focus shows it too; touch never does (a tap acts). * ───────────────────────────────────────────────────────── */export interface TooltipProps {  /** The trigger: one element that can take focus (usually an icon button). */  children: React.ReactElement<{ "aria-describedby"?: string }>;  content: React.ReactNode;  /** A key or chord shown after the text, e.g. "⌘K". */  shortcut?: string;  side?: Side;  align?: Align;  /** Rest before the first tooltip shows (ms). */  delay?: number;  disabled?: boolean;}// Shared across tooltips: where the last one was and when it left, so the next can glide from it.let last: { x: number; y: number; at: number } | null = null;const WARM_MS = 450;export function Tooltip({ children, content, shortcut, side = "top", align = "center", delay = 500, disabled }: TooltipProps) {  const reduced = useInteractionReducedMotion();  const anchor = React.useRef<HTMLSpanElement>(null);  const surface = React.useRef<HTMLDivElement>(null);  const [open, setOpen] = React.useState(false);  const [warm, setWarm] = React.useState(false);  const timer = React.useRef(0);  const id = React.useId();  const { mounted, shown } = usePresence(open, 140);  const place = useFloating({ open: mounted, anchor, floating: surface, side, align, offset: 8 });  const [vars, setVars] = React.useState<React.CSSProperties>({});  const show = (now: boolean) => {    if (disabled) return;    window.clearTimeout(timer.current);    const isWarm = !!last && performance.now() - last.at < WARM_MS;    if (now || isWarm) {      setWarm(isWarm);      setOpen(true);    } else timer.current = window.setTimeout(() => {      setWarm(false);      setOpen(true);    }, delay);  };  const hide = () => {    window.clearTimeout(timer.current);    setOpen(false);  };  React.useEffect(() => () => window.clearTimeout(timer.current), []);  // Remember where it was as it leaves; the next tooltip glides from here.  React.useEffect(() => {    if (!open && place.ready) last = { x: place.x, y: place.y, at: performance.now() };    if (open) last = null;  }, [open, place.ready, place.x, place.y]);  React.useLayoutEffect(() => {    if (mounted) setVars(themeVars(anchor.current));  }, [mounted]);  // Warm: glide over from the previous tooltip instead of rising out of a blur.  const from = React.useRef<{ x: number; y: number } | null>(null);  React.useLayoutEffect(() => {    if (open && warm) from.current = last ? { x: last.x, y: last.y } : null;  }, [open, warm]);  React.useLayoutEffect(() => {    const el = surface.current;    const f = from.current;    if (!el || !f || !place.ready || reduced) return;    from.current = null;    el.animate([{ transform: `translate(${f.x - place.x}px, ${f.y - place.y}px)` }, { transform: "none" }], { duration: 300, easing: EASE_OUT });  }, [place.ready, place.x, place.y, reduced]);  const pose = warm ? { opacity: shown ? 1 : 0, transition: reduced ? "none" : `opacity ${shown ? 120 : 140}ms ${EASE_OUT}` } : enterStyle(shown, place.side, reduced);  return (    <>      <span        ref={anchor}        className="inline-flex"        onPointerEnter={(e) => e.pointerType === "mouse" && show(false)}        onPointerLeave={hide}        onPointerDown={hide}        onFocus={(e) => (e.target as HTMLElement).matches(":focus-visible") && show(true)}        onBlur={hide}        onKeyDown={(e) => e.key === "Escape" && hide()}      >        {React.cloneElement(children, { "aria-describedby": open ? id : children.props["aria-describedby"] })}      </span>      {mounted &&        createPortal(          <div            ref={surface}            id={id}            role="tooltip"            className="pointer-events-none fixed left-0 top-0 z-[60] flex items-center gap-2 whitespace-nowrap rounded-full bg-foreground px-2.5 py-1 text-[12px] font-medium text-background"            style={{ ...vars, left: place.x, top: place.y, transformOrigin: place.origin, ...pose }}          >            {content}            {shortcut && <kbd className="font-mono text-[10.5px] text-background/60">{shortcut}</kbd>}          </div>,          document.body,        )}    </>  );}export default Tooltip;
components/floating.ts
"use client";import * as React from "react";/* ───────────────────────────────────────────────────────── * FLOATING: what every popover, menu and tooltip shares * *   place     beside an anchor on a side, lined up start, centre *             or end; flips to the other side when it doesn't fit *             and shifts to stay `padding` inside the viewport; *             follows scroll and resize *   presence  mounts on open, shows a frame later (so the entry *             transitions run), hides, then unmounts after `exit` *   dismiss   Escape and a press outside both close it *   theme     the portal carries the anchor's theme tokens, so a *             light specimen on a dark page stays light * ───────────────────────────────────────────────────────── */export type Side = "top" | "bottom" | "left" | "right";export type Align = "start" | "center" | "end";const TOKENS = ["--background", "--foreground", "--popover", "--popover-foreground", "--border", "--accent", "--accent-foreground", "--primary", "--primary-foreground", "--muted", "--muted-foreground", "--ring", "--card"];/** The anchor's theme tokens as inline styles for something portalled out of its tree. */export function themeVars(anchor: Element | null): React.CSSProperties {  if (!anchor) return {};  const style = getComputedStyle(anchor);  const vars: Record<string, string> = {};  for (const t of TOKENS) {    const v = style.getPropertyValue(t);    if (v) vars[t] = v;  }  return vars as React.CSSProperties;}const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };export interface Placement {  x: number;  y: number;  side: Side;  /** Where the surface grows from, for transform-origin. */  origin: string;  ready: boolean;}/** Positions `floating` (position: fixed) next to `anchor` while `open`. Pass a point instead of an anchor for context menus. */export function useFloating({  open,  anchor,  point,  floating,  side = "bottom",  align = "center",  offset = 8,  padding = 8,}: {  open: boolean;  anchor?: React.RefObject<Element | null>;  point?: { x: number; y: number } | null;  floating: React.RefObject<HTMLElement | null>;  side?: Side;  align?: Align;  offset?: number;  padding?: number;}): Placement {  const [place, setPlace] = React.useState<Placement>({ x: -9999, y: -9999, side, origin: "center", ready: false });  React.useLayoutEffect(() => {    if (!open) return;    let frame = 0;    const measure = () => {      const el = floating.current;      if (!el) return;      const a = point        ? { left: point.x, right: point.x, top: point.y, bottom: point.y, width: 0, height: 0 }        : anchor?.current?.getBoundingClientRect();      if (!a) return;      const w = el.offsetWidth;      const h = el.offsetHeight;      const vw = window.innerWidth;      const vh = window.innerHeight;      const room: Record<Side, number> = { top: a.top - padding, bottom: vh - a.bottom - padding, left: a.left - padding, right: vw - a.right - padding };      const need = side === "top" || side === "bottom" ? h + offset : w + offset;      const s = room[side] < need && room[OPPOSITE[side]] > room[side] ? OPPOSITE[side] : side;      let x: number;      let y: number;      if (s === "top" || s === "bottom") {        y = s === "bottom" ? a.bottom + offset : a.top - offset - h;        x = align === "start" ? a.left : align === "end" ? a.right - w : a.left + a.width / 2 - w / 2;        if (point) x = a.left;      } else {        x = s === "right" ? a.right + offset : a.left - offset - w;        y = align === "start" ? a.top : align === "end" ? a.bottom - h : a.top + a.height / 2 - h / 2;      }      x = Math.max(padding, Math.min(vw - padding - w, x));      y = Math.max(padding, Math.min(vh - padding - h, y));      // Grow from the point on the anchor's edge nearest the surface.      const ox = s === "left" ? "100%" : s === "right" ? "0%" : `${Math.max(0, Math.min(w, a.left + a.width / 2 - x))}px`;      const oy = s === "top" ? "100%" : s === "bottom" ? "0%" : `${Math.max(0, Math.min(h, a.top + a.height / 2 - y))}px`;      const next = { x: Math.round(x), y: Math.round(y), side: s, origin: `${ox} ${oy}`, ready: true };      setPlace((p) => (p.x === next.x && p.y === next.y && p.side === next.side && p.origin === next.origin && p.ready ? p : next));    };    const schedule = () => {      cancelAnimationFrame(frame);      frame = requestAnimationFrame(measure);    };    measure();    const ro = new ResizeObserver(schedule);    if (floating.current) ro.observe(floating.current);    window.addEventListener("scroll", schedule, { capture: true, passive: true });    window.addEventListener("resize", schedule);    return () => {      cancelAnimationFrame(frame);      ro.disconnect();      window.removeEventListener("scroll", schedule, { capture: true });      window.removeEventListener("resize", schedule);    };  }, [open, anchor, point, floating, side, align, offset, padding]);  return open ? place : { ...place, ready: false };}/** Mount → show next frame → hide → unmount after `exit` ms. */export function usePresence(open: boolean, exit = 160) {  const [mounted, setMounted] = React.useState(open);  const [shown, setShown] = React.useState(false);  if (open && !mounted) setMounted(true);  React.useEffect(() => {    if (open) {      let inner = 0;      const outer = requestAnimationFrame(() => {        inner = requestAnimationFrame(() => setShown(true));      });      return () => {        cancelAnimationFrame(outer);        cancelAnimationFrame(inner);      };    }    const timer = window.setTimeout(() => {      setMounted(false);      setShown(false);    }, exit);    return () => window.clearTimeout(timer);  }, [open, exit]);  return { mounted: mounted || open, shown: open && shown };}/** Escape and presses outside `refs` call onDismiss while `open`. */export function useDismiss(open: boolean, onDismiss: (reason: "escape" | "outside") => void, refs: React.RefObject<Element | null>[]) {  const latest = React.useRef({ onDismiss, refs });  React.useEffect(() => {    latest.current = { onDismiss, refs };  });  React.useEffect(() => {    if (!open) return;    const onKey = (e: KeyboardEvent) => {      if (e.key !== "Escape") return;      e.stopPropagation();      latest.current.onDismiss("escape");    };    const onDown = (e: PointerEvent) => {      const target = e.target as Node;      if (latest.current.refs.some((r) => r.current?.contains(target))) return;      latest.current.onDismiss("outside");    };    document.addEventListener("keydown", onKey);    document.addEventListener("pointerdown", onDown, true);    return () => {      document.removeEventListener("keydown", onKey);      document.removeEventListener("pointerdown", onDown, true);    };  }, [open]);}/** The entry pose for a surface on `side`: it comes 6px from the anchor, 0.96 and blurred. */export function enterStyle(shown: boolean, side: Side, reduced: boolean, ease = "cubic-bezier(0.16,1,0.3,1)"): React.CSSProperties {  const d = 6;  const shift = side === "top" ? `0 ${d}px` : side === "bottom" ? `0 -${d}px` : side === "left" ? `${d}px 0` : `-${d}px 0`;  return {    opacity: shown ? 1 : 0,    translate: shown || reduced ? "0 0" : shift,    scale: shown || reduced ? "1" : "0.96",    filter: shown || reduced ? "blur(0px)" : "blur(4px)",    transition: reduced      ? "opacity 120ms ease-out"      : shown        ? `opacity 220ms ${ease}, translate 320ms ${ease}, scale 320ms ${ease}, filter 220ms ${ease}`        : `opacity 140ms ${ease}, translate 180ms ${ease}, scale 180ms ${ease}, filter 140ms ${ease}`,  };}

Registry item: /r/tooltip.json