Tooltip

Light-surface tooltip built on React Aria.

Sizes

Light-surface tooltips with a caret.

function TooltipDemo() {
  return (
    <div className="flex flex-wrap items-center gap-4">
      <TooltipTrigger>
        <Button variant="secondary">Hover me</Button>
        <Tooltip>Copied to clipboard</Tooltip>
      </TooltipTrigger>
      <TooltipTrigger>
        <Button variant="secondary">Larger</Button>
        <Tooltip size="md">Runs the full production build</Tooltip>
      </TooltipTrigger>
    </div>
  );
}
function TooltipDemo() {
  return (
    <div className="flex flex-wrap items-center gap-4">
      <TooltipTrigger>
        <Button variant="secondary">Hover me</Button>
        <Tooltip>Copied to clipboard</Tooltip>
      </TooltipTrigger>
      <TooltipTrigger>
        <Button variant="secondary">Larger</Button>
        <Tooltip size="md">Runs the full production build</Tooltip>
      </TooltipTrigger>
    </div>
  );
}

Installation

npx shadcn@latest add https://boardcn.dev/r/tooltip.json
npx shadcn@latest add https://boardcn.dev/r/tooltip.json

npm packages

  • react-aria-components

BoardCN dependencies

The CLI installs these for you — you do not need to add them yourself.

Source

The file the CLI copies into your project.

components/base/tooltip/tooltip.tsx
"use client";

import type { ComponentProps, ReactNode } from "react";
import {
  OverlayArrow as AriaOverlayArrow,
  Tooltip as AriaTooltip,
  TooltipTrigger as AriaTooltipTrigger,
} from "react-aria-components";
import type { TooltipProps as AriaTooltipProps } from "react-aria-components";
import { cx, sortCx } from "@/utils/cx";

/**
 * Light-surface tooltip, styled to match the card / dropdown family:
 *   surface  bg background/primary/default (white), 1px border/button/default,
 *            radius/lg (8px), shadow/dropdown, text/primary.
 *   sizes    sm → px 10 py 6, Caption 1/Medium (12px)
 *            md → px 12 py 8, Body 1/Medium   (14px)
 *   arrow    filled caret (background/primary/default); a distinct path per
 *            side (not one path rotated with CSS) faces the trigger via
 *            OverlayArrow's `placement` render prop — see `TOOLTIP_CARETS` below.
 *
 * Behaviour comes from react-aria's TooltipTrigger / Tooltip — hover + focus
 * open, Escape closes, positioning + collision flipping, and the
 * `aria-describedby` wiring onto the trigger. We only dress the surface.
 *
 *   <TooltipTrigger delay={0}>
 *     <Button>Hover me</Button>
 *     <Tooltip size="md">Helpful copy</Tooltip>
 *   </TooltipTrigger>
 *
 * The trigger's single child must be focusable (react-aria `Button`, or any
 * component built on `useFocusable`).
 * BoardCN opens and begins closing immediately. The tooltip surface itself is
 * non-interactive, so moving off the trigger never keeps it open and tooltip
 * copy cannot be selected accidentally.
 */

export type TooltipSize = "sm" | "md";

const sizes = sortCx({
  sm: "px-2.5 py-1.5 text-caption-1-medium",
  md: "px-3 py-2 text-body-medium rounded-2lg",
});

/**
 * One hand-drawn caret per placement instead of a single path rotated with
 * CSS `transform`. Rotating a non-square (12×7) SVG changes its *visual*
 * footprint (7×12 at 90°) but not its *layout* box — `transform` never
 * affects layout — so `OverlayArrow` (which sizes/positions the arrow using
 * its unrotated 12×7 box) leaves a real ~2.5px gap between the rotated
 * triangle and the tooltip body for `left`/`right`. Each entry below instead
 * has `width`/`height` matching its own true rendered footprint, so the
 * layout box is always exactly right — no transform, no gap.
 *
 * Each path is left open on the edge touching the tooltip body (no "Z", so
 * only the two other edges get a stroke), and `shadow` points further along
 * the direction the tip already points — past the tip, never back toward the
 * body — matching the one spot that already looked right by default.
 */
export const TOOLTIP_CARETS = sortCx({
  top: { width: 12, height: 7, path: "M0 0 L6 6 L12 0", shadow: "0 1.5px 1px" },
  bottom: { width: 12, height: 7, path: "M0 7 L6 1 L12 7", shadow: "0 -1.5px 1px" },
  // left/right carets stick out sideways from the body; their shadow should
  // still fall *downward* (light-from-top, matching the body's shadow-dropdown)
  // rather than horizontally, which read as floating.
  left: { width: 7, height: 12, path: "M0 0 L6 6 L0 12", shadow: "0 1.5px 1px" },
  right: { width: 7, height: 12, path: "M7 0 L1 6 L7 12", shadow: "0 1.5px 1px" },
});

export interface TooltipProps extends Omit<AriaTooltipProps, "children"> {
  children?: ReactNode;
  /** Surface size. `sm` → 12px Caption 1, `md` → 14px Body 1. Default `sm`. */
  size?: TooltipSize;
  /** Show the little caret pointing at the trigger. Default `true`. */
  showArrow?: boolean;
}

export function Tooltip({ children, className, size = "sm", showArrow = true, offset = 10, ...props }: TooltipProps) {
  return (
    <AriaTooltip
      offset={offset}
      {...props}
      className={(state) =>
        cx(
          "pointer-events-none z-50 max-w-[240px] select-none rounded-lg border border-border-button-default bg-background-primary-default text-text-primary shadow-dropdown",
          sizes[size],
          // Blur + scale in/out. react-aria stamps `data-entering` on mount then
          // removes it (transition to base), and holds the element mounted while
          // `data-exiting` is set so the same transition plays in reverse. The
          // `transition` utility covers scale, filter (blur) and opacity.
          "transition duration-200 ease-out",
          "data-[entering]:scale-90 data-[entering]:opacity-0 data-[entering]:blur-[4px]",
          "data-[exiting]:scale-90 data-[exiting]:opacity-0 data-[exiting]:blur-[4px]",
          typeof className === "function" ? className(state) : className,
        )
      }
    >
      {showArrow && (
        <AriaOverlayArrow>
          {({ placement }) => {
            // `placement` includes react-aria's `"center"` (for other overlay
            // types); this tooltip only ever resolves to one of our 4 sides.
            const caret = TOOLTIP_CARETS[placement as keyof typeof TOOLTIP_CARETS] ?? TOOLTIP_CARETS.top;
            return (
              <svg
                width={caret.width}
                height={caret.height}
                viewBox={`0 0 ${caret.width} ${caret.height}`}
                className="block overflow-visible fill-background-primary-default stroke-border-button-default"
                style={{ filter: `drop-shadow(${caret.shadow} rgb(0 0 0 / 0.05))` }}
              >
                <path d={caret.path} />
              </svg>
            );
          }}
        </AriaOverlayArrow>
      )}
      {children}
    </AriaTooltip>
  );
}

export type TooltipTriggerProps = ComponentProps<typeof AriaTooltipTrigger>;

export function TooltipTrigger({ delay = 0, closeDelay = 0, ...props }: TooltipTriggerProps) {
  return <AriaTooltipTrigger delay={delay} closeDelay={closeDelay} {...props} />;
}
"use client";

import type { ComponentProps, ReactNode } from "react";
import {
  OverlayArrow as AriaOverlayArrow,
  Tooltip as AriaTooltip,
  TooltipTrigger as AriaTooltipTrigger,
} from "react-aria-components";
import type { TooltipProps as AriaTooltipProps } from "react-aria-components";
import { cx, sortCx } from "@/utils/cx";

/**
 * Light-surface tooltip, styled to match the card / dropdown family:
 *   surface  bg background/primary/default (white), 1px border/button/default,
 *            radius/lg (8px), shadow/dropdown, text/primary.
 *   sizes    sm → px 10 py 6, Caption 1/Medium (12px)
 *            md → px 12 py 8, Body 1/Medium   (14px)
 *   arrow    filled caret (background/primary/default); a distinct path per
 *            side (not one path rotated with CSS) faces the trigger via
 *            OverlayArrow's `placement` render prop — see `TOOLTIP_CARETS` below.
 *
 * Behaviour comes from react-aria's TooltipTrigger / Tooltip — hover + focus
 * open, Escape closes, positioning + collision flipping, and the
 * `aria-describedby` wiring onto the trigger. We only dress the surface.
 *
 *   <TooltipTrigger delay={0}>
 *     <Button>Hover me</Button>
 *     <Tooltip size="md">Helpful copy</Tooltip>
 *   </TooltipTrigger>
 *
 * The trigger's single child must be focusable (react-aria `Button`, or any
 * component built on `useFocusable`).
 * BoardCN opens and begins closing immediately. The tooltip surface itself is
 * non-interactive, so moving off the trigger never keeps it open and tooltip
 * copy cannot be selected accidentally.
 */

export type TooltipSize = "sm" | "md";

const sizes = sortCx({
  sm: "px-2.5 py-1.5 text-caption-1-medium",
  md: "px-3 py-2 text-body-medium rounded-2lg",
});

/**
 * One hand-drawn caret per placement instead of a single path rotated with
 * CSS `transform`. Rotating a non-square (12×7) SVG changes its *visual*
 * footprint (7×12 at 90°) but not its *layout* box — `transform` never
 * affects layout — so `OverlayArrow` (which sizes/positions the arrow using
 * its unrotated 12×7 box) leaves a real ~2.5px gap between the rotated
 * triangle and the tooltip body for `left`/`right`. Each entry below instead
 * has `width`/`height` matching its own true rendered footprint, so the
 * layout box is always exactly right — no transform, no gap.
 *
 * Each path is left open on the edge touching the tooltip body (no "Z", so
 * only the two other edges get a stroke), and `shadow` points further along
 * the direction the tip already points — past the tip, never back toward the
 * body — matching the one spot that already looked right by default.
 */
export const TOOLTIP_CARETS = sortCx({
  top: { width: 12, height: 7, path: "M0 0 L6 6 L12 0", shadow: "0 1.5px 1px" },
  bottom: { width: 12, height: 7, path: "M0 7 L6 1 L12 7", shadow: "0 -1.5px 1px" },
  // left/right carets stick out sideways from the body; their shadow should
  // still fall *downward* (light-from-top, matching the body's shadow-dropdown)
  // rather than horizontally, which read as floating.
  left: { width: 7, height: 12, path: "M0 0 L6 6 L0 12", shadow: "0 1.5px 1px" },
  right: { width: 7, height: 12, path: "M7 0 L1 6 L7 12", shadow: "0 1.5px 1px" },
});

export interface TooltipProps extends Omit<AriaTooltipProps, "children"> {
  children?: ReactNode;
  /** Surface size. `sm` → 12px Caption 1, `md` → 14px Body 1. Default `sm`. */
  size?: TooltipSize;
  /** Show the little caret pointing at the trigger. Default `true`. */
  showArrow?: boolean;
}

export function Tooltip({ children, className, size = "sm", showArrow = true, offset = 10, ...props }: TooltipProps) {
  return (
    <AriaTooltip
      offset={offset}
      {...props}
      className={(state) =>
        cx(
          "pointer-events-none z-50 max-w-[240px] select-none rounded-lg border border-border-button-default bg-background-primary-default text-text-primary shadow-dropdown",
          sizes[size],
          // Blur + scale in/out. react-aria stamps `data-entering` on mount then
          // removes it (transition to base), and holds the element mounted while
          // `data-exiting` is set so the same transition plays in reverse. The
          // `transition` utility covers scale, filter (blur) and opacity.
          "transition duration-200 ease-out",
          "data-[entering]:scale-90 data-[entering]:opacity-0 data-[entering]:blur-[4px]",
          "data-[exiting]:scale-90 data-[exiting]:opacity-0 data-[exiting]:blur-[4px]",
          typeof className === "function" ? className(state) : className,
        )
      }
    >
      {showArrow && (
        <AriaOverlayArrow>
          {({ placement }) => {
            // `placement` includes react-aria's `"center"` (for other overlay
            // types); this tooltip only ever resolves to one of our 4 sides.
            const caret = TOOLTIP_CARETS[placement as keyof typeof TOOLTIP_CARETS] ?? TOOLTIP_CARETS.top;
            return (
              <svg
                width={caret.width}
                height={caret.height}
                viewBox={`0 0 ${caret.width} ${caret.height}`}
                className="block overflow-visible fill-background-primary-default stroke-border-button-default"
                style={{ filter: `drop-shadow(${caret.shadow} rgb(0 0 0 / 0.05))` }}
              >
                <path d={caret.path} />
              </svg>
            );
          }}
        </AriaOverlayArrow>
      )}
      {children}
    </AriaTooltip>
  );
}

export type TooltipTriggerProps = ComponentProps<typeof AriaTooltipTrigger>;

export function TooltipTrigger({ delay = 0, closeDelay = 0, ...props }: TooltipTriggerProps) {
  return <AriaTooltipTrigger delay={delay} closeDelay={closeDelay} {...props} />;
}

Props

Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.

Tooltip

PropTypeDefaultDescription
showArrowbooleantrueShow the little caret pointing at the trigger. Default `true`.
size"sm" | "md"smSurface size. `sm` → 12px Caption 1, `md` → 14px Body 1. Default `sm`.