Skip to examples
Bento / Kitchen sink
Bento / compositions

Page patterns

Regions every screen has, and the screens they add up to. Patterns own layout and slots; content, state, and permissions stay with the screen.

CollectionToolbar

filters · live summary · actions

24 of 24 projects

The summary is announced. It is a status region, so “12 of 24 projects” is read out when a filter changes it.

Source src/lib/components/patterns/collection-toolbar/doc.ts · src/lib/components/patterns/collection-toolbar/CollectionToolbar.svelte · src/lib/components/patterns/collection-toolbar/collection-toolbar.module.css

src/lib/components/patterns/collection-toolbar/doc.ts

/**
 * CollectionToolbar — the controls above a list, grid, or table.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     children   the filters and search, each separately labelled
 *     summary?   "12 of 48 projects"
 *     actions?   create, export, view switches
 *
 * # Behaviour
 *
 * R1  Filters on the left, summary and actions on the right; each group wraps
 *     on its own as space shrinks.
 * R2  The summary is a status region, so a change in the count after
 *     filtering is announced.
 * R3  It is a layout, not an ARIA toolbar: every control keeps its own tab
 *     stop and label, because filters are unrelated controls, not one widget.
 */
export {};

src/lib/components/patterns/collection-toolbar/CollectionToolbar.svelte

<script lang="ts">
	import type { Snippet } from 'svelte';
	import { cn } from '$lib/utils/cn';
	import styles from './collection-toolbar.module.css';

	/* Filters and search on the left, a summary and actions on the right. A
	   layout for separately labelled controls, not an ARIA toolbar. */
	let {
		summary,
		actions,
		class: className = '',
		children
	}: {
		/** "12 of 48 projects": updated as filters change. */
		summary?: string;
		/** At the end: create, export, view switches. */
		actions?: Snippet;
		class?: string;
		children?: Snippet;
	} = $props();
</script>

<div class={cn(styles.root, className)}>
	<div class={styles.controls}>{@render children?.()}</div>
	{#if summary || actions}
		<div class={styles.trailing}>
			{#if summary}<span class={styles.summary} role="status">{summary}</span>{/if}
			{@render actions?.()}
		</div>
	{/if}
</div>

src/lib/components/patterns/collection-toolbar/collection-toolbar.module.css

@layer composition {
	.root {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		justify-content: space-between;
		gap: var(--space-5) var(--space-6);
	}
	.controls {
		display: flex;
		min-width: 0;
		flex: 1 1 18rem;
		flex-wrap: wrap;
		align-items: center;
		gap: var(--space-3);
	}
	.trailing {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		gap: var(--space-4);
	}
	.summary {
		color: var(--ink-3);
		font-size: var(--text-12);
		font-variant-numeric: tabular-nums;
	}
}

SelectionCard

a real radio or checkbox, the size of a card

For trying Bento with a small team.

$0 / month

Up to 50 seats and 100 GB.

$249 / month

Talk to sales.

Custom

Chosen plan: team

Link commits and pull requests.

Post updates to a channel.

Sync issues both ways.

The whole card is the target. It is still a radio in a RadioGroup, so arrow keys move between plans and the choice submits with a form.

Source src/lib/components/patterns/selection-card/doc.ts · src/lib/components/patterns/selection-card/SelectionCard.svelte · src/lib/components/patterns/selection-card/selection-card.module.css

src/lib/components/patterns/selection-card/doc.ts

/**
 * SelectionCard — a choice presented as a card.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label, value            REQUIRED
 *     description?, children? (price, features), disabled?
 *     mode "single"           a radio; place the cards in a RadioGroup
 *     mode "multiple"         a checkbox; name?, checked?, defaultChecked?,
 *                             onCheckedChange?
 *
 * # Behaviour
 *
 * R1  It is a real radio or checkbox, labelled by the card's label and
 *     described by its description, so it submits and is announced like one.
 * R2  A click anywhere on the card chooses it.
 * R3  A chosen card takes the accent border and tint; keyboard focus draws the
 *     ring around the whole card rather than the small control.
 * R4  In a RadioGroup, arrow keys move between cards, as between radios.
 */
export {};

src/lib/components/patterns/selection-card/SelectionCard.svelte

<script lang="ts">
	import type { Snippet } from 'svelte';
	import { Checkbox } from '$lib/components/forms/checkbox';
	import { Radio } from '$lib/components/forms/radio-group';
	import { cn } from '$lib/utils/cn';
	import styles from './selection-card.module.css';

	type Base = {
		label: string;
		value: string;
		description?: string;
		disabled?: boolean;
		/** Extra content below the description: a price, a feature list. */
		children?: Snippet;
		class?: string;
	};
	type Props = Base &
		(
			| { /** One of several: place the cards inside a RadioGroup. */ mode: 'single' }
			| {
					mode: 'multiple';
					name?: string;
					checked?: boolean;
					onCheckedChange?: (checked: boolean) => void;
			  }
		);

	/* A choice presented as a card: the whole card is the target, and it is a
	   real radio or checkbox underneath. */
	let props: Props = $props();
	const id = $props.id();
	const labelId = `${id}-label`;
	const descriptionId = $derived(props.description ? `${id}-description` : undefined);
</script>

<div class={cn(styles.root, props.class)}>
	<label id={labelId} for={id} class={styles.label}>{props.label}</label>
	<span class={styles.control}>
		{#if props.mode === 'single'}
			<Radio
				{id}
				value={props.value}
				disabled={props.disabled}
				aria-labelledby={labelId}
				aria-describedby={descriptionId}
			/>
		{:else}
			<Checkbox
				{id}
				value={props.value}
				name={props.name}
				disabled={props.disabled}
				checked={props.checked}
				onCheckedChange={props.onCheckedChange}
				aria-labelledby={labelId}
				aria-describedby={descriptionId}
			/>
		{/if}
	</span>
	{#if props.description}
		<p id={descriptionId} class={styles.description}>{props.description}</p>
	{/if}
	{#if props.children}<div class={styles.body}>{@render props.children()}</div>{/if}
</div>

src/lib/components/patterns/selection-card/selection-card.module.css

@layer composition {
	.root {
		position: relative;
		display: grid;
		grid-template-columns: minmax(0, 1fr) auto;
		align-content: start;
		gap: var(--space-3) var(--space-5);
		padding: var(--space-7);
		border: 1px solid var(--line-strong);
		border-radius: var(--radius-3);
		background: var(--surface-panel);
		transition:
			border-color var(--dur-2) var(--ease),
			background-color var(--dur-2) var(--ease);
	}
	.control {
		grid-column: 2;
		grid-row: 1;
	}
	.label {
		grid-column: 1;
		grid-row: 1;
		color: var(--ink);
		font-size: var(--text-body, var(--text-13));
		font-weight: var(--weight-strong);
		cursor: pointer;
	}
	/* The label covers the card, so a click anywhere on it chooses it. */
	.label::after {
		position: absolute;
		inset: 0;
		border-radius: inherit;
		content: '';
	}
	.description {
		grid-column: 1 / -1;
		margin: 0;
		color: var(--ink-2);
		font-size: var(--text-12);
		line-height: var(--leading-body);
	}
	.body {
		grid-column: 1 / -1;
		margin-top: var(--space-3);
	}
	.root:where(:hover:not(:has(:disabled))) {
		border-color: var(--accent-line);
	}
	.root:has([data-state='checked']) {
		border-color: var(--accent);
		background: var(--accent-tint);
	}
	/* The ring moves from the small control to the whole card. */
	.root:has(:focus-visible) {
		outline: 2px solid var(--accent);
		outline-offset: 2px;
	}
	.root:has(:focus-visible) :focus-visible {
		outline: none;
	}
	.root:has(:disabled) {
		opacity: 0.45;
	}
	.root:has(:disabled) .label {
		cursor: not-allowed;
	}
}

SettingsSection

label beside controls · stacks when narrow

Workspace name

Shown to everyone in the workspace and in invitations.

Source src/lib/components/patterns/settings-section/doc.ts · src/lib/components/patterns/settings-section/SettingsSection.svelte · src/lib/components/patterns/settings-section/settings-section.module.css

src/lib/components/patterns/settings-section/doc.ts

/**
 * SettingsSection — one group of settings.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     title, children     REQUIRED
 *     description?, footer? (Save, or the destructive action)
 *     level?              default 2
 *     tone?               "default" | "danger"
 *
 * # Behaviour
 *
 * R1  The title and description sit beside a card of controls (1:2) when the
 *     section is at least 44rem wide, and above it when narrower. It measures
 *     its own width, not the window's.
 * R2  The footer holds the section's commit action, end-aligned.
 * R3  `danger` frames irreversible actions: the title in `--crit` and the
 *     card outlined in `--crit-line`. Its action should confirm first.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * R1 is a container query, so the same section stacks inside a narrow column
 * and spreads out on a full-width page.
 */
export {};

src/lib/components/patterns/settings-section/SettingsSection.svelte

<script lang="ts">
	import type { Snippet } from 'svelte';
	import { Card, CardBody, CardFooter } from '$lib/components/display/card';
	import { Heading } from '$lib/components/typography/heading';
	import { Text } from '$lib/components/typography/text';
	import { cn } from '$lib/utils/cn';
	import styles from './settings-section.module.css';

	/* One group of settings: what it is on the left, the controls on a card on
	   the right; stacked when narrow. */
	let {
		title,
		description,
		footer,
		level = 2,
		tone = 'default',
		class: className = '',
		children
	}: {
		title: string;
		description?: string;
		/** The card's footer: usually Save, or the destructive action. */
		footer?: Snippet;
		level?: 1 | 2 | 3 | 4 | 5 | 6;
		/** "danger" frames irreversible actions in the critical tone. */
		tone?: 'default' | 'danger';
		class?: string;
		children: Snippet;
	} = $props();
</script>

<section class={cn(styles.root, tone === 'danger' && styles.danger, className)}>
	<div class={styles.grid}>
		<div class={styles.copy}>
			<Heading {level} size="sm" class={styles.title}>{title}</Heading>
			{#if description}<Text size="sm" tone="muted">{description}</Text>{/if}
		</div>
		<Card as="div" class={styles.card}>
			<CardBody>{@render children()}</CardBody>
			{#if footer}<CardFooter>{@render footer()}</CardFooter>{/if}
		</Card>
	</div>
</section>

src/lib/components/patterns/settings-section/settings-section.module.css

@layer composition {
	/* Adapts to its own width, not the viewport: a settings section in a narrow
     column stacks even on a wide screen. */
	.root {
		container-type: inline-size;
	}
	.grid {
		display: grid;
		gap: var(--space-6);
	}
	@container (min-width: 44rem) {
		.grid {
			grid-template-columns: minmax(12rem, 1fr) minmax(0, 2fr);
			gap: var(--space-9);
		}
	}
	.copy {
		display: grid;
		align-content: start;
		gap: var(--space-3);
	}
	.danger .title {
		color: var(--crit);
	}
	.danger .card {
		border-color: var(--crit-line);
	}
}

Collection page

recipe · header, toolbar, card grid, empty result

Projects

Everything the workspace is building, grouped by project.

24 of 24

Atlas

Margaret Hamilton

active

109 tasks · updated 11 Aug 2026

Beacon

Donald Knuth

active

181 tasks · updated Today

Cinder

Grace Hopper

active

139 tasks · updated 19 Aug 2026

Delta

Donald Knuth

active

231 tasks · updated 25 Aug 2026

Ember

Donald Knuth

paused

5 tasks · updated 26 Aug 2026

Fjord

Tim Berners-Lee

archived

90 tasks · updated 18 Aug 2026

Garnet

Barbara Liskov

active

213 tasks · updated Yesterday

Harbor

Frances Allen

archived

173 tasks · updated 28 Jul 2026

Iris

Ada Lovelace

archived

186 tasks · updated 19 Jul 2026

Detail page

recipe · breadcrumb, status, stats, tabs

Atlas

Active Created 12 Mar 2026
Open tasks 42 8 due this week
Members 4
Completed 68% +12% this month
Storage Not measured Not tracked for this project
Owner
Ada Lovelace
Visibility
Workspace members
Region
Europe (Ireland)
Description
Shared infrastructure and tooling for the autumn release.

Settings page

recipe · sections, feedback, danger zone

Workspace settings

Changes apply to everyone in Northstar.

Profile

How the workspace appears to members and in invitations.

A sentence or two.

Notifications

Changes apply immediately.

Delete workspace

Deletes every project and file for all members. This cannot be undone.

Only the owner can delete a workspace.

Destructive actions confirm. The danger zone is framed in the critical tone and opens an AlertDialog whose focus starts on Cancel.

Onboarding step

recipe · progress, plan choice, continue

Step 2 of 3

Choose a plan

You can change plans at any time.

3 seats, 1 GB. Free forever.

50 seats, 100 GB, email support.

250 seats, 1 TB, priority support.

Overview

recipe · greeting, alert, stats, activity, usage

Good afternoon, Ada

Here is what changed in Northstar this week.

2 limits nearly reached

Seats and API requests are above 80% of your plan.
Active members 10 of 50 seats
Projects 24 +3 this week
Tasks closed 1,284 +8% vs last week
Invoices due 1 $374.00 on 1 Oct

Recent activity

WhenWhoWhat
26 Sept, 17:24 UTC Linus Torvalds project.archived
26 Sept, 15:56 UTC Ada Lovelace member.invited
26 Sept, 13:54 UTC Linus Torvalds api_key.created
26 Sept, 12:57 UTC Katherine Johnson member.role_changed
26 Sept, 11:26 UTC Alan Turing project.archived

Plan usage

Team plan · renews 1 Oct 2026

Seats

46 / 50

Storage

71.4 / 100

API requests

1,020,000 / 1,000,000