Tooltip
One or two lines of plain text on hover or focus: the name of an icon-only control, or a label that truncates.
client - size not measured yet
Tooltip
client - size not measured yetOne or two lines of plain text on hover or focus: the name of an icon-only control, or a label that truncates.
Try it
placement
delay
closeDelay
The code
tooltip.tsx
import { linkSimple } from 'topclassui/icons/link-simple'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { TooltipPopup, TooltipRoot, TooltipTrigger } from 'topclassui/tooltip'
<TooltipRoot>
<TooltipTrigger
render={
<Button variant="ghost" aria-label="Copy link">
<Svg icon={linkSimple} />
</Button>
}
/>
<TooltipPopup>Copy link</TooltipPopup>
</TooltipRoot>Touch never shows a tooltip, so the trigger's own name must repeat the text: give an icon-only Button an aria-label.
Every state
4 statesImport from the package
topclassui/tooltip
import { TooltipRoot, TooltipPopup, TooltipTrigger } from 'topclassui/tooltip'Install once:npm i topclassui@beta zod
Examples
- Three flat parts,
TooltipRoot,TooltipTriggerandTooltipPopup, and no bareTooltip. - Anything with a link or a control is a Popover: the popup's children are typed as text, so an element is refused at compile time.
delayandcloseDelaygo on the trigger and are clamped to 0 to 10000 ms; left out, 600 and 0 apply, or the shared delay of the app's provider.placementgoes on the popup,topby default.- Put the trigger's text or icon inside the Button passed to
render, never insideTooltipTrigger, so the label still truncates and the icon still gives way to the loading spinner.
basic
basic.tsx
import { linkSimple } from 'topclassui/icons/link-simple'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { TooltipPopup, TooltipRoot, TooltipTrigger } from 'topclassui/tooltip'
/** The drawn sample: an icon-only ghost Button whose aria-label repeats the tooltip text,
* because touch never shows a tooltip. The icon sits inside the Button, not the trigger,
* so a Server Component keeps it beside a loading spinner. It opens above, the default. */
export default function Demo() {
return (
<TooltipRoot>
<TooltipTrigger
render={
<Button variant="ghost" aria-label="Copy link">
<Svg icon={linkSimple} />
</Button>
}
/>
<TooltipPopup>Copy link</TooltipPopup>
</TooltipRoot>
)
}
delays
delays.tsx
import { copy } from 'topclassui/icons/copy'
import { gear } from 'topclassui/icons/gear'
import { trash } from 'topclassui/icons/trash'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { TooltipPopup, TooltipRoot, TooltipTrigger } from 'topclassui/tooltip'
/** A toolbar of three icon-only Buttons: Copy opens at once, Delete waits Base UI's
* 600 ms because it passes no delay, and Settings stays 300 ms after the pointer leaves. */
export default function Demo() {
return (
<div className="flex flex-wrap items-center gap-2">
<TooltipRoot>
<TooltipTrigger
delay={0}
render={
<Button variant="ghost" aria-label="Copy">
<Svg icon={copy} />
</Button>
}
/>
<TooltipPopup>Copy</TooltipPopup>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger
render={
<Button variant="ghost" aria-label="Delete this row">
<Svg icon={trash} />
</Button>
}
/>
<TooltipPopup placement="bottom">Delete this row</TooltipPopup>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger
closeDelay={300}
render={
<Button variant="ghost" aria-label="Settings">
<Svg icon={gear} />
</Button>
}
/>
<TooltipPopup>Settings</TooltipPopup>
</TooltipRoot>
</div>
)
}
long-label
long-label.tsx
import { Button } from 'topclassui/button'
import { TooltipPopup, TooltipRoot, TooltipTrigger } from 'topclassui/tooltip'
const LONG = 'Save this document and continue to the next step of the wizard'
const FILE = 'quarterly-report-final-v2-reviewed-by-legal-and-finance.pdf'
/** Why Tooltip is in Unit 1: each Button truncates its label in a narrow box, and the
* tooltip shows it whole. The label is inside the Button, so a server render keeps its
* truncating span. The sentence wraps at max-w-xs; the file name breaks anywhere. */
export default function Demo() {
return (
<div className="flex flex-col gap-3">
<div className="w-50 rounded-box border border-line p-2">
<TooltipRoot>
<TooltipTrigger render={<Button>{LONG}</Button>} />
<TooltipPopup>{LONG}</TooltipPopup>
</TooltipRoot>
</div>
<div className="w-50 rounded-box border border-line p-2">
<TooltipRoot>
<TooltipTrigger render={<Button variant="outline">{FILE}</Button>} />
<TooltipPopup placement="bottom-start">{FILE}</TooltipPopup>
</TooltipRoot>
</div>
</div>
)
}
with-count
with-count.tsx
import { trash } from 'topclassui/icons/trash'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { TooltipPopup, TooltipRoot, TooltipTrigger } from 'topclassui/tooltip'
const COUNT = 3
/** An interpolated tooltip, which JSX passes as an array of text: the popup takes it,
* and the aria-label spells out the same words for the icon-only Button. */
export default function Demo() {
return (
<TooltipRoot>
<TooltipTrigger
render={
<Button variant="soft" tone="danger" aria-label={`Delete ${String(COUNT)} rows`}>
<Svg icon={trash} />
</Button>
}
/>
<TooltipPopup>Delete {COUNT} rows</TooltipPopup>
</TooltipRoot>
)
}
placements
placements.tsx
import { Button } from 'topclassui/button'
import { TooltipPopup, TooltipRoot, TooltipTrigger } from 'topclassui/tooltip'
import type { TooltipPlacement } from 'topclassui/tooltip'
const PLACES: TooltipPlacement[] = [
'top-start', 'top', 'top-end',
'start-top', 'start', 'start-bottom',
'end-top', 'end', 'end-bottom',
'bottom-start', 'bottom', 'bottom-end',
]
/** All twelve placements, one outline Button each. The label names the placement and
* the tooltip shows the same words, so the trigger's name carries the text. */
export default function Demo() {
return (
<div className="grid grid-cols-3 gap-3 p-12">
{PLACES.map((place) => (
<TooltipRoot key={place}>
<TooltipTrigger render={<Button variant="outline" size="sm">{place}</Button>} />
<TooltipPopup placement={place}>{place}</TooltipPopup>
</TooltipRoot>
))}
</div>
)
}
disabled
disabled.tsx
import { pencilSimple } from 'topclassui/icons/pencil-simple'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { TooltipPopup, TooltipRoot, TooltipTrigger } from 'topclassui/tooltip'
/** TooltipRoot disabled: the Button still works and its aria-label still names it, but
* no tooltip opens on hover or focus. Base UI takes the prop untouched. */
export default function Demo() {
return (
<TooltipRoot disabled>
<TooltipTrigger
render={
<Button variant="outline" aria-label="Rename">
<Svg icon={pencilSimple} />
</Button>
}
/>
<TooltipPopup>Rename</TooltipPopup>
</TooltipRoot>
)
}
In Arabic, right to left
rtl.tsx
import { linkSimple } from 'topclassui/icons/link-simple'
import { trash } from 'topclassui/icons/trash'
import { TopClassProvider } from 'topclassui/provider'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { TooltipPopup, TooltipRoot, TooltipTrigger } from 'topclassui/tooltip'
/** Arabic, inside TopClassProvider as the root layout would mount it: Base UI mirrors
* start and end from the provider, so start opens on the right and end on the left. */
export default function Demo() {
return (
<TopClassProvider direction="rtl" locale="ar">
<div dir="rtl" lang="ar" className="flex flex-wrap items-center gap-3">
<TooltipRoot>
<TooltipTrigger
render={
<Button variant="ghost" aria-label="نسخ الرابط">
<Svg icon={linkSimple} />
</Button>
}
/>
<TooltipPopup placement="start">نسخ الرابط</TooltipPopup>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger
render={
<Button variant="ghost" aria-label="حذف هذا الصف">
<Svg icon={trash} />
</Button>
}
/>
<TooltipPopup placement="end">حذف هذا الصف</TooltipPopup>
</TooltipRoot>
</div>
</TopClassProvider>
)
}
Props
TooltipRoot
No props of its own: it takes the attributes of the element it renders.
TooltipPopup
TooltipTrigger
Keyboard and accessibility
- Base UI links the popup to nothing, so the trigger's name carries the text, not the tooltip.
- The popup's children are typed as text: an element, a link or a control is refused at compile time.
Right to left
start opens on the right and end on the left. The default, top, stays above.
Server and client
A client component: it renders on the server and hydrates in the browser.
Files: index.tsx, client.tsx.
On the chip: client - size not measured yet.