Names a control on hover and on keyboard focus, built on [`@radix-ui/react-tooltip`](https://www.radix-ui.com/primitives/docs/components/tooltip).
registry/behivetech/atoms/tooltip at v0.2.5, snapshotted 2026-11-10
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";56/**7 * Shared open delay. Lives here rather than on each caller so tooltips feel the8 * same everywhere — the `title` attribute's roughly-one-second delay is one of9 * the reasons this component exists.10 */11const DELAY_DURATION = 300;1213export type TooltipSide = "top" | "right" | "bottom" | "left";1415export 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}2526function isDisabled(element: ReactElement): boolean {27 if (!isValidElement(element)) return false;28 const { disabled } = element.props as { disabled?: boolean };29 return disabled === true;30}3132/**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 disabled38 * 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 the40 * listeners instead, and the child is made pointer-transparent so hover lands41 * on the wrapper. That keeps the explanation reachable at the one moment it42 * matters most — when the control is unavailable and the user wants to know43 * 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 });5657 const disabled = isDisabled(children);5859 // The wrapper is deliberately left unlabelled: the disabled control inside60 // keeps its own accessible name, and Radix points the wrapper's61 // `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 children68 );6970 return (71 <RadixTooltip.Provider delayDuration={DELAY_DURATION}>72 <RadixTooltip.Root>73 <RadixTooltip.Trigger asChild>{trigger}</RadixTooltip.Trigger>74 <RadixTooltip.Portal>75 <RadixTooltip.Content76 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, so2// its fallback is the old on-surface/surface pair swapped — which is exactly3// what "inverse" means.45.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;1920 &[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 }2526 &__arrow {27 fill: var(--md-sys-color-inverse-surface, var(--color-on-surface));28 }2930 // A disabled control fires no pointer events of its own, so the wrapper takes31 // the listeners and the control is made transparent to the pointer.32 &__disabledTrigger {33 display: inline-flex;3435 > * {36 pointer-events: none;37 }3839 &:focus-visible {40 outline: 3px solid41 var(--md-sys-color-secondary, var(--color-border-focus));42 outline-offset: 2px;43 }44 }45}4647@keyframes tooltip-in {48 from {49 opacity: 0;50 }5152 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";23export const BasicTooltip = () => (4 <Tooltip label="Rectangle">5 <button type="button">Rect</button>6 </Tooltip>7);89export 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);2526/** 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 Road31 </button>32 </Tooltip>33);