Tooltip
A short hint on hover and keyboard focus that Esc dismisses, linked to its trigger for screen readers.
betaTooltip.tsx
Examples
On icon buttons
Hover or Tab to the buttons. Esc hides the hint.
NotificationsCopy a share linkSettings
tooltip/basic.tsx
import { Bell, Settings, Share2 } from "lucide-react";
import { Button, Tooltip } from "@streamuilib/react";
export default function TooltipBasic() {
return (
<div className="flex gap-4 pt-10">
<Tooltip content="Notifications">
<Button size="icon" variant="secondary" aria-label="Notifications">
<Bell aria-hidden />
</Button>
</Tooltip>
<Tooltip content="Copy a share link">
<Button size="icon" variant="secondary" aria-label="Share">
<Share2 aria-hidden />
</Button>
</Tooltip>
<Tooltip content="Settings" side="bottom">
<Button size="icon" variant="secondary" aria-label="Settings">
<Settings aria-hidden />
</Button>
</Tooltip>
</div>
);
}Installation
bun add @streamuilib/reactusage.tsx
import { Button, Tooltip } from "@streamuilib/react";
<Tooltip content="Notifications">
<Button size="icon" aria-label="Notifications">…</Button>
</Tooltip>API
| Prop | Type | Default | Description |
|---|---|---|---|
| content | ReactNode | — | The hint. Keep it short. Required. |
| children | ReactElement | — | One focusable trigger; it gets aria-describedby. |
| side | "top" | "bottom" | "top" | Which side of the trigger to show on. |
| delay | number | 150 | Hover delay in ms. Focus shows immediately. |
| className | string | — | Classes for the tooltip bubble. |
Accessibility
- Shows on keyboard focus as well as hover, and Esc dismisses it without moving focus (WCAG 1.4.13).
- The bubble has role="tooltip" and is linked with aria-describedby, so it's read after the trigger's name.
- Tooltips are hints, not labels: icon-only buttons still need their own aria-label.
Source
packages/react/src/Tooltip.tsx
"use client";
import {
cloneElement,
isValidElement,
useEffect,
useId,
useRef,
useState,
type ReactElement,
type ReactNode,
} from "react";
import { cn } from "./cn";
export type TooltipProps = {
/** The tooltip text. Keep it short; it's a hint, not content. */
content: ReactNode;
/** One focusable element, e.g. an icon button. */
children: ReactElement;
side?: "top" | "bottom";
/** Delay before showing on hover, in ms. Focus shows immediately. */
delay?: number;
className?: string;
};
const sides = {
top: "bottom-full mb-2.5",
bottom: "top-full mt-2.5",
};
/**
* Shows on hover and keyboard focus, hides on Esc, blur, or pointer leave
* (WCAG 1.4.13). The trigger gets aria-describedby, so screen readers read
* the hint after the trigger's own name.
*/
export function Tooltip({ content, children, side = "top", delay = 150, className }: TooltipProps) {
const [open, setOpen] = useState(false);
const id = useId();
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
const show = (wait: number) => {
clearTimeout(timer.current);
timer.current = setTimeout(() => setOpen(true), wait);
};
const hide = () => {
clearTimeout(timer.current);
setOpen(false);
};
return (
<span
className="relative inline-flex"
onPointerEnter={() => show(delay)}
onPointerLeave={hide}
onFocus={() => show(0)}
onBlur={hide}
onKeyDown={(e) => {
if (e.key === "Escape" && open) {
e.stopPropagation();
hide();
}
}}
>
{isValidElement(children)
? cloneElement(children, { "aria-describedby": id } as Record<string, unknown>)
: children}
<span
role="tooltip"
id={id}
hidden={!open}
className={cn(
"pointer-events-none absolute left-1/2 z-50 w-max max-w-60 -translate-x-1/2 border-2 border-ink bg-ink px-2.5 py-1.5 text-center font-mono text-xs leading-snug text-paper shadow-brutal-sm",
sides[side],
className,
)}
>
{content}
</span>
</span>
);
}