Skip to content

Tooltip

Supplementary text on hover and on keyboard focus, dismissible with Escape.

Overlaystartertooltiphinticon-buttonhover

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

'use client'

import { Bold, Italic, Link2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip } from '@/components/ui/tooltip'
import { DemoRow, DemoStage } from './_kit'

export default function TooltipDemo() {
  return (
    <DemoStage>
      <DemoRow
        label="Icon-only controls"
        description="Tab to a button — the tooltip opens on focus, not only on hover."
      >
        <Tooltip content="Bold — ⌘B">
          <Button variant="outline" size="icon" aria-label="Bold">
            <Bold className="size-4" />
          </Button>
        </Tooltip>
        <Tooltip content="Italic — ⌘I">
          <Button variant="outline" size="icon" aria-label="Italic">
            <Italic className="size-4" />
          </Button>
        </Tooltip>
        <Tooltip content="Insert link — ⌘K">
          <Button variant="outline" size="icon" aria-label="Insert link">
            <Link2 className="size-4" />
          </Button>
        </Tooltip>
      </DemoRow>

      <DemoRow label="Sides">
        <Tooltip content="Above" side="top">
          <Button variant="ghost">Top</Button>
        </Tooltip>
        <Tooltip content="Below" side="bottom">
          <Button variant="ghost">Bottom</Button>
        </Tooltip>
        <Tooltip content="To the left" side="left">
          <Button variant="ghost">Left</Button>
        </Tooltip>
        <Tooltip content="To the right" side="right">
          <Button variant="ghost">Right</Button>
        </Tooltip>
      </DemoRow>

      <DemoRow label="Longer content">
        <Tooltip content="Rebuilds the search index. Takes about 30 seconds on a large catalogue.">
          <Button variant="outline">Reindex</Button>
        </Tooltip>
      </DemoRow>
    </DemoStage>
  )
}

components/demos/tooltip.tsx

'use client'

import { Bold, Italic, Link2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip } from '@/components/ui/tooltip'
import { DemoRow, DemoStage } from './_kit'

export default function TooltipDemo() {
  return (
    <DemoStage>
      <DemoRow
        label="Icon-only controls"
        description="Tab to a button — the tooltip opens on focus, not only on hover."
      >
        <Tooltip content="Bold — ⌘B">
          <Button variant="outline" size="icon" aria-label="Bold">
            <Bold className="size-4" />
          </Button>
        </Tooltip>
        <Tooltip content="Italic — ⌘I">
          <Button variant="outline" size="icon" aria-label="Italic">
            <Italic className="size-4" />
          </Button>
        </Tooltip>
        <Tooltip content="Insert link — ⌘K">
          <Button variant="outline" size="icon" aria-label="Insert link">
            <Link2 className="size-4" />
          </Button>
        </Tooltip>
      </DemoRow>

      <DemoRow label="Sides">
        <Tooltip content="Above" side="top">
          <Button variant="ghost">Top</Button>
        </Tooltip>
        <Tooltip content="Below" side="bottom">
          <Button variant="ghost">Bottom</Button>
        </Tooltip>
        <Tooltip content="To the left" side="left">
          <Button variant="ghost">Left</Button>
        </Tooltip>
        <Tooltip content="To the right" side="right">
          <Button variant="ghost">Right</Button>
        </Tooltip>
      </DemoRow>

      <DemoRow label="Longer content">
        <Tooltip content="Rebuilds the search index. Takes about 30 seconds on a large catalogue.">
          <Button variant="outline">Reindex</Button>
        </Tooltip>
      </DemoRow>
    </DemoStage>
  )
}

components/ui/tooltip.tsx

'use client'

import { cloneElement, useId, useRef, useState, type ReactElement, type ReactNode } from 'react'
import { cn } from '@/lib/cn'

/**
 * Tooltip
 *
 * Supplementary text on hover *and* on keyboard focus — a tooltip that only
 * appears on hover is invisible to keyboard users, which is the single most
 * common tooltip bug.
 *
 * Escape dismisses it (WCAG 1.4.13), a short open delay stops it firing while a
 * pointer merely crosses the control, and the content is wired with
 * `aria-describedby` rather than replacing the control's accessible name.
 *
 * A tooltip must never hold the only copy of important information, and must
 * never contain interactive content — reach for Popover instead.
 */
interface TriggerProps {
  'aria-describedby'?: string
}

export interface TooltipProps {
  content: ReactNode
  children: ReactElement<TriggerProps>
  side?: 'top' | 'bottom' | 'left' | 'right'
  delay?: number
  className?: string
}

const sides = {
  top: 'bottom-full left-1/2 -translate-x-1/2 mb-1.5',
  bottom: 'top-full left-1/2 -translate-x-1/2 mt-1.5',
  left: 'right-full top-1/2 -translate-y-1/2 mr-1.5',
  right: 'left-full top-1/2 -translate-y-1/2 ml-1.5',
} as const

export function Tooltip({ content, children, side = 'top', delay = 120, className }: TooltipProps) {
  const [open, setOpen] = useState(false)
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
  const id = useId()
  const tooltipId = `${id}-tooltip`

  const show = () => {
    if (timer.current) clearTimeout(timer.current)
    timer.current = setTimeout(() => setOpen(true), delay)
  }

  const hide = () => {
    if (timer.current) clearTimeout(timer.current)
    setOpen(false)
  }

  // The trigger receives only `aria-describedby`; the listeners live on the
  // wrapper. Focus and blur bubble in React, so keyboard focus still opens it,
  // and cloneElement never carries a function that reads a ref.
  const trigger = cloneElement(children, {
    'aria-describedby': open ? tooltipId : undefined,
  })

  return (
    <span
      className={cn('relative inline-flex', className)}
      onMouseEnter={show}
      onMouseLeave={hide}
      onFocus={() => setOpen(true)}
      onBlur={hide}
      onKeyDown={(event) => {
        if (event.key === 'Escape') hide()
      }}
    >
      {trigger}
      {open ? (
        <span
          id={tooltipId}
          role="tooltip"
          className={cn(
            'animate-fade-in pointer-events-none absolute z-50 w-max max-w-56 rounded-md bg-surface-inverse px-2 py-1 text-xs leading-snug text-ink-inverse shadow-md',
            sides[side],
          )}
        >
          {content}
        </span>
      ) : null}
    </span>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

For naming icon-only controls and adding a short hint. A tooltip must never hold the only copy of important information, and must never contain interactive content.

  • Opens on focus as well as hover — a hover-only tooltip is invisible to keyboard users, and it is the single most common tooltip bug.
  • Escape dismisses it, as required by WCAG 1.4.13.
  • The trigger still needs its own accessible name; the tooltip describes, it does not name.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Four sides
  • Icon-only control labelling
  • Longer explanatory content

Accessibility

Description not name
Wired with `aria-describedby`, so the button keeps its own label.
Dismissible
Escape closes it without moving focus.
Delay
A short open delay stops tooltips firing as a pointer crosses a toolbar.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.