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.
Source src/lib/components/overlays/doc.ts · src/lib/components/overlays/dialog/doc.ts · src/lib/components/overlays/dialog/DialogContent.svelte · src/lib/components/overlays/overlay.module.css
src/lib/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 {};src/lib/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 {};src/lib/components/overlays/dialog/DialogContent.svelte
<script lang="ts">
import { Dialog as Primitive, type DialogContentProps as ContentProps } from 'bits-ui';
import { X } from '$lib/components/utility/icon';
import { visuallyHidden } from '$lib/components/utility/visually-hidden';
import { cn } from '$lib/utils/cn';
import surface from '../../surface.module.css';
import styles from '../overlay.module.css';
export type DialogContentProps = Omit<ContentProps, 'class' | '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;
class?: string;
};
let {
title,
description,
hideTitle = false,
closeLabel = 'Close',
class: className = '',
children,
...rest
}: DialogContentProps = $props();
</script>
<Primitive.Portal>
<Primitive.Overlay class={styles.scrim} />
<Primitive.Content {...rest} class={cn(surface.elevated, styles.panel, className)}>
<div class={styles.head}>
<Primitive.Title class={hideTitle ? visuallyHidden.root : styles.title}
>{title}</Primitive.Title
>
{#if description}
<Primitive.Description class={styles.description}>{description}</Primitive.Description>
{/if}
</div>
<div class={styles.body}>{@render children?.()}</div>
<Primitive.Close class={styles.close} aria-label={closeLabel}>
<X aria-hidden="true" />
</Primitive.Close>
</Primitive.Content>
</Primitive.Portal>src/lib/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.
Source src/lib/components/overlays/drawer/doc.ts · src/lib/components/overlays/drawer/DrawerContent.svelte · src/lib/components/overlays/drawer/drawer.module.css
src/lib/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 {};src/lib/components/overlays/drawer/DrawerContent.svelte
<script lang="ts">
import { Dialog as Primitive, type DialogContentProps as ContentProps } from 'bits-ui';
import type { Snippet } from 'svelte';
import { X } from '$lib/components/utility/icon';
import { visuallyHidden } from '$lib/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';
/* A dialog that slides from an edge: details, filters, a form beside the
page, mobile navigation. Everything a Dialog promises, it keeps. */
let {
title,
description,
hideTitle = false,
side = 'right',
size = 'md',
footer,
closeLabel = 'Close',
class: className = '',
children,
...rest
}: Omit<ContentProps, 'class' | '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?: Snippet;
closeLabel?: string;
class?: string;
} = $props();
</script>
<Primitive.Portal>
<Primitive.Overlay class={overlay.scrim} />
<Primitive.Content
{...rest}
data-side={side}
class={cn(surface.elevated, styles.panel, styles[size], className)}
>
<div class={styles.head}>
<Primitive.Title class={hideTitle ? visuallyHidden.root : overlay.title}
>{title}</Primitive.Title
>
{#if description}
<Primitive.Description class={overlay.description}>{description}</Primitive.Description>
{/if}
</div>
<div class={styles.body}>{@render children?.()}</div>
{#if footer}<div class={styles.footer}>{@render footer()}</div>{/if}
<Primitive.Close class={overlay.close} aria-label={closeLabel}>
<X aria-hidden="true" />
</Primitive.Close>
</Primitive.Content>
</Primitive.Portal>src/lib/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.
Source src/lib/components/overlays/alert-dialog/doc.ts · src/lib/components/overlays/alert-dialog/AlertDialogContent.svelte · src/lib/components/overlays/alert-dialog/AlertDialogCancel.svelte
src/lib/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 {};src/lib/components/overlays/alert-dialog/AlertDialogContent.svelte
<script lang="ts">
import { AlertDialog as Primitive, type AlertDialogContentProps as ContentProps } from 'bits-ui';
import { cn } from '$lib/utils/cn';
import surface from '../../surface.module.css';
import styles from '../overlay.module.css';
export type AlertDialogContentProps = Omit<ContentProps, 'class' | 'title'> & {
title: string;
/** Required: a question you cannot dismiss by looking away must say what
* it is asking. */
description: string;
class?: string;
};
let {
title,
description,
class: className = '',
children,
...rest
}: AlertDialogContentProps = $props();
let content: HTMLElement | null = $state(null);
/* bits-ui focuses the dialog itself on open; the contract (doc.ts R3) puts
focus on Cancel, so a stray Enter never confirms. */
function focusCancel(event: Event) {
event.preventDefault();
setTimeout(() => content?.querySelector<HTMLElement>('[data-cancel]')?.focus());
}
</script>
<!-- A dialog that requires an answer: no close button, and bits-ui ignores a
click outside. The ways out are its two buttons and Escape. -->
<Primitive.Portal>
<Primitive.Overlay class={styles.scrim} />
<Primitive.Content
{...rest}
bind:ref={content}
onOpenAutoFocus={focusCancel}
class={cn(surface.elevated, styles.panel, className)}
>
<div class={styles.head}>
<Primitive.Title class={styles.title}>{title}</Primitive.Title>
<Primitive.Description class={styles.description}>{description}</Primitive.Description>
</div>
<div class={styles.footer}>{@render children?.()}</div>
</Primitive.Content>
</Primitive.Portal>src/lib/components/overlays/alert-dialog/AlertDialogCancel.svelte
<script lang="ts">
import { AlertDialog as Primitive, type AlertDialogCancelProps } from 'bits-ui';
/* Marked, so the content can find it and put initial focus here. */
let props: AlertDialogCancelProps = $props();
</script>
<Primitive.Cancel {...props} data-cancel="" />Popover
interactive, anchored
Source src/lib/components/overlays/popover/doc.ts · src/lib/components/overlays/popover/PopoverContent.svelte · src/lib/components/overlays/popover/popover.module.css
src/lib/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 {};src/lib/components/overlays/popover/PopoverContent.svelte
<script lang="ts">
import { Popover as Primitive, type PopoverContentProps } from 'bits-ui';
import { cn } from '$lib/utils/cn';
import surface from '../../surface.module.css';
import styles from './popover.module.css';
/* Focusable, dismissible content anchored to a trigger. It may hold
controls. A hint about something that already has a name is a Tooltip. */
let {
class: className = '',
sideOffset = 6,
...rest
}: Omit<PopoverContentProps, 'class'> & { class?: string } = $props();
</script>
<Primitive.Portal>
<Primitive.Content
{...rest}
{sideOffset}
class={cn(surface.elevated, styles.content, className)}
/>
</Primitive.Portal>src/lib/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.
Source src/lib/components/overlays/tooltip/doc.ts · src/lib/components/overlays/tooltip/Tooltip.svelte · src/lib/components/overlays/tooltip/tooltip.module.css
src/lib/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 {};src/lib/components/overlays/tooltip/Tooltip.svelte
<script lang="ts">
import { Tooltip as Primitive } from 'bits-ui';
import type { Snippet } from 'svelte';
import styles from './tooltip.module.css';
let {
content,
side = 'top',
trigger
}: {
/** The hint. Supplementary only: the trigger must already have a name,
* because touch and keyboard-only users may never see this. */
content: string;
side?: 'top' | 'right' | 'bottom' | 'left';
/** The one focusable trigger; spread `props` onto it. */
trigger: Snippet<[Record<string, unknown>]>;
} = $props();
</script>
<Primitive.Root>
<Primitive.Trigger>
{#snippet child({ props })}{@render trigger(props)}{/snippet}
</Primitive.Trigger>
<Primitive.Portal>
<!-- role="tooltip": Radix sets it and bits-ui does not; the trigger's
aria-describedby already points here in both. -->
<Primitive.Content {side} sideOffset={6} role="tooltip" class={styles.content}>
{content}
<Primitive.Arrow class={styles.arrow} />
</Primitive.Content>
</Primitive.Portal>
</Primitive.Root>src/lib/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.
Source src/lib/components/overlays/command/doc.ts · src/lib/components/overlays/command/CommandPalette.svelte · src/lib/components/overlays/command/CommandItem.svelte · src/lib/components/overlays/command/context.ts · src/lib/components/overlays/command/command.module.css
src/lib/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?, and its text
* (children in React, label in Svelte)
* CommandSeparator
* commandShortcut(toggle) ⌘K / Ctrl+K; returns its cleanup
*
* # 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 {};src/lib/components/overlays/command/CommandPalette.svelte
<script lang="ts">
import { Command, Dialog } from 'bits-ui';
import type { Snippet } from 'svelte';
import { Search } from '$lib/components/utility/icon';
import { visuallyHidden } from '$lib/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';
import { setClose } from './context';
/* 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 commandShortcut for ⌘K. */
let {
open = $bindable(false),
onOpenChange,
label = 'Command palette',
placeholder = 'Type a command or search…',
empty = 'No results.',
children
}: {
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?: string | Snippet;
children: Snippet;
} = $props();
setClose(() => {
open = false;
onOpenChange?.(false);
});
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay class={overlay.scrim} />
<Dialog.Content aria-describedby={undefined} class={cn(surface.elevated, styles.panel)}>
<Dialog.Title class={visuallyHidden.root}>{label}</Dialog.Title>
<Command.Root {label} loop>
<div class={styles.search}>
<Search aria-hidden="true" />
<Command.Input class={styles.input} {placeholder} />
</div>
<Command.List class={styles.list}>
<Command.Empty class={styles.empty}>
{#if typeof empty === 'string'}{empty}{:else}{@render empty()}{/if}
</Command.Empty>
{@render children()}
</Command.List>
</Command.Root>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>src/lib/components/overlays/command/CommandItem.svelte
<script lang="ts">
import { Command } from 'bits-ui';
import { Kbd } from '$lib/components/typography/kbd';
import type { LayoutGrid } from '$lib/components/utility/icon';
import styles from './command.module.css';
import { getClose } from './context';
let {
onSelect,
icon: Icon,
shortcut,
keywords,
keepOpen = false,
disabled = false,
label
}: {
onSelect: () => void;
icon?: typeof LayoutGrid;
/** 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;
/** Its text, which is also what the search matches. */
label: string;
} = $props();
const close = getClose();
</script>
<Command.Item
class={styles.item}
value={label}
keywords={keywords ? [...keywords] : undefined}
{disabled}
onSelect={() => {
onSelect();
if (!keepOpen) close();
}}
>
{#if Icon}<Icon aria-hidden="true" />{/if}
<span class={styles.text}>{label}</span>
{#if shortcut}<Kbd keys={shortcut} />{/if}
</Command.Item>src/lib/components/overlays/command/context.ts
import { getContext, setContext } from 'svelte';
const KEY = Symbol('command-close');
/** The palette hands its items a way to close it after they run. */
export const setClose = (close: () => void) => setContext(KEY, close);
export const getClose = () => getContext<(() => void) | undefined>(KEY) ?? (() => {});
/** ⌘K on a Mac, Ctrl+K elsewhere, toggles the palette. Returns the cleanup,
* so it can be the whole body of an effect: $effect(() => commandShortcut(t)). */
export function commandShortcut(toggle: () => void) {
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);
}src/lib/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);
}
}
}