stream/ui
Browse the library

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.

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/react
usage.tsx
import { Button, Tooltip } from "@streamuilib/react";

<Tooltip content="Notifications">
  <Button size="icon" aria-label="Notifications">…</Button>
</Tooltip>

API

PropTypeDefaultDescription
contentReactNode—The hint. Keep it short. Required.
childrenReactElement—One focusable trigger; it gets aria-describedby.
side"top" | "bottom""top"Which side of the trigger to show on.
delaynumber150Hover delay in ms. Focus shows immediately.
classNamestring—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>
  );
}