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.

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

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.

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

settings
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

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.

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