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
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
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
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
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);
}
}
}