Tooltip
A short label in ink that names a control on hover and focus.
Vibra fades the tooltip in over --duration-base without shadcn's zoom and slides it 4px from its side rather than 8; it is ink on the page, with room for keycaps inside, and an inline side (inline-start, inline-end) slides and hangs its arrow on logical edges, so a right-to-left rail mirrors whole. Base UI's tooltip tells assistive technology nothing — its words exist only while it is open, in a portal — so a tooltip that only repeats its trigger's name needs nothing more, and one that carries information takes describeTrigger: the kit keeps a copy of its words beside the trigger, sr-only (spoken, never shown), and points the trigger's aria-describedby at it, read after the name whether or not it is open, and present in the server's HTML. Nothing hovers under a finger, so a reader on a phone never sees a tooltip: put what they must know in the page, or in a Popover that opens on a tap. Button's tooltip and shortcut props build a labelling tooltip for you.
Install
npx shadcn@latest add @vibra/tooltipNeeds the @vibra registry in your components.json — set it up once.
Examples
import { RefreshCwIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
// The tooltip names an icon for the pointer, and says nothing the button's
// own name doesn't: aria-label carries the same words to a screen reader, so
// there is nothing more to describe.
export default function TooltipDemo() {
return (
<Tooltip>
<TooltipTrigger render={<Button variant="outline" size="icon" aria-label="Refresh data" />}>
<RefreshCwIcon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>Refresh data</TooltipContent>
</Tooltip>
)
}Carries information
The chip says the state and the tooltip says why. describeTrigger makes the why each chip's description, heard with it, open or not.
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
const SERVICES = [
{ name: "Card payments", status: "Degraded", tone: "bg-warning", detail: "Webhooks have been failing since 09:12 UTC. Payments still go through." },
{ name: "Email delivery", status: "Operational", tone: "bg-success", detail: "Last message sent 12 seconds ago." },
{ name: "CSV imports", status: "Down", tone: "bg-danger", detail: "The import worker stopped at 08:40 UTC. Queued files wait." },
]
// The word on the chip is the state; the tooltip says what it means. That is
// information, not a label, so describeTrigger gives each chip the tooltip's
// words as its description: a screen reader hears "Card payments: Degraded,
// button" and then why, open or not. Each chip is a button, so focus opens it.
export default function TooltipStatus() {
return (
<ul aria-label="Integrations" className="flex w-full max-w-sm flex-col divide-y divide-border rounded-lg border border-border">
{SERVICES.map((service) => (
<li key={service.name} className="flex items-center justify-between gap-3 px-3 py-2 text-sm">
{service.name}
<Tooltip describeTrigger>
<TooltipTrigger
render={<button type="button" aria-label={`${service.name}: ${service.status}`} />}
className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs text-muted-foreground ring-1 ring-border focus-ring hover:text-foreground"
>
<span aria-hidden="true" className={`size-1.5 rounded-full ${service.tone}`} />
{service.status}
</TooltipTrigger>
<TooltipContent className="max-w-56">
{service.detail}
</TooltipContent>
</Tooltip>
</li>
))}
</ul>
)
}With a title
A definition on an info button: a title and a sentence, both the button's description. For a phone, the same definition in a Popover opens on a tap.
import { InfoIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
// A figure's name is jargon until it's defined. The info button carries the
// definition as its description (describeTrigger), so a keyboard or screen
// reader user gets the same sentence as the pointer. A space sits between the
// title and the sentence: the hidden copy has no layout to part them.
export default function TooltipDefinition() {
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1 text-sm text-muted-foreground">
Net revenue retention
<Tooltip describeTrigger>
<TooltipTrigger render={<Button variant="ghost" size="icon-xs" aria-label="About net revenue retention" />}>
<InfoIcon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent className="max-w-64 flex-col items-start gap-1 py-2">
<span className="font-medium">Revenue kept, a year on.</span>{" "}
<span className="opacity-80">This September's revenue from last September's customers, upgrades included, divided by what they paid then.</span>
</TooltipContent>
</Tooltip>
</div>
<p className="type-numeral text-3xl">112%</p>
</div>
)
}Says why it is disabled
focusableWhenDisabled keeps the button reachable, so its tooltip can open; the reason is its description.
import { DownloadIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
// A natively disabled button takes no focus and no hover, so its tooltip
// could never open. This one stays focusable (focusableWhenDisabled) and says
// it is unavailable with aria-disabled; the reason is its description, heard
// with it and shown on hover and focus.
export default function TooltipDisabled() {
return (
<div className="flex items-center gap-2">
<Button variant="outline">Share report</Button>
<Tooltip describeTrigger>
<TooltipTrigger render={<Button variant="outline" disabled focusableWhenDisabled />}>
<DownloadIcon aria-hidden="true" data-icon="inline-start" />
Export CSV
</TooltipTrigger>
<TooltipContent>Exports come with the Growth plan.</TooltipContent>
</Tooltip>
</div>
)
}With keyboard shortcuts
Button's tooltip and shortcut props in a toolbar: the name, the chord as keycaps, and aria-keyshortcuts for the chord without a hover.
import { BoldIcon, ItalicIcon, LinkIcon, ListIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Toolbar } from "@/components/ui/toolbar"
import { TooltipProvider } from "@/components/ui/tooltip"
const TOOLS = [
{ label: "Bold", icon: BoldIcon, shortcut: "mod+b" },
{ label: "Italic", icon: ItalicIcon, shortcut: "mod+i" },
{ label: "Link", icon: LinkIcon, shortcut: "mod+k" },
{ label: "Bulleted list", icon: ListIcon, shortcut: "mod+shift+8" },
]
// A note editor's toolbar. Button's tooltip and shortcut props build each
// tooltip: the name, then the chord as keycaps — ⌘ on a Mac, Ctrl elsewhere —
// and aria-keyshortcuts announces the chord without a hover. The toolbar is
// one tab stop that the arrow keys walk, and one provider holds the row, so
// after the first tooltip the next ones open at once.
export default function TooltipShortcuts() {
return (
<TooltipProvider delay={400}>
<Toolbar aria-label="Formatting" size="sm">
{TOOLS.map(({ label, icon: Icon, shortcut }) => (
<Button key={label} variant="ghost" size="icon-sm" aria-label={label} tooltip={label} shortcut={shortcut}>
<Icon aria-hidden="true" />
</Button>
))}
</Toolbar>
</TooltipProvider>
)
}Beside a side rail
A collapsed sidebar's links, named by aria-label, with tooltips on the inline-end side, towards the page.
import { LayoutDashboardIcon, ReceiptTextIcon, SettingsIcon, UsersIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
const PAGES = [
{ href: "/overview", label: "Overview", icon: LayoutDashboardIcon },
{ href: "/customers", label: "Customers", icon: UsersIcon },
{ href: "/billing/invoices", label: "Invoices", icon: ReceiptTextIcon },
{ href: "/settings", label: "Settings", icon: SettingsIcon },
]
// A collapsed sidebar: icons only, so each link is named by aria-label and a
// tooltip repeats the name beside it. side="inline-end" opens it towards the
// page — to the right here, to the left in a right-to-left app — with the
// arrow on the edge that faces the rail. The current page is on the brand
// tint and marked aria-current.
export default function TooltipRail({ current = "/customers" }: { current?: string }) {
return (
<TooltipProvider delay={300}>
<nav aria-label="Main" className="flex flex-col gap-1 rounded-xl bg-surface p-1.5 ring-1 ring-border">
{PAGES.map(({ href, label, icon: Icon }) => (
<Tooltip key={href}>
<TooltipTrigger
render={
<a
href={href}
aria-label={label}
aria-current={href === current ? "page" : undefined}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "aria-[current=page]:bg-brand-muted aria-[current=page]:text-foreground")}
/>
}
>
<Icon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent side="inline-end">{label}</TooltipContent>
</Tooltip>
))}
</nav>
</TooltipProvider>
)
}Only when cut off
A long name is cut to one line; the tooltip opens only for a name that is actually cut, since a screen reader already hears it whole.
"use client"
import * as React from "react"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
const REPORTS = [
"Q3 revenue",
"Churn by plan and region, excluding annual contracts signed before March",
"Board pack, September",
]
/** A link cut to one line, with its full name in a tooltip only while it is cut. */
function ReportLink({ name }: { name: string }) {
const ref = React.useRef<HTMLAnchorElement>(null)
const [cut, setCut] = React.useState(false)
React.useEffect(() => {
const link = ref.current
if (!link) return
const measure = () => setCut(link.scrollWidth > link.clientWidth)
measure()
const observer = new ResizeObserver(measure)
observer.observe(link)
return () => observer.disconnect()
}, [])
return (
<Tooltip disabled={!cut}>
<TooltipTrigger
render={<a ref={ref} href={`/reports/${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`} />}
className="block truncate rounded-sm focus-ring hover:underline"
>
{name}
</TooltipTrigger>
<TooltipContent className="max-w-80">{name}</TooltipContent>
</Tooltip>
)
}
// Long names are cut to keep the list to one line a report. A screen reader
// already hears the whole name — the cut is only paint — so the tooltip is
// for the eye, and opens only for a name that is actually cut.
export default function TooltipTruncated() {
return (
<ul aria-label="Saved reports" className="flex w-full max-w-64 flex-col gap-2 text-sm">
{REPORTS.map((name) => (
<li key={name} className="min-w-0">
<ReportLink name={name} />
</li>
))}
</ul>
)
}Right to left
The rail on the right: under DirectionProvider inline-end opens to the left, and the arrow sits on the edge that faces the rail.
import { LayoutDashboardIcon, ReceiptTextIcon, SettingsIcon, UsersIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { DirectionProvider } from "@/components/ui/direction"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
const PAGES = [
{ href: "/overview", label: "نظرة عامة", icon: LayoutDashboardIcon },
{ href: "/customers", label: "العملاء", icon: UsersIcon },
{ href: "/billing/invoices", label: "الفواتير", icon: ReceiptTextIcon },
{ href: "/settings", label: "الإعدادات", icon: SettingsIcon },
]
// The rail on the right of a right-to-left app. DirectionProvider tells Base
// UI which way inline-end is, so each tooltip opens to the left, towards the
// page; the popup is portalled out of the region, so it carries dir itself,
// and its arrow sits on the edge that faces the rail.
export default function TooltipRtl() {
return (
<DirectionProvider direction="rtl">
<TooltipProvider delay={300}>
<nav dir="rtl" lang="ar" aria-label="الرئيسية" className="flex flex-col gap-1 rounded-xl bg-surface p-1.5 ring-1 ring-border">
{PAGES.map(({ href, label, icon: Icon }) => (
<Tooltip key={href}>
<TooltipTrigger
render={
<a
href={href}
aria-label={label}
aria-current={href === "/billing/invoices" ? "page" : undefined}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "aria-[current=page]:bg-brand-muted aria-[current=page]:text-foreground")}
/>
}
>
<Icon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent side="inline-end" dir="rtl" lang="ar">
{label}
</TooltipContent>
</Tooltip>
))}
</nav>
</TooltipProvider>
</DirectionProvider>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| Tooltip.describeTrigger | boolean | false | Gives the trigger the tooltip's words as its accessible description — for a status, a definition, the reason a control is disabled. Leave it off when the tooltip only repeats the trigger's name. |
| Tooltip.disabled | boolean | false | Keeps the tooltip shut: for one that only matters sometimes, such as the full text of a label that isn't cut. |
| TooltipContent.side | "top" | "bottom" | "left" | "right" | "inline-start" | "inline-end" | "top" | Where it opens; the inline sides follow the reading direction. Base UI flips it when there is no room. |
| TooltipProvider.delay | number | 0 | How long the first tooltip in the group waits, in ms. Once one is open, its neighbours open at once. |
Dependencies
Registry
Source
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
/**
* The id of the copy of its words a described trigger points at, or nothing.
*
* Base UI draws a tooltip for the pointer and tells assistive technology
* nothing: the trigger has no aria-describedby, and the popup exists only
* while it is open, in a portal at the end of the page. A tooltip that only
* repeats its trigger's name — an icon button's label — loses nothing by
* that. One that carries information does, so `describeTrigger` keeps a copy
* of the tooltip's words in the page and points the trigger at it.
*/
const TooltipDescriptionContext = React.createContext<string | undefined>(
undefined
)
function Tooltip({
describeTrigger = false,
...props
}: TooltipPrimitive.Root.Props & {
/**
* Also gives the trigger the tooltip's words as its accessible description,
* read after its name whether or not the tooltip is open. For a tooltip
* that says more than the trigger's name: a status, a definition, the
* reason a control is disabled.
*/
describeTrigger?: boolean
}) {
const id = React.useId()
return (
<TooltipDescriptionContext.Provider
value={describeTrigger ? `${id}-description` : undefined}
>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipDescriptionContext.Provider>
)
}
function TooltipTrigger({ render, ...props }: TooltipPrimitive.Trigger.Props) {
const descriptionId = React.useContext(TooltipDescriptionContext)
if (!descriptionId) {
return (
<TooltipPrimitive.Trigger
data-slot="tooltip-trigger"
render={render}
{...props}
/>
)
}
// Joined with a description the trigger already has, on itself or on the
// element it renders — that element's own props win a merge, so the joined
// value is written onto it as well.
const element = React.isValidElement<{ "aria-describedby"?: string }>(render)
? render
: undefined
const describedBy = [
props["aria-describedby"] ?? element?.props["aria-describedby"],
descriptionId,
]
.filter(Boolean)
.join(" ")
return (
<TooltipPrimitive.Trigger
data-slot="tooltip-trigger"
render={
element
? React.cloneElement(element, { "aria-describedby": describedBy })
: render
}
{...props}
aria-describedby={describedBy}
/>
)
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
const descriptionId = React.useContext(TooltipDescriptionContext)
return (
<>
{descriptionId ? (
// The words a described trigger points at: in the page from the first
// render, beside the trigger rather than in the portal, and sr-only —
// spoken, never shown, and out of the layout. Chrome reads a
// description from a display:none element aria-describedby names, but
// not every browser and screen reader pair has done so reliably; a
// rendered node is the case they all support. A reader moving line by
// line meets the words once more after the trigger, as with a field's
// help text.
<span id={descriptionId} className="sr-only">
{children}
</span>
) : null}
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pe-1.5 data-[side=bottom]:slide-in-from-top-1 data-[side=inline-end]:slide-in-from-start-1 data-[side=inline-start]:slide-in-from-end-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:duration-(--duration-base) data-[state=delayed-open]:ease-(--ease-standard) data-open:animate-in data-open:fade-in-0 data-open:duration-(--duration-base) data-open:ease-(--ease-standard) data-closed:animate-out data-closed:fade-out-0 data-closed:duration-(--duration-fast) data-closed:ease-(--ease-exit)",
className
)}
{...props}
>
{children}
{/* An inline side is logical, so its arrow is too: on the edge
that faces the trigger in either direction. */}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-start-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-end-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
</>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }