Names a control on hover and on keyboard focus, built on [`@radix-ui/react-tooltip`](https://www.radix-ui.com/primitives/docs/components/tooltip).

pnpm add @behivetech/atoms.tooltip

You are viewing Tooltip v0.2.4, snapshotted 2026-11-09. The latest is v0.2.6. Go to the latest →

registry/behivetech/atoms/tooltip at v0.2.4, snapshotted 2026-11-09

tooltip.tsx
1import { isValidElement, type ReactElement } from "react";
2import * as RadixTooltip from "@radix-ui/react-tooltip";
3import { getClassName } from "@bht-component-registry/class-names";
4import styles from "./tooltip.module.scss";
5
6/**
7 * Shared open delay. Lives here rather than on each caller so tooltips feel the
8 * same everywhere — the `title` attribute's roughly-one-second delay is one of
9 * the reasons this component exists.
10 */
11const DELAY_DURATION = 300;
12
13export type TooltipSide = "top" | "right" | "bottom" | "left";
14
15export interface TooltipProps {
16 /** Text shown in the tooltip. This is the control's name when the trigger is icon-only. */
17 label: string;
18 /** Which side of the trigger to place the tooltip on; defaults to "top" */
19 side?: TooltipSide;
20 /** The element the tooltip describes — rendered as-is via Radix's `asChild` */
21 children: ReactElement;
22 /** Additional class names to merge with the tooltip content element */
23 className?: string;
24}
25
26function isDisabled(element: ReactElement): boolean {
27 if (!isValidElement(element)) return false;
28 const { disabled } = element.props as { disabled?: boolean };
29 return disabled === true;
30}
31
32/**
33 * Names a control on hover and on keyboard focus, built on Radix's `Tooltip`.
34 *
35 * Mounts its own `Tooltip.Provider`, so a caller never has to add one.
36 *
37 * Works on a disabled trigger, which a bare Radix tooltip does not: a disabled
38 * element fires no pointer events, so the trigger never sees hover or focus.
39 * When the child is disabled it is wrapped in a focusable span that carries the
40 * listeners instead, and the child is made pointer-transparent so hover lands
41 * on the wrapper. That keeps the explanation reachable at the one moment it
42 * matters most — when the control is unavailable and the user wants to know
43 * why — including by keyboard, since a disabled control is not focusable.
44 */
45export const Tooltip = ({
46 label,
47 side = "top",
48 children,
49 className,
50}: TooltipProps) => {
51 const [, getChildClass] = getClassName({
52 className,
53 rootClass: "BHT__Tooltip",
54 styles,
55 });
56
57 const disabled = isDisabled(children);
58
59 // The wrapper is deliberately left unlabelled: the disabled control inside
60 // keeps its own accessible name, and Radix points the wrapper's
61 // `aria-describedby` at the tooltip, so focusing it announces both.
62 const trigger = disabled ? (
63 <span className={getChildClass("disabledTrigger")} tabIndex={0}>
64 {children}
65 </span>
66 ) : (
67 children
68 );
69
70 return (
71 <RadixTooltip.Provider delayDuration={DELAY_DURATION}>
72 <RadixTooltip.Root>
73 <RadixTooltip.Trigger asChild>{trigger}</RadixTooltip.Trigger>
74 <RadixTooltip.Portal>
75 <RadixTooltip.Content
76 className={getChildClass("content")}
77 side={side}
78 sideOffset={6}
79 >
80 {label}
81 <RadixTooltip.Arrow className={getChildClass("arrow")} />
82 </RadixTooltip.Content>
83 </RadixTooltip.Portal>
84 </RadixTooltip.Root>
85 </RadixTooltip.Provider>
86 );
87};
tooltip.module.scss
1// Material Design 3 plain tooltip. `inverse-surface` has no pre-MD3 name, so
2// its fallback is the old on-surface/surface pair swapped — which is exactly
3// what "inverse" means.
4
5.BHT__Tooltip {
6 &__content {
7 padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);
8 background: var(--md-sys-color-inverse-surface, var(--color-on-surface));
9 color: var(--md-sys-color-inverse-on-surface, var(--color-surface));
10 border-radius: var(--md-sys-shape-corner-extra-small, 4px);
11 font-family: var(--md-sys-typescale-body-small-font, var(--font-body));
12 font-size: var(--md-sys-typescale-body-small-size, 0.75rem);
13 font-weight: var(--md-sys-typescale-body-small-weight, 400);
14 line-height: var(--md-sys-typescale-body-small-line-height, 1rem);
15 letter-spacing: var(--md-sys-typescale-body-small-tracking, 0.0333em);
16 max-width: 16rem;
17 z-index: var(--z-tooltip, 600);
18 user-select: none;
19
20 &[data-state="delayed-open"] {
21 animation: tooltip-in var(--md-sys-motion-duration-short-3, 150ms)
22 var(--md-sys-motion-easing-standard-decelerate, ease-out);
23 }
24 }
25
26 &__arrow {
27 fill: var(--md-sys-color-inverse-surface, var(--color-on-surface));
28 }
29
30 // A disabled control fires no pointer events of its own, so the wrapper takes
31 // the listeners and the control is made transparent to the pointer.
32 &__disabledTrigger {
33 display: inline-flex;
34
35 > * {
36 pointer-events: none;
37 }
38
39 &:focus-visible {
40 outline: 3px solid
41 var(--md-sys-color-secondary, var(--color-border-focus));
42 outline-offset: 2px;
43 }
44 }
45}
46
47@keyframes tooltip-in {
48 from {
49 opacity: 0;
50 }
51
52 to {
53 opacity: 1;
54 }
55}
index.ts
1export { Tooltip } from "./tooltip.js";
2export type { TooltipProps, TooltipSide } from "./tooltip.js";
tooltip.example.tsx
1import { Tooltip } from "./tooltip.js";
2
3export const BasicTooltip = () => (
4 <Tooltip label="Rectangle">
5 <button type="button">Rect</button>
6 </Tooltip>
7);
8
9export const TooltipSides = () => (
10 <div style={{ display: "flex", gap: "1.5rem", padding: "3rem" }}>
11 <Tooltip label="Above" side="top">
12 <button type="button">Top</button>
13 </Tooltip>
14 <Tooltip label="To the right" side="right">
15 <button type="button">Right</button>
16 </Tooltip>
17 <Tooltip label="Below" side="bottom">
18 <button type="button">Bottom</button>
19 </Tooltip>
20 <Tooltip label="To the left" side="left">
21 <button type="button">Left</button>
22 </Tooltip>
23 </div>
24);
25
26/** The case a bare Radix tooltip cannot do — and the reason this is an atom. */
27export const DisabledTrigger = () => (
28 <Tooltip label="You do not have permission to edit roads" side="right">
29 <button type="button" disabled>
30 Road
31 </button>
32 </Tooltip>
33);