Skip to content
UI Kit
Accent color

Tooltip for React

Short labels on hover and focus, with an optional shortcut. Escape hides them, they never hold important content.

One of 9 components in Overlays

2 variants

Short labels, never the only label

A tooltip names what an icon does when there is no room for a word. This one opens on hover and on keyboard focus, hides with Escape, links itself to its trigger through aria-describedby and keeps out of the way of the pointer.

Sides and shortcuts shows the tooltip above, below, left and right, with an optional shortcut such as Ctrl B in a key cap. In a toolbar shows the typical use, a row of icon buttons for an editor, each one with its name and shortcut, and a short delay so moving along the row feels calm.

Keep tooltips to a few words. They cannot hold links or buttons, use a popover or a hover card for that. Touch screens have no hover, so the icon button still needs its own aria label, the tooltip only adds the visual hint.

Variants

Sides and shortcuts

Four sides, the arrow always points at the trigger, even when the tip has to move to stay on screen.

In a toolbar

Icon buttons get their names on hover. The quiet tone fits help next to form fields.

Details

What Tooltip brings along, read from the registry and the source.

Category
Overlays
Variants
2
Version
1.0.0 · since 1.0.0
File
components/ui/tooltip.tsx
Brings along
utilskitpopover
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The TooltipProps interface from components/ui/tooltip.tsx, with the comments from the code.

  • contentrequired
    React.ReactNode

    The text. Keep it short, a tooltip is a label, not a manual.

  • keysoptional
    string[]

    Keyboard shortcut shown next to the text, e.g. ["⌘", "S"]

  • sideoptional
    Side
  • alignoptional
    Align
  • delayoptional
    number

    Delay before showing on hover, in ms. Focus shows it right away.

  • openoptional
    boolean

    Force it open, e.g. for a demo or an onboarding hint

  • toneoptional
    'default' | 'quiet'

    quiet: popover colors. Default is the inverted, high-contrast chip.

  • childrenrequired
    React.ReactElement

    One focusable element. It gets aria-describedby.

Accessibility

Read from the source of the component.

  • Tooltip handles the keys Escape itself.
  • Uses the role tooltip.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 135 lines
components/ui/tooltip.tsx
'use client';

import { cloneElement, isValidElement, useEffect, useId, useRef, useState } from 'react';
import { cn } from '@/lib/kit';
import { Floating, originFor, useMounted, usePresence, type Align, type FloatingPosition, type Side } from '@/components/ui/popover';

/* The tip slides a few pixels away from its trigger while it fades in, and back toward it when it leaves */
const FROM: Record<Side, string> = {
  top: 'starting:translate-y-1 data-[closing]:translate-y-0.5',
  bottom: 'starting:-translate-y-1 data-[closing]:-translate-y-0.5',
  left: 'starting:translate-x-1 data-[closing]:translate-x-0.5',
  right: 'starting:-translate-x-1 data-[closing]:-translate-x-0.5',
};

export interface TooltipProps {
  /** The text. Keep it short, a tooltip is a label, not a manual. */
  content: React.ReactNode;
  /** Keyboard shortcut shown next to the text, e.g. ["⌘", "S"] */
  keys?: string[];
  side?: Side;
  align?: Align;
  /** Delay before showing on hover, in ms. Focus shows it right away. */
  delay?: number;
  /** Force it open, e.g. for a demo or an onboarding hint */
  open?: boolean;
  /** quiet: popover colors. Default is the inverted, high-contrast chip. */
  tone?: 'default' | 'quiet';
  className?: string;
  /** One focusable element. It gets aria-describedby. */
  children: React.ReactElement;
}

function Arrow({ pos, tone }: { pos: FloatingPosition | null; tone: 'default' | 'quiet' }) {
  if (!pos) return null;
  const s = pos.side;
  const style: React.CSSProperties =
    s === 'bottom' ? { top: -4, left: pos.arrow - 4 } : s === 'top' ? { bottom: -4, left: pos.arrow - 4 } : s === 'right' ? { left: -4, top: pos.arrow - 4 } : { right: -4, top: pos.arrow - 4 };
  const borders = tone === 'quiet' ? (s === 'bottom' ? 'border-l border-t' : s === 'top' ? 'border-r border-b' : s === 'right' ? 'border-l border-b' : 'border-r border-t') : '';
  return <span aria-hidden className={cn('absolute size-2 rotate-45 rounded-[1px]', tone === 'quiet' ? 'border-border bg-popover' : 'bg-foreground', borders)} style={style} />;
}

/**
 * A short label on hover and focus. Escape hides it.
 *   <Tooltip content="Archive" keys={["E"]}><IconButton icon="archive" label="Archive" /></Tooltip>
 */
export function Tooltip({ content, keys, side = 'top', align = 'center', delay = 450, open: forced, tone = 'default', className, children }: TooltipProps) {
  const [open, setOpen] = useState(false);
  const mounted = useMounted();
  const anchorRef = useRef<HTMLSpanElement>(null);
  const timer = useRef<number | undefined>(undefined);
  const id = useId();
  const visible = forced ?? open;
  const { present, closing } = usePresence(visible, 120);

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [open]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  const show = (now: boolean) => {
    window.clearTimeout(timer.current);
    if (now) setOpen(true);
    else timer.current = window.setTimeout(() => setOpen(true), delay);
  };
  const hide = () => {
    window.clearTimeout(timer.current);
    setOpen(false);
  };

  const child = isValidElement<Record<string, unknown>>(children)
    ? cloneElement(children, { 'aria-describedby': cn(children.props['aria-describedby'] as string | undefined, id) || undefined })
    : children;

  return (
    <>
      <span
        ref={anchorRef}
        className="inline-flex"
        onPointerEnter={(e) => e.pointerType === 'mouse' && show(false)}
        onPointerLeave={hide}
        onFocus={(e) => (e.target as HTMLElement).matches(':focus-visible') && show(true)}
        onBlur={hide}
        onPointerDown={hide}
      >
        {child}
      </span>
      {/* Server and closed state: the text stays in the DOM for aria-describedby */}
      {!(mounted && present) && (
        <span id={id} hidden>
          {content}
        </span>
      )}
      {mounted && present && (
        <Floating anchor={anchorRef} side={side} align={align} offset={8} className="pointer-events-none">
          {(pos) => (
            <div
              id={id}
              role="tooltip"
              data-closing={closing || undefined}
              className={cn(
                'relative flex max-w-[16rem] items-center gap-2 rounded-md px-2.5 py-1.5 text-[12.5px] font-medium leading-[18px] transition-[opacity,scale,translate] duration-200 ease-[cubic-bezier(0.34,1.4,0.64,1)] starting:scale-90 starting:opacity-0 data-[closing]:scale-95 data-[closing]:opacity-0 data-[closing]:duration-[120ms] data-[closing]:ease-[var(--ease-kit)] motion-reduce:transition-none',
                'origin-[var(--origin)]',
                FROM[pos?.side ?? side],
                tone === 'quiet' ? 'border border-border bg-popover text-popover-foreground shadow-[var(--shadow-md)]' : 'bg-foreground text-background shadow-[var(--shadow-md)]',
                className,
              )}
              style={{ '--origin': originFor(pos, side) } as React.CSSProperties}
            >
              <Arrow pos={pos} tone={tone} />
              <span className="relative">{content}</span>
              {keys && (
                <span className="relative flex gap-0.5">
                  {keys.map((k) => (
                    <kbd
                      key={k}
                      className={cn(
                        'grid h-[18px] min-w-[18px] place-items-center rounded-[4px] px-1 font-sans text-[11px] font-medium',
                        tone === 'quiet' ? 'border border-border bg-surface text-muted-foreground' : 'bg-[color-mix(in_oklab,var(--background)_16%,transparent)] text-[color-mix(in_oklab,var(--background)_78%,var(--foreground))]',
                      )}
                    >
                      {k}
                    </kbd>
                  ))}
                </span>
              )}
            </div>
          )}
        </Floating>
      )}
    </>
  );
}

Frequent questions

How do I install Tooltip?

With aiw add @ai-website/tooltip. The command puts components/ui/tooltip.tsx into your project, the code is yours from then on. It brings along popover. It needs no further npm packages.

Which variants does Tooltip have?

2 variants, Sides and shortcuts and In a toolbar.

Can an AI assistant build with Tooltip?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Tooltip with aiw_components and install it with aiw_add @ai-website/tooltip.

Often searched

Jump to

Copied to the clipboard