Skip to content
design
Components

Tooltip

A short label that names or explains an element when it is hovered or focused.

On this page
A chart toolbar of icon buttons. Rest the pointer on one, then move along the row: each next label opens at once.
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.

01Never the only placeA touch screen has no hover. What the crew needs to act safely is on the screen; a tooltip only names or explains what is already there.
02Short, and nothing to pressA name of a few words, or a title over a line or two. A link, a button or anything else a hand must reach goes in a popover.
03Little motionIt fades in while it moves a few pixels away from its element, and only fades when the system asks for reduced motion.

When to use a tooltip

UseWhenExample
TooltipThe 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
PopoverThe 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 screenThe 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 needs aria-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. Under prefers-reduced-motion it 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.

An icon-only button: the tooltip shows its name to the eye, and aria-label gives the same name to a screen reader.
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.

Four pan buttons, each with its label on the outward side: top, right, bottom and left.
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.

Three wide buttons with their labels below, lined up with the start, the center and the end of each button.
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.

The button sits at the top edge of its panel, so a label asked for above opens below, with its arrow turned.
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.

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.
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&apos;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.

The default 200 ms delay beside a delay of 0, which opens the label as soon as the pointer arrives.
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.

A disabled button takes no pointer or focus, so a focusable wrapper carries the tooltip that says what enables it.
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°.

An abbreviation with a dotted underline; a resting mouse, a press or a tap opens the term written out and what it measures.
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.

A depth reading whose detail names where the value comes from and how old it is.
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.

A title with two short sentences under it: what the control does, and the reading it works from.
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.

A label with the keyboard shortcut that does the same thing, in a key chip.
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.

A tooltip per priority on the badge it explains: an edge in the alarm, warning or caution color, or none.
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.

A rail whose labels follow open and onOpenChange: hover opens one, and the switch opens all of them at once.
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.

CPA alarm at 0.5 NM
A note that stays open until its close button or Escape is pressed: a Popover from the kit, not a tooltip.
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°.

An abbreviation with a dotted underline; a resting mouse, a press or a tap opens the term written out and what it measures.
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

A depth reading whose detail names where the value comes from and how old it is.
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

A label cut with an ellipsis; a resting mouse, a press or a tap offers the whole text — a label that fits stays plain.
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

The value under the pointer: the tip follows the pointer across the chart, and a finger drags it along.
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

PropTypeDefaultDescription
labelstring | undefined–The name of an icon-only control, which also carries the same words as its accessible name. Set label or description, not both.
descriptionstring | 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.
titlestring | undefined–A heading above description, which then wraps at a reading width.
shortcutstring | undefined–A keyboard shortcut shown in a key chip after the text, such as Ctrl K.
priorityTooltipPriority | undefined–The priority of what the tooltip explains; none (the default) draws no edge.
childrenrequiredReactElement–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.
sideOffsetnumber | undefined–The gap between the element and the tooltip, in pixels.
collisionBoundaryElement | null | undefined–The element whose edges count as having no room; the window when unset.
delaynumber | undefined–How long the pointer rests on the element before the tooltip opens, in milliseconds; 200, or the group's.
openboolean | undefined–Whether the tooltip is open, for a caller that controls it.
defaultOpenboolean | 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.
testIdstring | undefined–Becomes data-testid on the tooltip's element.
PropTypeDefaultDescription
sidePopoverSide | undefined"top"The side of the trigger the panel opens on; the opposite side when this one has no room.
alignPopoverAlign | undefined"center"Where along that side the panel lines up with the trigger.
sideOffsetnumber | undefined–The gap between the trigger and the panel, in pixels.
collisionBoundaryElement | null | undefined–The element whose edges count as having no room; the window when unset.
hoverDelaynumber | undefined–How long a resting mouse waits before the panel opens, in milliseconds.
testIdstring | undefined–Becomes data-testid on the panel; the trigger is ${testId}--trigger.
abbreviationrequiredstring–The abbreviation as the crew write it: "CPA", "TCPA", "COG".
definitionrequiredstring–The term written out, the panel's title: "Closest point of approach".
childrenReactNode | undefined–A sentence or two of explanation, translated by the caller.
PropTypeDefaultDescription
sidePopoverSide | undefined"top"The side of the trigger the panel opens on; the opposite side when this one has no room.
alignPopoverAlign | undefined"center"Where along that side the panel lines up with the trigger.
sideOffsetnumber | undefined–The gap between the trigger and the panel, in pixels.
collisionBoundaryElement | null | undefined–The element whose edges count as having no room; the window when unset.
hoverDelaynumber | undefined–How long a resting mouse waits before the panel opens, in milliseconds.
testIdstring | undefined–Becomes data-testid on the panel; the trigger is ${testId}--trigger.
sourcerequiredstring–Where the value comes from, translated ("Echo sounder").
ageTextstring | undefined–How old the value is, written by the caller with the duration formatter ("3 s ago"); no line when unset.
reasonstring | undefined–Why the reading carries less than full trust, translated; unset when the reading is intact.
childrenrequiredReactNode–The element the detail belongs to, such as a readout; it must take a ref and a trigger's props.
PropTypeDefaultDescription
textrequiredstring–The full text, drawn on one line and cut with an ellipsis when the line has no room for it.
maxWidthnumber | string | undefined–How wide the line is, as any CSS length; the width of its parent when unset.
classNamestring | undefined–Classes added to the line's own.
testIdstring | undefined–Becomes data-testid on the line.
PropTypeDefaultDescription
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.
labelrequiredstring–The tip's name for a screen reader, translated by the caller ("Trend values").
classNamestring | undefined–Classes added to the anchor dot's own.
testIdstring | undefined–Becomes data-testid on the panel; the dot is ${testId}--dot.
PropTypeDefaultDescription
contentrequiredReactNode–The tooltip's text: the element's name, or with title, a line or two that explain it.
titlestring | undefined–A heading above content; the tooltip then wraps its text at a reading width.
shortcutstring | undefined–A keyboard shortcut shown in a key chip after the text, such as Ctrl K.
priorityTooltipPriority | 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.
childrenrequiredReactElement–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.
sideOffsetnumber | undefined6The gap between the element and the tooltip, in pixels.
collisionBoundaryElement | null | undefined–The element whose edges count as having no room; the window when unset.
delaynumber | undefined–How long the pointer rests on the element before the tooltip opens, in milliseconds; 200, or the group's.
openboolean | undefined–Whether the tooltip is open, for a caller that controls it.
defaultOpenboolean | 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.
classNamestring | undefined–Accepted and not applied: the tooltip's look is fixed.
testIdstring | undefined–Becomes data-testid on the tooltip's element.
PropTypeDefaultDescription
childrenrequiredReactNode–The tooltips, anywhere inside, that share the group's delay.
delaynumber | undefinedDEFAULT_DELAYHow long the pointer rests on a trigger before the first tooltip opens, in milliseconds.
skipDelaynumber | undefinedDEFAULT_SKIP_DELAYHow long after one tooltip closes the next one still opens at once, in milliseconds.
Do
Name an icon-only button. The tooltip shows the name, and aria-label gives it to a screen reader.
Don't
Don't repeat a label the button already shows. The tooltip adds nothing, and covers what is beside it.
Do
Load a route to start the passage.
Put what the crew needs to act on the screen. The reason a control is disabled reads on a touch screen too.
Don't
Don't leave it in a tooltip alone. A finger on a touch screen never opens it.
  • 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.