Skip to examples
Bento / Kitchen sink
Bento / primitives

Overlays

Content that floats above the page. Every overlay is portalled, closes on Escape, and returns focus to what opened it.

Dialog

title required · focus trapped

form

The title is a required prop. A dialog without a name is announced as just “dialog”. hideTitle hides it visually; it is still announced.

Sourcecomponents/overlays/dialog/doc.ts · components/overlays/dialog/dialog.tsx · components/overlays/doc.ts · components/overlays/overlay.module.css

components/overlays/dialog/doc.ts

/**
 * Dialog — a task that takes over the page until done or dismissed.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     Dialog          open?, defaultOpen?, onOpenChange?
 *     DialogTrigger   usually a Button (asChild)
 *     DialogContent   title, REQUIRED; description?; hideTitle?; closeLabel?
 *     DialogFooter    the actions
 *     DialogClose     closes it; usually wraps a Button
 *
 * # Behaviour
 *
 * R1  Modal: a scrim covers the page, focus moves inside and is trapped there,
 *     and the page behind is hidden from assistive technology.
 * R2  It is named by its title, which is required; `hideTitle` hides it
 *     visually but it is still announced. It is described by `description`
 *     when given.
 * R3  It closes on Escape, on the close button (named by `closeLabel`,
 *     default "Close"), and on a click outside.
 * R4  Centred, at most 480px wide, and scrolls within itself when taller than
 *     the viewport.
 */
export {};

components/overlays/dialog/dialog.tsx

"use client";

import * as Primitive from "@radix-ui/react-dialog";
import type { ComponentPropsWithRef, ReactNode } from "react";

import { X } from "@/components/utility/icon";
import { visuallyHidden } from "@/components/utility/visually-hidden";
import { cn } from "@/lib/utils/cn";
import surface from "../../surface.module.css";
import styles from "../overlay.module.css";

export type DialogProps = ComponentPropsWithRef<typeof Primitive.Root>;

export const Dialog = (props: DialogProps) => <Primitive.Root {...props} />;
export const DialogTrigger = Primitive.Trigger;
export const DialogClose = Primitive.Close;

export type DialogContentProps = Omit<
  ComponentPropsWithRef<typeof Primitive.Content>,
  "title"
> & {
  /** Required: a dialog with no name is announced as just "dialog". */
  title: string;
  description?: string;
  /** Hide the title visually where the design already names the dialog. It
   *  is still announced; this never omits it. */
  hideTitle?: boolean;
  closeLabel?: string;
  children: ReactNode;
};

export function DialogContent({
  title,
  description,
  hideTitle = false,
  closeLabel = "Close",
  className,
  children,
  ...props
}: DialogContentProps) {
  return (
    <Primitive.Portal>
      <Primitive.Overlay className={styles.scrim} />
      <Primitive.Content
        {...props}
        // Without a description, tell Radix there is none on purpose.
        {...(description ? {} : { "aria-describedby": undefined })}
        className={cn(surface.elevated, styles.panel, className)}
      >
        <div className={styles.head}>
          <Primitive.Title
            className={hideTitle ? visuallyHidden.root : styles.title}
          >
            {title}
          </Primitive.Title>
          {description ? (
            <Primitive.Description className={styles.description}>
              {description}
            </Primitive.Description>
          ) : null}
        </div>
        <div className={styles.body}>{children}</div>
        <Primitive.Close className={styles.close} aria-label={closeLabel}>
          <X aria-hidden="true" />
        </Primitive.Close>
      </Primitive.Content>
    </Primitive.Portal>
  );
}

export function DialogFooter({ children }: { children: ReactNode }) {
  return <div className={styles.footer}>{children}</div>;
}

components/overlays/doc.ts

/**
 * overlays — content that floats above the page.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Rules for every member
 *
 * R1  Floating content is portalled, so no overflow or stacking context of the
 *     page can clip or bury it, and sits on the shared elevated surface.
 * R2  Escape closes it and focus returns to what opened it.
 * R3  Layering uses the z tokens: menus and popovers `--z-palette`, dialogs
 *     `--z-drawer` over a `--z-scrim` scrim, tooltips `--z-toast`.
 * R4  Entrance motion is a short fade, removed under reduced motion.
 *
 * # Motion
 *
 * Every overlay enters and leaves: the libraries keep it mounted until its
 * exit animation (a CSS keyframe on data-state="closed") ends. Durations
 * are motion tokens, so reduced motion makes both instant.
 */
export {};

components/overlays/overlay.module.css

@layer composition {
  /* Shared by Dialog and AlertDialog, so the scrim and panel cannot drift. */
  .scrim {
    position: fixed;
    z-index: var(--z-scrim);
    inset: 0;
    background: var(--scrim);
    animation: fade var(--dur-2) var(--ease);
  }
  .panel {
    position: fixed;
    z-index: var(--z-drawer);
    top: 50%;
    left: 50%;
    display: grid;
    width: min(calc(100vw - var(--space-8) * 2), 480px);
    max-height: calc(100dvh - var(--space-9) * 2);
    gap: var(--space-6);
    overflow-y: auto;
    padding: var(--space-8);
    transform: translate(-50%, -50%);
    animation: rise var(--dur-2) var(--ease);
  }
  .panel:focus-visible {
    outline: none;
  }
  .head {
    display: grid;
    gap: var(--space-3);
    /* Room for the close button. */
    padding-right: var(--space-8);
  }
  .title {
    margin: 0;
    color: var(--ink);
    font-family: var(--font-display, var(--font-sans));
    font-size: var(--text-18);
    font-weight: var(--heading-weight, var(--weight-bold));
    line-height: var(--leading-tight);
  }
  .description {
    margin: 0;
    color: var(--ink-2);
    font-size: var(--text-body, var(--text-13));
    line-height: var(--leading-body);
  }
  .body {
    color: var(--ink);
    font-size: var(--text-body, var(--text-13));
    line-height: var(--leading-body);
  }
  .footer {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: var(--space-4);
  }
  .close {
    position: absolute;
    top: var(--space-6);
    right: var(--space-6);
    display: grid;
    width: var(--control-sm);
    height: var(--control-sm);
    place-items: center;
    padding: 0;
    border: 0;
    border-radius: var(--radius-1);
    background: none;
    color: var(--ink-3);
    cursor: pointer;
  }
  .close:hover {
    background: var(--surface-hover-2);
    color: var(--ink);
  }
  .close:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }
  .close svg {
    width: 14px;
    height: 14px;
  }
  /* Out the way they came in. The libraries keep the element mounted until
     its exit animation ends — Radix only notices one with a different name
     from the entrance, so each exit has its own keyframes. `forwards` holds
     the last frame. */
  .scrim[data-state="closed"] {
    animation: fade-out var(--dur-2) var(--ease) forwards;
  }
  .panel[data-state="closed"] {
    animation: fall var(--dur-2) var(--ease) forwards;
  }
  @keyframes fade {
    from {
      opacity: 0;
    }
  }
  @keyframes fade-out {
    to {
      opacity: 0;
    }
  }
  @keyframes fall {
    to {
      opacity: 0;
      transform: translate(-50%, calc(-50% + 8px));
    }
  }
  @keyframes rise {
    from {
      opacity: 0;
      transform: translate(-50%, calc(-50% + 8px));
    }
  }
  @media (prefers-reduced-motion: reduce) {
    .scrim,
    .panel {
      animation: none;
    }
  }
}

Drawer

right · left · bottom · pinned footer

A dialog from an edge. It traps focus, closes on Escape, and returns focus like a Dialog; it slides in from its edge and back out to it. The body scrolls; the header and footer stay.

Sourcecomponents/overlays/drawer/doc.ts · components/overlays/drawer/drawer.tsx · components/overlays/drawer/drawer.module.css

components/overlays/drawer/doc.ts

/**
 * Drawer — a dialog that slides in from an edge.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     Drawer, DrawerTrigger, DrawerClose     as Dialog
 *     DrawerContent  title REQUIRED, description?, hideTitle?,
 *                    side? "right" (default) | "left" | "bottom",
 *                    size? "sm" | "md" (default) | "lg",
 *                    footer?, closeLabel?, children
 *
 * # Behaviour
 *
 * R1  Everything a Dialog promises: modal, named, focus trapped, Escape and
 *     the scrim close it, focus returns to what opened it.
 * R2  The header and footer stay; the body scrolls, without scrolling the
 *     page behind.
 * R3  It enters from its edge and leaves back to it (instantly under reduced
 *     motion). A side drawer never covers the whole viewport width.
 */
export {};

components/overlays/drawer/drawer.tsx

"use client";

import * as Primitive from "@radix-ui/react-dialog";
import type { ComponentPropsWithRef, ReactNode } from "react";

import { X } from "@/components/utility/icon";
import { visuallyHidden } from "@/components/utility/visually-hidden";
import { cn } from "@/lib/utils/cn";
import surface from "../../surface.module.css";
import overlay from "../overlay.module.css";
import styles from "./drawer.module.css";

export type DrawerProps = ComponentPropsWithRef<typeof Primitive.Root>;

/** A dialog that slides from an edge: details, filters, a form beside the
 *  page, mobile navigation. Everything a Dialog promises, it keeps. */
export const Drawer = (props: DrawerProps) => <Primitive.Root {...props} />;
export const DrawerTrigger = Primitive.Trigger;
export const DrawerClose = Primitive.Close;

export type DrawerContentProps = Omit<
  ComponentPropsWithRef<typeof Primitive.Content>,
  "title"
> & {
  /** Required: a drawer is a dialog, and a dialog needs a name. */
  title: string;
  description?: string;
  hideTitle?: boolean;
  /** The edge it comes from. */
  side?: "right" | "left" | "bottom";
  /** Width, for side drawers. */
  size?: "sm" | "md" | "lg";
  /** Pinned under the scrolling body: the drawer's actions. */
  footer?: ReactNode;
  closeLabel?: string;
  children: ReactNode;
};

export function DrawerContent({
  title,
  description,
  hideTitle = false,
  side = "right",
  size = "md",
  footer,
  closeLabel = "Close",
  className,
  children,
  ...props
}: DrawerContentProps) {
  return (
    <Primitive.Portal>
      <Primitive.Overlay className={overlay.scrim} />
      <Primitive.Content
        {...props}
        {...(description ? {} : { "aria-describedby": undefined })}
        data-side={side}
        className={cn(surface.elevated, styles.panel, styles[size], className)}
      >
        <div className={styles.head}>
          <Primitive.Title
            className={hideTitle ? visuallyHidden.root : overlay.title}
          >
            {title}
          </Primitive.Title>
          {description ? (
            <Primitive.Description className={overlay.description}>
              {description}
            </Primitive.Description>
          ) : null}
        </div>
        <div className={styles.body}>{children}</div>
        {footer ? <div className={styles.footer}>{footer}</div> : null}
        <Primitive.Close className={overlay.close} aria-label={closeLabel}>
          <X aria-hidden="true" />
        </Primitive.Close>
      </Primitive.Content>
    </Primitive.Portal>
  );
}

components/overlays/drawer/drawer.module.css

@layer composition {
  /* A panel from an edge. Header and footer stay; the body scrolls. */
  .panel {
    position: fixed;
    z-index: var(--z-drawer);
    display: grid;
    grid-template-rows: auto minmax(0, 1fr) auto;
    overflow: hidden;
  }
  .panel:focus-visible {
    outline: none;
  }
  .panel[data-side="right"],
  .panel[data-side="left"] {
    top: 0;
    bottom: 0;
    width: min(var(--drawer-w, 28rem), calc(100vw - var(--space-8)));
  }
  .panel[data-side="right"] {
    right: 0;
    border-radius: var(--radius-3) 0 0 var(--radius-3);
  }
  .panel[data-side="left"] {
    left: 0;
    border-radius: 0 var(--radius-3) var(--radius-3) 0;
  }
  .panel[data-side="bottom"] {
    right: 0;
    bottom: 0;
    left: 0;
    max-height: 85dvh;
    border-radius: var(--radius-3) var(--radius-3) 0 0;
  }
  .sm {
    --drawer-w: 22rem;
  }
  .md {
    --drawer-w: 28rem;
  }
  .lg {
    --drawer-w: 40rem;
  }
  .head {
    display: grid;
    gap: var(--space-3);
    padding: var(--space-7) calc(var(--space-7) + var(--control-sm))
      var(--space-5) var(--space-7);
    border-bottom: 1px solid var(--line);
  }
  .body {
    min-height: 0;
    overflow-y: auto;
    padding: var(--space-7);
    color: var(--ink);
    font-size: var(--text-body, var(--text-13));
    line-height: var(--leading-body);
    overscroll-behavior: contain;
  }
  .footer {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: var(--space-4);
    padding: var(--space-5) var(--space-7);
    border-top: 1px solid var(--line);
  }
  /* In from its own edge, and back out to it. */
  .panel[data-side="right"][data-state="open"] {
    animation: in-right var(--dur-3) var(--ease);
  }
  .panel[data-side="right"][data-state="closed"] {
    animation: out-right var(--dur-2) var(--ease) forwards;
  }
  .panel[data-side="left"][data-state="open"] {
    animation: in-left var(--dur-3) var(--ease);
  }
  .panel[data-side="left"][data-state="closed"] {
    animation: out-left var(--dur-2) var(--ease) forwards;
  }
  .panel[data-side="bottom"][data-state="open"] {
    animation: in-bottom var(--dur-3) var(--ease);
  }
  .panel[data-side="bottom"][data-state="closed"] {
    animation: out-bottom var(--dur-2) var(--ease) forwards;
  }
  @keyframes out-right {
    to {
      transform: translateX(100%);
    }
  }
  @keyframes in-right {
    from {
      transform: translateX(100%);
    }
  }
  @keyframes out-left {
    to {
      transform: translateX(-100%);
    }
  }
  @keyframes in-left {
    from {
      transform: translateX(-100%);
    }
  }
  @keyframes out-bottom {
    to {
      transform: translateY(100%);
    }
  }
  @keyframes in-bottom {
    from {
      transform: translateY(100%);
    }
  }
}

AlertDialog

must be answered

destructive

No close button, no outside click. The ways out are the two buttons and Escape, and focus starts on Cancel so a stray Enter never confirms.

Sourcecomponents/overlays/alert-dialog/doc.ts · components/overlays/alert-dialog/alert-dialog.tsx

components/overlays/alert-dialog/doc.ts

/**
 * AlertDialog — a question that must be answered.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     AlertDialog          open?, defaultOpen?, onOpenChange?
 *     AlertDialogTrigger   usually a Button (asChild)
 *     AlertDialogContent   title and description, BOTH REQUIRED; children are
 *                          the footer
 *     AlertDialogCancel    backs out
 *     AlertDialogAction    does the thing asked about
 *
 * # Behaviour
 *
 * R1  As Dialog R1, but announced as an alert dialog.
 * R2  No close button, and a click outside does nothing: the only ways out
 *     are Cancel, the action, and Escape (which cancels).
 * R3  Focus starts on Cancel, so a stray Enter never confirms.
 * R4  Use it before destructive or irreversible actions. The action button
 *     names the action ("Delete workspace"), never "OK".
 * R5  Activating the action runs its handler and closes the dialog. A handler
 *     that must wait (a request that can fail) calls preventDefault() to keep
 *     it open, then closes it through the controlled `open` state.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * R5: Radix's Action closes the dialog itself. bits-ui's does not, so the
 * Svelte AlertDialog shares a close function through context and the Svelte
 * AlertDialogAction calls it after the handler, unless it was prevented.
 */
export {};

components/overlays/alert-dialog/alert-dialog.tsx

"use client";

import * as Primitive from "@radix-ui/react-alert-dialog";
import type { ComponentPropsWithRef, ReactNode } from "react";

import { cn } from "@/lib/utils/cn";
import surface from "../../surface.module.css";
import styles from "../overlay.module.css";

export type AlertDialogProps = ComponentPropsWithRef<typeof Primitive.Root>;

export const AlertDialog = (props: AlertDialogProps) => (
  <Primitive.Root {...props} />
);
export const AlertDialogTrigger = Primitive.Trigger;
/** The action the question is about. Separate from Cancel so the two cannot
 *  be given the same weight by accident. */
export const AlertDialogAction = Primitive.Action;
export const AlertDialogCancel = Primitive.Cancel;

export type AlertDialogContentProps = Omit<
  ComponentPropsWithRef<typeof Primitive.Content>,
  "title"
> & {
  title: string;
  /** Required: a question you cannot dismiss by looking away must say what it
   *  is asking. */
  description: string;
  /** The footer: an AlertDialogCancel and an AlertDialogAction. */
  children: ReactNode;
};

/** A dialog that requires an answer: no close button and no outside-click
 *  dismissal. The ways out are its two buttons and Escape, which cancels. */
export function AlertDialogContent({
  title,
  description,
  className,
  children,
  ...props
}: AlertDialogContentProps) {
  return (
    <Primitive.Portal>
      <Primitive.Overlay className={styles.scrim} />
      <Primitive.Content
        {...props}
        className={cn(surface.elevated, styles.panel, className)}
      >
        <div className={styles.head}>
          <Primitive.Title className={styles.title}>{title}</Primitive.Title>
          <Primitive.Description className={styles.description}>
            {description}
          </Primitive.Description>
        </div>
        <div className={styles.footer}>{children}</div>
      </Primitive.Content>
    </Primitive.Portal>
  );
}

Popover

interactive, anchored

settings
Sourcecomponents/overlays/popover/doc.ts · components/overlays/popover/popover.tsx · components/overlays/popover/popover.module.css

components/overlays/popover/doc.ts

/**
 * Popover — interactive content anchored to a trigger.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     Popover          open?, onOpenChange?
 *     PopoverTrigger   usually a Button (asChild)
 *     PopoverContent   side?, align?, sideOffset?
 *     PopoverClose
 *
 * # Behaviour
 *
 * R1  Opens on the trigger and takes focus; Escape or a click outside closes
 *     it and returns focus to the trigger.
 * R2  It may contain controls (a small form, filters), unlike a tooltip.
 * R3  At most 20rem wide, never wider than the viewport allows.
 *
 * # Popover or Tooltip?
 *
 * Anything a user acts on, or must be able to reach by keyboard and touch,
 * is a popover. A tooltip only restates something that already has a name.
 */
export {};

components/overlays/popover/popover.tsx

"use client";

import * as Primitive from "@radix-ui/react-popover";
import type { ComponentPropsWithRef } from "react";

import { cn } from "@/lib/utils/cn";
import surface from "../../surface.module.css";
import styles from "./popover.module.css";

export type PopoverProps = ComponentPropsWithRef<typeof Primitive.Root>;
export type PopoverContentProps = ComponentPropsWithRef<
  typeof Primitive.Content
>;

export const Popover = (props: PopoverProps) => <Primitive.Root {...props} />;
export const PopoverTrigger = Primitive.Trigger;
export const PopoverClose = Primitive.Close;

/** Focusable, dismissible content anchored to a trigger. It may hold
 *  controls. A hint about something that already has a name is a Tooltip. */
export function PopoverContent({
  className,
  sideOffset = 6,
  ...props
}: PopoverContentProps) {
  return (
    <Primitive.Portal>
      <Primitive.Content
        {...props}
        sideOffset={sideOffset}
        className={cn(surface.elevated, styles.content, className)}
      />
    </Primitive.Portal>
  );
}

components/overlays/popover/popover.module.css

@layer primitive {
  .content {
    z-index: var(--z-palette);
    width: min(calc(100vw - var(--space-7) * 2), 20rem);
    padding: var(--space-6);
    color: var(--ink);
    font-size: var(--text-body, var(--text-13));
    line-height: var(--leading-body);
  }
  .content:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }
  /* Grow from the trigger's side, and shrink back into it. */
  .content {
    transform-origin: var(
      --radix-dropdown-menu-content-transform-origin,
      var(
        --radix-popover-content-transform-origin,
        var(
          --bits-dropdown-menu-content-transform-origin,
          var(--bits-popover-content-transform-origin, top)
        )
      )
    );
  }
  .content[data-state="open"] {
    animation: pop var(--dur-2) var(--ease);
  }
  .content[data-state="closed"] {
    animation: unpop var(--dur-1) var(--ease) forwards;
  }
  @keyframes unpop {
    to {
      opacity: 0;
      transform: scale(0.96);
    }
  }
  @keyframes pop {
    from {
      opacity: 0;
      transform: scale(0.96);
    }
  }
}

Tooltip

supplementary hint

hover or focus

Never the only name. Each trigger here is named without its tooltip; the tooltip only adds a description. Touch users may never see it.

Sourcecomponents/overlays/tooltip/doc.ts · components/overlays/tooltip/tooltip.tsx · components/overlays/tooltip/tooltip.module.css

components/overlays/tooltip/doc.ts

/**
 * Tooltip — a short hint on hover or focus.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     TooltipProvider   once, at the app root
 *     Tooltip           content, REQUIRED; side?; children is the trigger
 *
 * # Behaviour
 *
 * R1  Shows after a short delay on hover and immediately on keyboard focus;
 *     hides on leave, blur, or Escape.
 * R2  The trigger is described by the tooltip, so readers hear it after the
 *     trigger's own name.
 * R3  Supplementary only: the trigger must already have an accessible name.
 *     Touch users may never see a tooltip, so nothing essential goes in one.
 * R4  Inverted colours (ink on panel), at most 16rem wide, with an arrow.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The provider shares timing across tooltips, so moving from one trigger to
 * the next shows the second without the full delay.
 */
export {};

components/overlays/tooltip/tooltip.tsx

"use client";

import * as Primitive from "@radix-ui/react-tooltip";
import type { ComponentPropsWithRef, ReactNode } from "react";

import styles from "./tooltip.module.css";

/** Mounted once at the root: it shares open/close timing across tooltips, so
 *  moving from one to the next shows the second at once. */
export const TooltipProvider = (
  props: ComponentPropsWithRef<typeof Primitive.Provider>,
) => <Primitive.Provider delayDuration={400} {...props} />;

export type TooltipProps = {
  /** The hint. Supplementary only: the trigger must already have a name,
   *  because touch and keyboard-only users may never see this. */
  content: ReactNode;
  side?: "top" | "right" | "bottom" | "left";
  /** One focusable element: the trigger. */
  children: ReactNode;
};

export function Tooltip({ content, side = "top", children }: TooltipProps) {
  return (
    <Primitive.Root>
      <Primitive.Trigger asChild>{children}</Primitive.Trigger>
      <Primitive.Portal>
        <Primitive.Content
          side={side}
          sideOffset={6}
          className={styles.content}
        >
          {content}
          <Primitive.Arrow className={styles.arrow} />
        </Primitive.Content>
      </Primitive.Portal>
    </Primitive.Root>
  );
}

components/overlays/tooltip/tooltip.module.css

@layer primitive {
  .content {
    z-index: var(--z-toast);
    max-width: 16rem;
    padding: var(--space-3) var(--space-5);
    border-radius: var(--radius-2);
    background: var(--ink);
    color: var(--surface-panel);
    font-size: var(--text-12);
    line-height: var(--leading-snug);
    animation: fade var(--dur-2) var(--ease);
  }
  .arrow {
    fill: var(--ink);
  }
  .content[data-state="closed"] {
    animation: fade-out var(--dur-1) var(--ease) forwards;
  }
  @keyframes fade-out {
    to {
      opacity: 0;
    }
  }
  @keyframes fade {
    from {
      opacity: 0;
    }
  }
  @media (prefers-reduced-motion: reduce) {
    .content {
      animation: none;
    }
  }
}

CommandPalette

⌘K · filter · keywords · shortcuts

Press ⌘K (Ctrl+K) anywhere on this page. Type to filter — keywords match too, so “invoice” finds Billing — arrows to move, Enter to run. Running an item closes the palette.

Sourcecomponents/overlays/command/doc.ts · components/overlays/command/command.tsx · components/overlays/command/command.module.css

components/overlays/command/doc.ts

/**
 * CommandPalette — find an action or a place, and run it.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     CommandPalette   open, onOpenChange, label?, placeholder?, empty?
 *     CommandGroup     heading
 *     CommandItem      onSelect, icon?, shortcut?, keywords?, keepOpen?,
 *                      disabled?, children (its text)
 *     CommandSeparator
 *     useCommandShortcut(toggle)   ⌘K / Ctrl+K
 *
 * # Behaviour
 *
 * R1  A modal dialog holding a combobox and its list: typing filters by
 *     text and keywords, arrows move (and wrap), Enter runs, Escape closes.
 * R2  Running an item closes the palette unless it asks to stay open.
 * R3  No match shows the empty message; it is never a blank box.
 * R4  It enters and leaves like every overlay.
 */
export {};

components/overlays/command/command.tsx

"use client";

import * as Dialog from "@radix-ui/react-dialog";
import { Command } from "cmdk";
import { createContext, useContext, useEffect, type ReactNode } from "react";

import { Kbd } from "@/components/typography/kbd";
import { Search, type LucideIcon } from "@/components/utility/icon";
import { visuallyHidden } from "@/components/utility/visually-hidden";
import { cn } from "@/lib/utils/cn";
import surface from "../../surface.module.css";
import overlay from "../overlay.module.css";
import styles from "./command.module.css";

const Close = createContext<() => void>(() => {});

export type CommandPaletteProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  /** Names the palette (the dialog, and the list of results). */
  label?: string;
  placeholder?: string;
  /** Shown when nothing matches. */
  empty?: ReactNode;
  children: ReactNode;
};

/** Search for an action or a place and run it, from the keyboard. Type to
 *  filter, arrows to move, Enter to run; running an item closes the
 *  palette. Pair with useCommandShortcut for ⌘K. */
export function CommandPalette({
  open,
  onOpenChange,
  label = "Command palette",
  placeholder = "Type a command or search…",
  empty = "No results.",
  children,
}: CommandPaletteProps) {
  return (
    <Dialog.Root open={open} onOpenChange={onOpenChange}>
      <Dialog.Portal>
        <Dialog.Overlay className={overlay.scrim} />
        <Dialog.Content
          aria-describedby={undefined}
          className={cn(surface.elevated, styles.panel)}
        >
          <Dialog.Title className={visuallyHidden.root}>{label}</Dialog.Title>
          <Close.Provider value={() => onOpenChange(false)}>
            <Command label={label} loop>
              <div className={styles.search}>
                <Search aria-hidden="true" />
                <Command.Input
                  className={styles.input}
                  placeholder={placeholder}
                />
              </div>
              <Command.List className={styles.list}>
                <Command.Empty className={styles.empty}>{empty}</Command.Empty>
                {children}
              </Command.List>
            </Command>
          </Close.Provider>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

export function CommandGroup({
  heading,
  children,
}: {
  heading: string;
  children: ReactNode;
}) {
  return (
    <Command.Group heading={heading} className={styles.group}>
      {children}
    </Command.Group>
  );
}

export const CommandSeparator = () => (
  <Command.Separator className={styles.separator} />
);

export type CommandItemProps = {
  onSelect: () => void;
  icon?: LucideIcon;
  /** Printed at the end: the item's own shortcut. */
  shortcut?: readonly string[];
  /** More words it should match: "invite" for "Add member". */
  keywords?: readonly string[];
  /** Keep the palette open after running it. */
  keepOpen?: boolean;
  disabled?: boolean;
  children: string;
};

export function CommandItem({
  onSelect,
  icon: Icon,
  shortcut,
  keywords,
  keepOpen = false,
  disabled,
  children,
}: CommandItemProps) {
  const close = useContext(Close);
  return (
    <Command.Item
      className={styles.item}
      keywords={keywords ? [...keywords] : undefined}
      disabled={disabled}
      onSelect={() => {
        onSelect();
        if (!keepOpen) close();
      }}
    >
      {Icon ? <Icon aria-hidden="true" /> : null}
      <span className={styles.text}>{children}</span>
      {shortcut ? <Kbd keys={shortcut} /> : null}
    </Command.Item>
  );
}

/** ⌘K on a Mac, Ctrl+K elsewhere, toggles the palette. */
export function useCommandShortcut(toggle: () => void) {
  useEffect(() => {
    const onKey = (event: KeyboardEvent) => {
      if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) {
        event.preventDefault();
        toggle();
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [toggle]);
}

components/overlays/command/command.module.css

@layer composition {
  /* Sits in the upper third, where the eye already is, not dead centre. */
  .panel {
    position: fixed;
    z-index: var(--z-drawer);
    top: 14vh;
    left: 50%;
    display: grid;
    width: min(calc(100vw - var(--space-7) * 2), 36rem);
    max-height: 70vh;
    grid-template-rows: auto minmax(0, 1fr);
    overflow: hidden;
    transform: translateX(-50%);
  }
  .panel[data-state="open"] {
    animation: drop var(--dur-2) var(--ease);
  }
  .panel[data-state="closed"] {
    animation: lift var(--dur-1) var(--ease) forwards;
  }
  .panel:focus-visible {
    outline: none;
  }
  .search {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    padding: 0 var(--space-6);
    border-bottom: 1px solid var(--line);
  }
  .search svg {
    width: 16px;
    height: 16px;
    flex: none;
    color: var(--ink-3);
  }
  .input {
    height: var(--control-lg);
    flex: 1;
    min-width: 0;
    border: 0;
    background: transparent;
    color: var(--ink);
    font: inherit;
    font-size: var(--text-15);
  }
  .input:focus {
    outline: none;
  }
  .input::placeholder {
    color: var(--ink-3);
  }
  .list {
    overflow-y: auto;
    padding: var(--space-3);
    overscroll-behavior: contain;
  }
  .empty {
    padding: var(--space-8) var(--space-6);
    color: var(--ink-3);
    font-size: var(--text-13);
    text-align: center;
  }
  /* cmdk and bits-ui mark headings and groups differently; both land here. */
  .group [cmdk-group-heading],
  .heading {
    padding: var(--space-4) var(--space-4) var(--space-2);
    color: var(--ink-3);
    font-size: var(--text-11);
    font-weight: var(--weight-strong);
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
  }
  .item {
    display: flex;
    min-height: var(--control-md);
    align-items: center;
    gap: var(--space-4);
    padding: 0 var(--space-4);
    border-radius: var(--radius-2);
    color: var(--ink-2);
    cursor: pointer;
    font-size: var(--text-13);
  }
  .item svg {
    width: 16px;
    height: 16px;
    flex: none;
    color: var(--ink-3);
  }
  /* cmdk writes data-selected="true|false"; bits-ui adds data-selected. */
  .item[data-selected]:not([data-selected="false"]) {
    background: var(--surface-hover-2);
    color: var(--ink);
  }
  .item[data-selected]:not([data-selected="false"]) svg {
    color: var(--accent);
  }
  .item[data-disabled]:not([data-disabled="false"]) {
    cursor: not-allowed;
    opacity: 0.45;
  }
  .text {
    min-width: 0;
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .separator {
    height: 1px;
    margin: var(--space-3) var(--space-2);
    background: var(--line);
  }
  @keyframes drop {
    from {
      opacity: 0;
      transform: translate(-50%, -8px) scale(0.98);
    }
  }
  @keyframes lift {
    to {
      opacity: 0;
      transform: translate(-50%, -8px) scale(0.98);
    }
  }
}