Tooltip
A short label that names or explains an element when it is hovered or focused.
On this page
Code
import { Button } from "@bosunkit/components/button";
import { Tooltip, TooltipGroup } from "@bosunkit/components/tooltip";
import { Crosshair, Layers, Ruler, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react";
import type { ReactElement } from "react";
export const description: string =
"A chart toolbar in one group: the first label waits 200 ms, and moving along the row opens each next label at once.";
const TOOLS: readonly { readonly label: string; readonly Icon: LucideIcon }[] = [
{ label: "Zoom in", Icon: ZoomIn },
{ label: "Zoom out", Icon: ZoomOut },
{ label: "Center on vessel", Icon: Crosshair },
{ label: "Chart layers", Icon: Layers },
{ label: "Measure distance", Icon: Ruler },
];
export default function Group(): ReactElement {
return (
<TooltipGroup>
<div role="toolbar" aria-label="Chart" style={{ display: "flex", gap: "var(--space-xs)" }}>
{TOOLS.map(({ label, Icon }: { readonly label: string; readonly Icon: LucideIcon }): ReactElement => (
<Tooltip key={label} content={label} side="bottom">
<Button size="icon" aria-label={label}>
<Icon size={18} aria-hidden />
</Button>
</Tooltip>
))}
</div>
</TooltipGroup>
);
}
A tooltip is a short label that opens when the element it describes is hovered or focused, and closes when the
pointer or focus leaves or Escape is pressed. The element is the tooltip's child and must take a ref and the props of
a trigger, such as a button or a link. The label renders in the page body through a portal, on the side given, and
moves to the opposite side when that one has no room. Its look is fixed: className is accepted and not applied.
A tooltip names a control that has no room for a visible label, explains a term, or says where a reading comes from. It never carries anything the crew must read to act safely, because a touch screen at the helm has no hover: a finger on the button presses it, and the label never opens.
When to use a tooltip
| Use | When | Example |
|---|---|---|
| Tooltip | The element has no room for its name, or a term or a reading needs one line of explanation. | An icon-only button; "SOG"; a depth reading's source |
| Popover | The content holds a link or a button, or must stay open while the crew reads or acts on it. | A note with a close button; a brightness control |
| Text on the screen | The crew needs it to decide or act: why a control is disabled, a limit, an alert's state. | "Load a route to start the passage" under the button |
A tooltip may hold only text. By its ARIA role (tooltip) it describes its element and is never focused, so a screen
reader user cannot reach a link or a button inside it, and it closes as soon as the pointer or focus moves on. Content
that stays open until it is closed is a Popover, as in the last example below, and an
explanation with a link is an InfoTip, which also opens on a tap.
Behavior
- Pointer: the label opens after the pointer rests on the element for 200 ms, or after
delay. The pointer can move onto the label without closing it, so a long label can be read to the end. - Keyboard: focusing the element opens the label at once; Escape closes it without moving focus.
- Touch: nothing opens. A press acts on the element, so the element must make sense without its label.
- Screen readers: the label is the element's description (
aria-describedby). An icon-only button needsaria-label, because a description is not a name. - Motion: the label fades in and moves 4 pixels away from its element, on the side it opens on, over
--motion-medium-duration, and reverses as it closes over--motion-fast-duration. Underprefers-reduced-motionit only fades, in and out, over the same durations.
Examples
Icon-only button
The most common tooltip: the label is the button's name, shown to the eye. The same name goes in aria-label, so a
screen reader announces the button and the label is not the only place its name lives.
Code
import { Button } from "@bosunkit/components/button";
import { Tooltip } from "@bosunkit/components/tooltip";
import { Crosshair } from "lucide-react";
import type { ReactElement } from "react";
export const description: string =
"An icon-only button: the tooltip shows its name to the eye, and aria-label gives the same name to a screen reader.";
export default function Trigger(): ReactElement {
return (
<Tooltip content="Center on vessel" side="right">
<Button size="icon" aria-label="Center on vessel">
<Crosshair size={18} aria-hidden />
</Button>
</Tooltip>
);
}
A row of icon buttons
TooltipGroup gives the tooltips inside it one delay. The first label waits 200 ms; once one is open, moving to the
next button opens its label at once, and the group waits again after 300 ms with none open. Wrap a toolbar or a rail
in one group, so reading along it does not stop at every button. The hero's toolbar is one group; its code is under
it.
Sides
side is top (the default), right, bottom or left. Choose the side that keeps the label off what the crew
is looking at: the right of a sidebar rail, below a toolbar at the top of the screen.
Code
import { Button } from "@bosunkit/components/button";
import { Tooltip } from "@bosunkit/components/tooltip";
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, type LucideIcon } from "lucide-react";
import type { ReactElement } from "react";
export const description: string =
"Four pan buttons, each with its label on the outward side: top, right, bottom and left.";
type Side = "top" | "right" | "bottom" | "left";
const PAN: readonly {
readonly side: Side;
readonly label: string;
readonly Icon: LucideIcon;
readonly area: string;
}[] = [
{ side: "top", label: "Pan north", Icon: ArrowUp, area: "1 / 2" },
{ side: "right", label: "Pan east", Icon: ArrowRight, area: "2 / 3" },
{ side: "bottom", label: "Pan south", Icon: ArrowDown, area: "3 / 2" },
{ side: "left", label: "Pan west", Icon: ArrowLeft, area: "2 / 1" },
];
export default function Sides(): ReactElement {
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, auto)",
gap: "var(--space-xs)",
alignSelf: "center",
padding: "var(--space-2xl)",
}}
>
{PAN.map(({ side, label, Icon, area }: (typeof PAN)[number]): ReactElement => (
<Tooltip key={side} content={label} side={side}>
<Button size="icon" aria-label={label} style={{ gridArea: area }}>
<Icon size={18} aria-hidden />
</Button>
</Tooltip>
))}
</div>
);
}
Alignment
align lines the label up with the start, center (the default) or end of its element, along the side it opens
on. Align a label on a wide element with the end the pointer is likely to be near.
Code
import { Button } from "@bosunkit/components/button";
import { Tooltip } from "@bosunkit/components/tooltip";
import type { ReactElement } from "react";
export const description: string =
"Three wide buttons with their labels below, lined up with the start, the center and the end of each button.";
type Align = "start" | "center" | "end";
const ALIGNMENTS: readonly Align[] = ["start", "center", "end"];
export default function Alignment(): ReactElement {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-2xl)", alignSelf: "stretch" }}>
{ALIGNMENTS.map(
(align: Align): ReactElement => (
<Tooltip key={align} content={`Aligned to the ${align}`} side="bottom" align={align}>
<Button style={{ width: "100%" }}>{`align="${align}"`}</Button>
</Tooltip>
),
)}
</div>
);
}
Flipping at an edge
When the side asked for has no room, the label opens on the opposite side, and its arrow turns to point at the
element. It never moves to a side next to the element: when neither side has the room, it takes the one with more. The room is measured against the window, or against collisionBoundary when one is given, such as a panel
whose edge the label should not cross.
Code
import { Button } from "@bosunkit/components/button";
import { Tooltip } from "@bosunkit/components/tooltip";
import { Anchor } from "lucide-react";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"The button sits at the top edge of its panel, so a label asked for above opens below, with its arrow turned.";
export default function Flip(): ReactElement {
const [panel, setPanel]: [HTMLDivElement | null, Dispatch<SetStateAction<HTMLDivElement | null>>] =
useState<HTMLDivElement | null>(null);
return (
<div
ref={setPanel}
style={{
alignSelf: "stretch",
display: "flex",
justifyContent: "center",
// Room under the button for the label, its gap and the edge's margin; none above it.
paddingBottom: "calc(var(--space-2xl) * 2)",
border: "1px dashed var(--border-bright)",
borderRadius: "var(--radius-md)",
}}
>
<Tooltip content="Anchor watch" side="top" collisionBoundary={panel}>
<Button size="icon" aria-label="Anchor watch">
<Anchor size={18} aria-hidden />
</Button>
</Tooltip>
</div>
);
}
Motion
The label fades in and moves 4 pixels away from its element, so a label above rises and a label on the left moves
left. It reverses on close. The motion reads Base UI's data-side attribute on the label for its direction and
data-ending-style for the close; under prefers-reduced-motion only the fade stays.
Code
import { Button } from "@bosunkit/components/button";
import { Label } from "@bosunkit/components/label";
import { Switch } from "@bosunkit/components/switch";
import { Tooltip } from "@bosunkit/components/tooltip";
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, type LucideIcon } from "lucide-react";
import {
useEffect,
useId,
useState,
useSyncExternalStore,
type Dispatch,
type ReactElement,
type SetStateAction,
} from "react";
export const description: string =
"Showing and hiding all four labels together: each fades in moving away from its button, and back as it closes. " +
"Slow motion stretches the 120 ms to 720 ms so the movement can be seen.";
type Side = "top" | "right" | "bottom" | "left";
const PAN: readonly {
readonly side: Side;
readonly label: string;
readonly Icon: LucideIcon;
readonly area: string;
}[] = [
{ side: "top", label: "Pan north", Icon: ArrowUp, area: "1 / 2" },
{ side: "right", label: "Pan east", Icon: ArrowRight, area: "2 / 3" },
{ side: "bottom", label: "Pan south", Icon: ArrowDown, area: "3 / 2" },
{ side: "left", label: "Pan west", Icon: ArrowLeft, area: "2 / 1" },
];
/** The token the tooltip's entrance runs on, stretched six times in slow motion as the motion page's previews
are; the close runs on `--motion-fast-duration` and keeps its own speed. */
const DURATION_TOKEN: string = "--motion-medium-duration";
const SLOW_DURATION: string = "1200ms";
const REDUCED_MOTION: string = "(prefers-reduced-motion: reduce)";
function subscribeReducedMotion(onChange: () => void): () => void {
const query: MediaQueryList = window.matchMedia(REDUCED_MOTION);
query.addEventListener("change", onChange);
return (): void => {
query.removeEventListener("change", onChange);
};
}
/** Whether the system asks for reduced motion; false on the server. */
function useReducedMotion(): boolean {
return useSyncExternalStore(
subscribeReducedMotion,
(): boolean => window.matchMedia(REDUCED_MOTION).matches,
(): boolean => false,
);
}
export default function Motion(): ReactElement {
const [shown, setShown]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
const [slow, setSlow]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
const reduced: boolean = useReducedMotion();
const slowId: string = useId();
// The labels render in a portal on the page's body, so the stretched duration is set on the root while slow motion
// is on, and taken off again when it is turned off or the example leaves the page.
useEffect((): (() => void) | undefined => {
if (!slow) {
return undefined;
}
const root: HTMLElement = document.documentElement;
const previous: string = root.style.getPropertyValue(DURATION_TOKEN);
root.style.setProperty(DURATION_TOKEN, SLOW_DURATION);
return (): void => {
if (previous === "") {
root.style.removeProperty(DURATION_TOKEN);
} else {
root.style.setProperty(DURATION_TOKEN, previous);
}
};
}, [slow]);
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", alignSelf: "stretch" }}>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-lg)", flexWrap: "wrap" }}>
<Button
aria-pressed={shown}
onClick={(): void => {
setShown(!shown);
}}
>
{shown ? "Hide labels" : "Show labels"}
</Button>
<span style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-sm)" }}>
<Switch id={slowId} checked={slow} onCheckedChange={setSlow} />
<Label htmlFor={slowId}>Slow motion (×6)</Label>
</span>
</div>
{reduced && (
<p style={{ margin: "var(--space-sm) 0 0", fontSize: 12, color: "var(--text-dim)", textAlign: "center" }}>
Your system asks for reduced motion, so the labels appear and go without moving. Turn off Reduce motion in the
system's accessibility settings to see the movement.
</p>
)}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, auto)",
gap: "var(--space-xs)",
padding: "var(--space-2xl)",
}}
>
{PAN.map(({ side, label, Icon, area }: (typeof PAN)[number]): ReactElement => (
<Tooltip key={side} content={label} side={side} open={shown}>
<Button size="icon" aria-label={label} style={{ gridArea: area }}>
<Icon size={18} aria-hidden />
</Button>
</Tooltip>
))}
</div>
</div>
);
}
Delay
The 200 ms default keeps labels from opening as the pointer crosses the screen. delay={0} opens the label as soon
as the pointer arrives, for a control whose name the crew needs before they press it, such as a small icon that two
controls share. Keyboard focus always opens the label at once.
Code
import { Button } from "@bosunkit/components/button";
import { Tooltip } from "@bosunkit/components/tooltip";
import type { ReactElement } from "react";
export const description: string =
"The default 200 ms delay beside a delay of 0, which opens the label as soon as the pointer arrives.";
export default function Delay(): ReactElement {
return (
<div style={{ display: "flex", gap: "var(--space-xl)" }}>
<Tooltip content="Opens after 200 ms">
<Button>Default delay</Button>
</Tooltip>
<Tooltip content="Opens at once" delay={0}>
<Button>No delay</Button>
</Tooltip>
</div>
);
}
Disabled control
A disabled button takes no pointer events and no focus, so a tooltip on it never opens. Wrap it in an element that
takes focus (tabIndex={0}) and give that element the tooltip. When the reason matters for what the crew does next,
also show it as text beside the button, where a touch screen shows it too.
Code
import { Button } from "@bosunkit/components/button";
import { Tooltip } from "@bosunkit/components/tooltip";
import type { ReactElement } from "react";
export const description: string =
"A disabled button takes no pointer or focus, so a focusable wrapper carries the tooltip that says what enables it.";
export default function Disabled(): ReactElement {
return (
<Tooltip title="Start passage" content="Load a route first, then start the passage from here.">
<span tabIndex={0} style={{ display: "inline-flex", borderRadius: "var(--radius-md)" }}>
<Button variant="primary" disabled style={{ pointerEvents: "none" }}>
Start passage
</Button>
</span>
</Tooltip>
);
}
Abbreviation
A term written short, such as SOG or CPA, gets a dotted underline and a tooltip with the term written out and what it measures. The underline tells the reader there is more to read; the element takes focus so the keyboard can open it.
Holding 6.2 kn on a course of 214°.
Code
import { Term } from "@bosunkit/components/tooltip";
import type { ReactElement } from "react";
export const description: string =
"An abbreviation with a dotted underline; a resting mouse, a press or a tap opens the term written out and what it measures.";
export default function TermExample(): ReactElement {
return (
<p style={{ margin: 0, color: "var(--text)" }}>
Holding 6.2 kn <Term abbreviation="SOG" definition="Speed over ground">How fast the vessel moves over the seabed, from the GPS.</Term>{" "}
on a course of 214°.
</p>
);
}
Reading source and age
A reading's tooltip names where the value comes from and how old it is. The value itself, and whether it is stale, show on the reading; the tooltip adds the detail behind them.
Code
import { ReadingDetail } from "@bosunkit/components/tooltip";
import type { ReactElement } from "react";
export const description: string =
"A depth reading whose detail names where the value comes from and how old it is.";
export default function Reading(): ReactElement {
return (
<ReadingDetail source="Echo sounder" ageText="3 s ago">
<span>
Depth <b>4.2</b> m
</span>
</ReadingDetail>
);
}
Title and text
With title, the tooltip shows the title in the label style over a line or two of body text, wrapped at a reading
width. Keep it to two short sentences: what the control does, and the one fact that helps the crew decide to use it.
Code
import { Button } from "@bosunkit/components/button";
import { Tooltip } from "@bosunkit/components/tooltip";
import { Anchor } from "lucide-react";
import type { ReactElement } from "react";
export const description: string =
"A title with two short sentences under it: what the control does, and the reading it works from.";
export default function Rich(): ReactElement {
return (
<Tooltip
title="Anchor watch"
content="Raises an alarm when the vessel leaves its swing circle. Radius 35 m; the vessel is 12 m from the anchor."
side="right"
>
<Button size="icon" aria-label="Anchor watch">
<Anchor size={18} aria-hidden />
</Button>
</Tooltip>
);
}
Keyboard shortcut
shortcut adds the key or keys that do the same thing, in a key chip after the label. Mark the same shortcut on the
button with aria-keyshortcuts, so a screen reader announces it too.
Code
import { Button } from "@bosunkit/components/button";
import { Tooltip } from "@bosunkit/components/tooltip";
import { Search } from "lucide-react";
import type { ReactElement } from "react";
export const description: string = "A label with the keyboard shortcut that does the same thing, in a key chip.";
export default function Shortcut(): ReactElement {
return (
<Tooltip content="Search" shortcut="Ctrl K" side="right">
<Button size="icon" aria-label="Search" aria-keyshortcuts="Control+K">
<Search size={18} aria-hidden />
</Button>
</Tooltip>
);
}
Priority
priority is the priority of what the tooltip explains: alarm, warning or caution draw an edge along the start of
the tooltip in that priority's alert color, and none, the default, draws no edge. Use it on a tooltip that belongs to
a badge or mark of the same priority, so the two read as one. The edge only matches: the words of the tooltip and of the
badge say the priority, because a color alone does not reach every reader. The title and text stay in the text color.
See Badge for tooltips on badges.
Code
import { Badge } from "@bosunkit/components/badge";
import { Tooltip, TooltipGroup, type TooltipPriority } from "@bosunkit/components/tooltip";
import type { CSSProperties, ReactElement } from "react";
export const description: string =
"A tooltip per priority on the badge it explains: an edge in the alarm, warning or caution color, or none.";
interface Entry {
readonly priority: TooltipPriority;
readonly variant: "red" | "amber" | "yellow" | "dim";
readonly text: string;
readonly detail: string;
}
const ENTRIES: readonly Entry[] = [
{
priority: "alarm",
variant: "red",
text: "Bilge alarm",
detail: "Water at 38 cm in the aft bilge since 03:12 UTC.",
},
{ priority: "warning", variant: "amber", text: "Oil change", detail: "Due in 18 h of engine running." },
{ priority: "caution", variant: "yellow", text: "Shoaling", detail: "6.2 m under the keel, safety depth 5.0 m." },
{ priority: "none", variant: "dim", text: "No fix", detail: "No position from the backup GPS since 03:12 UTC." },
];
const ROW: CSSProperties = { display: "flex", flexWrap: "wrap", gap: "var(--space-sm)" };
export default function Tone(): ReactElement {
return (
<TooltipGroup>
<div style={ROW}>
{ENTRIES.map((entry: Entry): ReactElement => (
<Tooltip key={entry.text} title={entry.text} content={entry.detail} priority={entry.priority} side="bottom">
<Badge variant={entry.variant} tabIndex={0}>
{entry.text}
</Badge>
</Tooltip>
))}
</div>
</TooltipGroup>
);
}
Controlled
open and onOpenChange hand the open state to the caller. Here a switch opens every label on the rail at once,
for a crew member learning the icons; with the switch off, hover opens one at a time. defaultOpen starts the tooltip open and
leaves it to the tooltip after that.
Code
import { Button } from "@bosunkit/components/button";
import { Label } from "@bosunkit/components/label";
import { Switch } from "@bosunkit/components/switch";
import { Tooltip } from "@bosunkit/components/tooltip";
import { Compass, Gauge, Map as MapIcon, Radio, type LucideIcon } from "lucide-react";
import { useId, useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"A rail whose labels follow open and onOpenChange: hover opens one, and the switch opens all of them at once.";
const PAGES: readonly { readonly label: string; readonly Icon: LucideIcon }[] = [
{ label: "Situation", Icon: Compass },
{ label: "Chart", Icon: MapIcon },
{ label: "Instruments", Icon: Gauge },
{ label: "VHF", Icon: Radio },
];
export default function Controlled(): ReactElement {
const switchId: string = useId();
const [showAll, setShowAll]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
const [hovered, setHovered]: [string | null, Dispatch<SetStateAction<string | null>>] = useState<string | null>(null);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-lg)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
<Switch id={switchId} checked={showAll} onCheckedChange={setShowAll} />
<Label htmlFor={switchId} style={{ marginBottom: 0, cursor: "pointer" }}>
Show all labels
</Label>
</div>
<nav aria-label="Pages" style={{ display: "flex", flexDirection: "column", gap: "var(--space-xs)" }}>
{PAGES.map(({ label, Icon }: { readonly label: string; readonly Icon: LucideIcon }): ReactElement => (
<Tooltip
key={label}
content={label}
side="right"
open={showAll || hovered === label}
onOpenChange={(open: boolean): void => {
setHovered(open ? label : null);
}}
>
<Button size="icon" aria-label={label}>
<Icon size={18} aria-hidden />
</Button>
</Tooltip>
))}
</nav>
</div>
);
}
A note that stays open
A note that stays open until its close button is pressed is not a tooltip. It holds a button, so by ARIA it is a
dialog that opens from a button: the kit's Popover. A press, a tap or Enter opens it with focus inside, its close
button is named in the crew's language, and Escape or a press outside closes it and returns focus to the button that
opened it.
Code
import { Button } from "@bosunkit/components/button";
import { Popover } from "@bosunkit/components/popover";
import { Info } from "lucide-react";
import type { ReactElement } from "react";
export const description: string =
"A note that stays open until its close button or Escape is pressed: a Popover from the kit, not a tooltip.";
export default function Persistent(): ReactElement {
return (
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
<span style={{ color: "var(--text)" }}>CPA alarm at 0.5 NM</span>
<Popover
trigger={
<Button size="lg" variant="ghost" aria-label="About the CPA alarm">
<Info size={18} aria-hidden />
</Button>
}
title="Closest point of approach"
side="bottom"
align="start"
>
The alarm sounds when another vessel is predicted to pass within 0.5 NM of yours.
</Popover>
</div>
);
}
The family
Tooltips matter on this display, and most of it runs with a coarse pointer, where a hover tooltip never appears.
The kit therefore offers tooltips by use case, and each picks its primitive by need. Tip label names an icon-only
control, which also carries the label as its accessible name; Tip description adds detail that is never needed to
act safely; InfoTip explains, possibly with links; Term expands an SN.1/Circ.243 abbreviation; TruncatedText
offers the whole text of a label it cut; ReadingDetail names a reading's source, age and integrity; and DataTip
shows the value under the pointer on a chart. Whatever the member, the rules hold: never the only place for
something needed to act safely, Escape dismisses it, the pointer can move onto it without closing it (WCAG 1.4.13),
and one shared timing, so the next tooltip along a row opens at once.
A term
Holding 6.2 kn on a course of 214°.
Code
import { Term } from "@bosunkit/components/tooltip";
import type { ReactElement } from "react";
export const description: string =
"An abbreviation with a dotted underline; a resting mouse, a press or a tap opens the term written out and what it measures.";
export default function TermExample(): ReactElement {
return (
<p style={{ margin: 0, color: "var(--text)" }}>
Holding 6.2 kn <Term abbreviation="SOG" definition="Speed over ground">How fast the vessel moves over the seabed, from the GPS.</Term>{" "}
on a course of 214°.
</p>
);
}
Term draws an abbreviation with a dotted underline. A resting mouse, a press or a tap opens the term written out
with what it measures; a finger never gets a hover, so the tap is the same opening.
A reading's detail
Code
import { ReadingDetail } from "@bosunkit/components/tooltip";
import type { ReactElement } from "react";
export const description: string =
"A depth reading whose detail names where the value comes from and how old it is.";
export default function Reading(): ReactElement {
return (
<ReadingDetail source="Echo sounder" ageText="3 s ago">
<span>
Depth <b>4.2</b> m
</span>
</ReadingDetail>
);
}
ReadingDetail opens from the reading itself and names its source, its age and, when there is one, why it carries
less than full trust. The source and age sit on the readout too, so the panel is always detail.
A label that cuts
Code
import { TruncatedText } from "@bosunkit/components/tooltip";
import type { ReactElement } from "react";
export const description: string =
"A label cut with an ellipsis; a resting mouse, a press or a tap offers the whole text — a label that fits stays plain.";
export default function Truncated(): ReactElement {
return (
<div style={{ width: "16em", border: "1px solid var(--border)", borderRadius: "var(--radius-md)", padding: "var(--space-sm)" }}>
<TruncatedText text="The anchor watch radius in the swing circle" />
</div>
);
}
TruncatedText cuts a one-line label with an ellipsis, and only then does it offer the whole text: a label that
fits is plain text.
The value under the pointer
Code
import { DataTip, type DataTipPoint } from "@bosunkit/components/tooltip";
import type { ReactElement } from "react";
import { useRef, type RefObject } from "react";
export const description: string =
"The value under the pointer: the tip follows the pointer across the chart, and a finger drags it along.";
export default function DataTipExample(): ReactElement {
const chart: RefObject<SVGSVGElement | null> = useRef<SVGSVGElement | null>(null);
return (
<div style={{ position: "relative", width: "100%", maxWidth: "22em" }}>
<svg ref={chart} viewBox="0 0 220 90" role="img" aria-label="A trend rising and falling.">
<polyline points="10,70 60,30 110,60 160,20 210,50" fill="none" stroke="var(--accent)" strokeWidth="2" />
</svg>
<DataTip
anchor={chart}
label="Trend values"
render={(point: DataTipPoint): ReactElement => <span>{Math.round((1 - point.y / 90) * 100)}% of range</span>}
/>
</div>
);
}
DataTip anchors a panel to a dot that follows the pointer across a chart, and the caller renders the value from
the point. The chart's own marks reach a screen reader; the tip is visual aid over them, named by label.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | undefined | – | The name of an icon-only control, which also carries the same words as its accessible name. Set label or
description, not both. |
| description | string | undefined | – | Detail that helps but is never needed to act safely — a unit, a source, what a control does. With title, a
heading above it. Set label or description, not both. |
| title | string | undefined | – | A heading above description, which then wraps at a reading width. |
| shortcut | string | undefined | – | A keyboard shortcut shown in a key chip after the text, such as Ctrl K. |
| priority | TooltipPriority | undefined | – | The priority of what the tooltip explains; none (the default) draws no edge. |
| childrenrequired | ReactElement | – | The element the tooltip describes; it must take a ref and the props of a trigger (a button, a link). |
| side | "top" | "right" | "bottom" | "left" | undefined | – | The side of the element the tooltip opens on; it opens on the opposite side when this one has no room. |
| align | "start" | "center" | "end" | undefined | – | Where along that side the tooltip lines up with the element. |
| sideOffset | number | undefined | – | The gap between the element and the tooltip, in pixels. |
| collisionBoundary | Element | null | undefined | – | The element whose edges count as having no room; the window when unset. |
| delay | number | undefined | – | How long the pointer rests on the element before the tooltip opens, in milliseconds; 200, or the group's. |
| open | boolean | undefined | – | Whether the tooltip is open, for a caller that controls it. |
| defaultOpen | boolean | undefined | – | Whether the tooltip starts open, when the caller does not control it. |
| onOpenChange | ((open: boolean) => void) | undefined | – | Called with the open state a hover, focus, Escape or press asks for. |
| testId | string | undefined | – | Becomes data-testid on the tooltip's element. |
| Prop | Type | Default | Description |
|---|---|---|---|
| side | PopoverSide | undefined | "top" | The side of the trigger the panel opens on; the opposite side when this one has no room. |
| align | PopoverAlign | undefined | "center" | Where along that side the panel lines up with the trigger. |
| sideOffset | number | undefined | – | The gap between the trigger and the panel, in pixels. |
| collisionBoundary | Element | null | undefined | – | The element whose edges count as having no room; the window when unset. |
| hoverDelay | number | undefined | – | How long a resting mouse waits before the panel opens, in milliseconds. |
| testId | string | undefined | – | Becomes data-testid on the panel; the trigger is ${testId}--trigger. |
| abbreviationrequired | string | – | The abbreviation as the crew write it: "CPA", "TCPA", "COG". |
| definitionrequired | string | – | The term written out, the panel's title: "Closest point of approach". |
| children | ReactNode | undefined | – | A sentence or two of explanation, translated by the caller. |
| Prop | Type | Default | Description |
|---|---|---|---|
| side | PopoverSide | undefined | "top" | The side of the trigger the panel opens on; the opposite side when this one has no room. |
| align | PopoverAlign | undefined | "center" | Where along that side the panel lines up with the trigger. |
| sideOffset | number | undefined | – | The gap between the trigger and the panel, in pixels. |
| collisionBoundary | Element | null | undefined | – | The element whose edges count as having no room; the window when unset. |
| hoverDelay | number | undefined | – | How long a resting mouse waits before the panel opens, in milliseconds. |
| testId | string | undefined | – | Becomes data-testid on the panel; the trigger is ${testId}--trigger. |
| sourcerequired | string | – | Where the value comes from, translated ("Echo sounder"). |
| ageText | string | undefined | – | How old the value is, written by the caller with the duration formatter ("3 s ago"); no line when unset. |
| reason | string | undefined | – | Why the reading carries less than full trust, translated; unset when the reading is intact. |
| childrenrequired | ReactNode | – | The element the detail belongs to, such as a readout; it must take a ref and a trigger's props. |
| Prop | Type | Default | Description |
|---|---|---|---|
| textrequired | string | – | The full text, drawn on one line and cut with an ellipsis when the line has no room for it. |
| maxWidth | number | string | undefined | – | How wide the line is, as any CSS length; the width of its parent when unset. |
| className | string | undefined | – | Classes added to the line's own. |
| testId | string | undefined | – | Becomes data-testid on the line. |
| Prop | Type | Default | Description |
|---|---|---|---|
| anchorrequired | { readonly current: Element | null; } | – | The chart the tip follows the pointer over; its box is the coordinates the point is measured in. |
| renderrequired | (point: DataTipPoint) => ReactNode | – | Renders the tip's content from the point, such as the trend value at that time. |
| labelrequired | string | – | The tip's name for a screen reader, translated by the caller ("Trend values"). |
| className | string | undefined | – | Classes added to the anchor dot's own. |
| testId | string | undefined | – | Becomes data-testid on the panel; the dot is ${testId}--dot. |
| Prop | Type | Default | Description |
|---|---|---|---|
| contentrequired | ReactNode | – | The tooltip's text: the element's name, or with title, a line or two that explain it. |
| title | string | undefined | – | A heading above content; the tooltip then wraps its text at a reading width. |
| shortcut | string | undefined | – | A keyboard shortcut shown in a key chip after the text, such as Ctrl K. |
| priority | TooltipPriority | undefined | "none" | The priority of what the tooltip explains: alarm, warning or caution draws an edge in that priority's alert
color along the start of the tooltip, so it matches the badge or mark it sits on; none (the default) draws
none. The color only matches; the words say the priority. |
| childrenrequired | ReactElement | – | The element the tooltip describes; it must take a ref and the props of a trigger (a button, a link). |
| side | "top" | "right" | "bottom" | "left" | undefined | "top" | The side of the element the tooltip opens on; it opens on the opposite side when this one has no room. |
| align | "start" | "center" | "end" | undefined | "center" | Where along that side the tooltip lines up with the element. |
| sideOffset | number | undefined | 6 | The gap between the element and the tooltip, in pixels. |
| collisionBoundary | Element | null | undefined | – | The element whose edges count as having no room; the window when unset. |
| delay | number | undefined | – | How long the pointer rests on the element before the tooltip opens, in milliseconds; 200, or the group's. |
| open | boolean | undefined | – | Whether the tooltip is open, for a caller that controls it. |
| defaultOpen | boolean | undefined | – | Whether the tooltip starts open, when the caller does not control it. |
| onOpenChange | ((open: boolean) => void) | undefined | – | Called with the open state a hover, focus, Escape or press asks for. |
| className | string | undefined | – | Accepted and not applied: the tooltip's look is fixed. |
| testId | string | undefined | – | Becomes data-testid on the tooltip's element. |
| Prop | Type | Default | Description |
|---|---|---|---|
| childrenrequired | ReactNode | – | The tooltips, anywhere inside, that share the group's delay. |
| delay | number | undefined | DEFAULT_DELAY | How long the pointer rests on a trigger before the first tooltip opens, in milliseconds. |
| skipDelay | number | undefined | DEFAULT_SKIP_DELAY | How long after one tooltip closes the next one still opens at once, in milliseconds. |
Related
- Accessibility: the accessible name a control needs when its label is a tooltip.
- Motion: the duration and easing tokens the tooltip's entrance uses.
- Elevation: the surface and shadow the label sits on.
- Button: the icon-only and disabled buttons most tooltips describe.
- Popover: a note, a source or a short form that stays open, and
InfoTip.