Tooltip

Free · MIT

Dark, light, tone, glass and neo tooltips with sizes, shapes, shadows and an optional arrow; twelve placements that flip and shift at the screen edge, scale, fade, slide or spring entrances, rich title + keyboard-shortcut content, delay groups that open instantly after the first, follow-cursor for charts and touch long-press.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/tooltip.json

The file lands in components/goodui/tooltip.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/tooltip works too.

Usage

import { Bell, CalendarPlus } from "lucide-react"
import { Button } from "@/components/goodui/button"
import { Tooltip, TooltipGroup } from "@/components/goodui/tooltip"

export function Toolbar() {
  return (
    <TooltipGroup>
      <Tooltip title="New booking" content="For the selected slot" shortcut="Mod+N">
        <Button size="sm" iconLeft={<CalendarPlus />}>New</Button>
      </Tooltip>
      <Tooltip content="3 unread notifications" variant="tone" tone="danger" side="bottom">
        <Button size="sm" variant="ghost" iconOnly aria-label="Notifications">
          <Bell />
        </Button>
      </Tooltip>
    </TooltipGroup>
  )
}

When to use

Use it for
  • Naming icon-only buttons in toolbars and tables, with the keyboard shortcut (shortcut="Mod+N").
  • A one-line explanation of a status, a badge or a truncated value.
  • Charts and heatmaps where the hint should follow the pointer (followCursor).
  • Explaining why a control is disabled (wrap the disabled button in a focusable span).
Reach for something else
  • Anything the user must read or click (links, buttons, forms): use popover; tooltips vanish on blur and can't hold focus.
  • Information needed on touch devices to finish a task: show it inline; long-press is a fallback, not a primary path.
  • The only name of an icon button: the button still needs aria-label; the tooltip is a description.
If you needUse
App chrome, toolbarsvariant="dark" (default), wrapped in TooltipGroup
Dark UIs or busy screensvariant="light"
Status meaning (paid, overdue)variant="tone" with tone
Over photos or gradientsvariant="glass"
Playful brandsvariant="neo"
Shortcut hintstitle + shortcut (Mod is ⌘ on Mac, Ctrl elsewhere)
ChartsfollowCursor with content that changes as the pointer moves

Examples

Disabled button

// Disabled buttons get no pointer or focus events, so wrap them.
<Tooltip content="Refunds open after the payout clears">
  <span tabIndex={0}>
    <Button disabled>Refund</Button>
  </span>
</Tooltip>

Follow the cursor over a chart

const [i, setI] = React.useState(0)

<Tooltip followCursor delay={0} animation="fade" title={hours[i]} content={`${load[i]} of 8 chairs booked`}>
  <div tabIndex={0} onPointerMove={(e) => setI(indexFromX(e))}>{bars}</div>
</Tooltip>

Keep it open to show feedback

<Tooltip content={copied ? "Copied!" : "Copy link"} open={copied ? true : undefined}>
  <Button onClick={copy}>Copy link</Button>
</Tooltip>

API reference

Tooltip

PropTypeDefault
children*
The trigger: one element (a button, an icon, a link). Text is wrapped in a focusable span.
React.ReactNode—
content
Tooltip body (with a title, the muted second line).
React.ReactNode—
title
Bold first line for rich tooltips; content becomes the muted second line.
React.ReactNode—
shortcut
Keyboard shortcut shown as keycaps: "Mod+K" or ["Shift", "D"]. Mod is ⌘ on Mac and Ctrl elsewhere.
string | string[]—
variant
Paint: dark (default, dark in both themes), light (light in both themes), tone (filled with the tone), glass, neo.
"dark" | "light" | "tone" | "glass" | "neo""dark"
tone
Color for the tone and neo variants.
"primary" | "neutral" | "success" | "warning" | "danger" | "info""primary"
size"sm" | "md" | "lg""md"
shape"square" | "rounded" | "pill""rounded"
shadow
Drop shadow. Ignored by neo, which has its own offset shadow.
"none" | "sm" | "md" | "lg""md"
animation
How it appears: scale (default), fade, slide, spring or none. Reduced motion uses a short fade.
"none" | "fade" | "scale" | "slide" | "spring""scale"
side
Preferred side; flips to the opposite side when there is no room.
"top" | "bottom" | "left" | "right" | "start" | "end""top"
align
Alignment along the side. start/end follow the reading direction.
"start" | "center" | "end""center"
offset
Gap between trigger and tooltip, in px.
number8
arrowbooleantrue
delay
Hover delay before opening, in ms. Inside a TooltipGroup the group's delay is used. Focus opens at once.
number500
closeDelay
Grace period before closing after the pointer leaves, so the pointer can move onto the tooltip, in ms.
number100
longPressDelay
Touch: how long to press before the tooltip shows, in ms. It hides 1.5 s after release or on the next tap.
number500
followCursor
Position at the pointer and follow it while it moves over the trigger (charts, heatmaps).
booleanfalse
open
Open state (controlled).
boolean—
defaultOpen
Initial open state when uncontrolled.
booleanfalse
onOpenChange(open: boolean) => void—
disabled
Never open.
booleanfalse
portal
Render into document.body so overflow and z-index of parents can't clip it.
booleantrue
labels
Text for screen readers; pass translations here.
Partial<TooltipLabels>—

Also accepts every <div> attribute.

TooltipGroup

PropTypeDefault
children*
Content.
React.ReactNode—
delay
Delay before the first tooltip in the group opens, in ms.
number500
skipDelay
After a tooltip closes, how long the next one still opens instantly, in ms.
number400

Accessibility

TabFocusing the trigger with the keyboard shows the tooltip at once; moving focus away hides it.
EscHides the open tooltip. It is handled first, so a dialog behind the tooltip stays open.
  • The bubble has role="tooltip". The trigger's aria-describedby points at it while it is open, and at a hidden copy of the same text while it is closed, so screen readers announce the description on focus either way. Existing aria-describedby values are kept.
  • It opens on hover (after delay), on keyboard focus (focus-visible, at once) and on touch long-press; it never opens on mouse click.
  • The pointer can move from the trigger onto the tooltip without it closing (closeDelay), and Esc dismisses it.
  • The shortcut keycaps come from the kbd component, which gives each special key a spoken name; a translatable "Shortcut:" prefix (labels.shortcut) is read before them.
  • The tooltip is a description, not a name: icon-only triggers still need aria-label.

Motion

  • scale: from 0.9 in 160 ms. slide: 5px toward its side in 200 ms. spring: from 0.55 on a spring (stiffness 620, damping 22). fade: opacity only, 140 ms. Every variant fades out in 100 ms.
  • Scale and spring grow from the arrow tip, so the tooltip seems to come out of the trigger.
  • Inside a TooltipGroup, tooltips that open instantly after the first skip the movement and fade in over 60 ms.
  • Reduced motion: a 100 ms fade, no movement. animation="none" shows and hides instantly.
  • Nothing renders until the tooltip opens (apart from the hidden description). Positioning runs on open, scroll, resize and size changes, not every frame; followCursor repositions on pointer move.

Notes for AI

Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.

  • The trigger is the child: one element (Button, a, span with tabIndex=0). Text children are wrapped in a focusable span automatically.
  • Use content for the body, title for a bold first line, shortcut for keys ("Mod+K", "Shift+F" or an array). Don't build keycaps by hand.
  • Wrap toolbars in TooltipGroup so only the first tooltip waits.
  • side accepts start and end for RTL-safe placement; the tooltip flips and shifts to stay on screen by itself.
  • Don't put links or buttons inside a tooltip; use popover.

FAQ

Does it work on phones?

Yes: press and hold the trigger (longPressDelay, 500 ms by default). The tooltip hides 1.5 s after release or on the next tap, and the release doesn't click the button.

Can I control it?

Yes: open, defaultOpen and onOpenChange. open={undefined} hands control back to hover and focus.

Why doesn't it show on my disabled button?

Browsers send no pointer or focus events to disabled buttons. Wrap the button in a <span tabIndex={0}>.

Related