Skip to examples
Bento / Kitchen sink
Bento / compositions

Shells

The frame a screen sits in. A shell owns the main landmark, the skip link, and which region scrolls; the screen owns everything inside. Each is previewed in its own document, since a shell nested in this page would put a main inside a main.

AppShell

sticky header · sidebar that scrolls on its own · document scroll

The page scrolls as a document. Only the sidebar has its own scroll, so find-in-page, anchors, and the browser’s scroll restoration all keep working.

Narrow, the sidebar moves above the content instead of disappearing: navigation that vanishes cannot be reached.

Source src/lib/components/shells/doc.ts · src/lib/components/shells/app-shell/doc.ts · src/lib/components/shells/app-shell/AppShell.svelte · src/lib/components/shells/app-shell/app-shell.module.css

src/lib/components/shells/doc.ts

/**
 * shells — the frame a whole screen sits in.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # What belongs here
 *
 * A shell owns page-level concerns: the one main landmark, the skip link that
 * targets it, the header and navigation regions, viewport height, and which
 * region scrolls. It owns no content, no routes, and no data.
 *
 * # Rules for every member
 *
 * R1  A screen renders exactly one shell, and the shell renders exactly one
 *     main. Nothing inside a shell renders another main.
 * R2  The first focusable element is "Skip to content", which moves focus to
 *     main.
 * R3  Navigation is passed in: which link is current is the caller's
 *     decision, made from its router, so shells work in any framework.
 * R4  Navigation never disappears at narrow widths; it moves.
 * R5  Shells are demonstrated in their own documents (the shell-preview
 *     route), never nested inside another page, since R1 would break.
 */
export {};

src/lib/components/shells/app-shell/doc.ts

/**
 * AppShell — header across the top, sidebar down the side, content.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     nav        the sidebar, REQUIRED — usually a SidebarNav
 *     actions?   the header's end: theme, notifications, account
 *     header?    between the brand and the actions: search, a switcher
 *     brand?     replaces the wordmark
 *     children   the screen
 *
 * # Behaviour
 *
 * R1  The header sticks to the top; its height follows control density.
 * R2  The sidebar sticks under the header and scrolls on its own, so a long
 *     navigation and a long page do not drag each other.
 * R3  The page scrolls as a document: the browser's own scroll, find, and
 *     anchor behaviour are kept.
 * R4  Below 40rem the sidebar moves above the content.
 * R5  Main is focusable only by script, as the skip link's target.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * --header-h is derived from --control-md and the header padding, and the
 * sidebar's sticky offset and height read it. Main takes tabindex=-1 and an
 * id from $props.id() for the skip link.
 */
export {};

src/lib/components/shells/app-shell/AppShell.svelte

<script lang="ts">
	import type { Snippet } from 'svelte';
	import { Mark } from '$lib/components/chrome/mark';
	import { cn } from '$lib/utils/cn';
	import styles from './app-shell.module.css';

	/* Header, sidebar, main — and nothing about what goes in them. Owns the
	   page's one <main>, which the skip link targets. */
	let {
		nav,
		actions,
		header,
		brand,
		children,
		class: className = ''
	}: {
		/** The sidebar: usually a SidebarNav, often inside a ContextSidebar. */
		nav: Snippet;
		/** The header's end: theme toggle, notifications, account menu. */
		actions?: Snippet;
		/** Between the brand and the actions: search, a workspace switcher. */
		header?: Snippet;
		/** Replaces the wordmark. */
		brand?: Snippet;
		children: Snippet;
		class?: string;
	} = $props();

	const id = $props.id();
</script>

<div class={cn(styles.root, className)}>
	<a href="#{id}-main" class={styles.skip}>Skip to content</a>
	<header class={styles.header}>
		{#if brand}{@render brand()}{:else}<Mark />{/if}
		<div class={styles.headerContent}>{@render header?.()}</div>
		{#if actions}<div class={styles.actions}>{@render actions()}</div>{/if}
	</header>
	<div class={styles.body}>
		<aside class={styles.sidebar}>{@render nav()}</aside>
		<main id="{id}-main" tabindex="-1" class={styles.main}>{@render children()}</main>
	</div>
</div>

src/lib/components/shells/app-shell/app-shell.module.css

@layer composition {
	.root {
		/* Derived from what the header holds (sm controls plus its padding), so it
       follows compact density instead of a typed-in height. */
		--header-h: calc(var(--control-md) + var(--space-4) * 2 + 1px);
		--sidebar-w: 15rem;
		display: grid;
		min-height: 100dvh;
		grid-template-rows: auto minmax(0, 1fr);
		background: var(--surface-ground);
		color: var(--ink);
		font-family: var(--font-sans);
		font-size: var(--text-body, var(--text-13));
	}
	.skip {
		position: absolute;
		z-index: var(--z-toast);
		top: var(--space-3);
		left: var(--space-3);
		padding: var(--space-3) var(--space-5);
		border-radius: var(--radius-2);
		background: var(--fill);
		color: var(--fill-ink);
		transform: translateY(-200%);
	}
	.skip:focus {
		transform: none;
	}
	.header {
		position: sticky;
		z-index: var(--z-sticky);
		top: 0;
		display: flex;
		height: var(--header-h);
		align-items: center;
		gap: var(--space-6);
		padding: var(--space-4) var(--space-7);
		border-bottom: 1px solid var(--line);
		background: var(--surface-panel);
	}
	.headerContent {
		min-width: 0;
		flex: 1;
	}
	.actions {
		display: flex;
		align-items: center;
		gap: var(--space-3);
	}
	.body {
		display: grid;
		grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
	}
	/* Its own scroll: a long nav does not drag the page, and a long page does
     not scroll the nav away. */
	.sidebar {
		position: sticky;
		top: var(--header-h);
		height: calc(100dvh - var(--header-h));
		overflow-y: auto;
		padding: var(--space-6) var(--space-5);
		border-right: 1px solid var(--line);
		background: var(--surface-rail);
	}
	.main {
		min-width: 0;
		padding: var(--space-9) clamp(var(--space-7), 4vw, var(--space-10));
	}
	.main:focus {
		outline: none;
	}
	/* Narrow: the sidebar becomes a strip above the content rather than
     disappearing; a navigation that vanishes cannot be reached. */
	@media (max-width: 40rem) {
		.body {
			grid-template-columns: minmax(0, 1fr);
		}
		.sidebar {
			position: static;
			height: auto;
			border-right: 0;
			border-bottom: 1px solid var(--line);
		}
	}
}

RailShell

icon rail · collapsible section sidebar · contained scroll

Two levels of navigation. The rail picks the section; the sidebar lists its pages. Rail links are icons, so each is named by label and shows it as a tooltip.

A hidden sidebar is removed, not moved off screen: its links leave the tab order. The toggle reports aria-expanded.

It responds to its own width through a container query, so it lays out the same in this frame as in a window.

Source src/lib/components/shells/rail-shell/doc.ts · src/lib/components/shells/rail-shell/RailShell.svelte · src/lib/components/shells/rail-shell/rail-shell.module.css · src/lib/components/shells/navigation-rail/doc.ts · src/lib/components/shells/navigation-rail/NavigationRail.svelte · src/lib/components/shells/navigation-rail/RailLink.svelte · src/lib/components/shells/navigation-rail/navigation-rail.module.css · src/lib/components/shells/context-sidebar/doc.ts · src/lib/components/shells/context-sidebar/ContextSidebar.svelte · src/lib/components/shells/context-sidebar/context-sidebar.module.css

src/lib/components/shells/rail-shell/doc.ts

/**
 * RailShell — icon rail, collapsible section sidebar, header, content.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     rail                  the top-level sections, REQUIRED — a NavigationRail
 *     sidebar               the section's navigation, REQUIRED
 *     header?               after the sidebar toggle
 *     sidebarOpen? + onSidebarOpenChange   controlled
 *     defaultSidebarOpen?   uncontrolled, default true
 *     sidebarLabel?         names the sidebar region
 *     children              the screen
 *
 * # Behaviour
 *
 * R1  The shell fills its viewport and the content scrolls inside it; rail,
 *     sidebar, and header stay put.
 * R2  The toggle is a button naming what it will do, with aria-expanded and
 *     aria-controls pointing at the sidebar.
 * R3  A hidden sidebar is removed from the page, not just drawn off screen:
 *     its links cannot be tabbed to.
 * R4  Narrow (under 40rem of its own width) the sidebar opens below the
 *     header instead of beside it; the rail stays.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The shell is a size container, so R4 responds to the shell's width, not
 * the window's — it lays out the same in a preview frame. The sidebar uses
 * the hidden attribute (R3).
 */
export {};

src/lib/components/shells/rail-shell/RailShell.svelte

<script lang="ts">
	import type { Snippet } from 'svelte';
	import { Button } from '$lib/components/forms/button';
	import { PanelLeftClose, PanelLeftOpen } from '$lib/components/utility/icon';
	import { cn } from '$lib/utils/cn';
	import styles from './rail-shell.module.css';

	/* Rail, collapsible section sidebar, header, and content that scrolls on
	   its own. Owns the page's one <main>. */
	let {
		rail,
		sidebar,
		header,
		children,
		sidebarLabel = 'Section navigation',
		sidebarOpen = $bindable(true),
		onSidebarOpenChange,
		class: className = ''
	}: {
		/** The icon rail: a NavigationRail. */
		rail: Snippet;
		/** The section's own navigation: usually a ContextSidebar. */
		sidebar: Snippet;
		/** After the sidebar toggle: a title, search, actions. */
		header?: Snippet;
		children: Snippet;
		/** Names the sidebar region. */
		sidebarLabel?: string;
		/** Bindable; open by default. */
		sidebarOpen?: boolean;
		onSidebarOpenChange?: (open: boolean) => void;
		class?: string;
	} = $props();

	const id = $props.id();

	function toggle() {
		sidebarOpen = !sidebarOpen;
		onSidebarOpenChange?.(sidebarOpen);
	}
</script>

<div class={cn(styles.root, className)}>
	<div class={styles.frame} data-sidebar-open={sidebarOpen || undefined}>
		<a href="#{id}-main" class={styles.skip}>Skip to content</a>
		<div class={styles.rail}>{@render rail()}</div>
		<aside id="{id}-sidebar" aria-label={sidebarLabel} hidden={!sidebarOpen} class={styles.sidebar}>
			{@render sidebar()}
		</aside>
		<header class={styles.header}>
			<Button
				size="icon"
				variant="quiet"
				aria-label={sidebarOpen ? 'Hide section navigation' : 'Show section navigation'}
				aria-expanded={sidebarOpen}
				aria-controls="{id}-sidebar"
				onclick={toggle}
			>
				{#if sidebarOpen}<PanelLeftClose aria-hidden="true" />{:else}<PanelLeftOpen
						aria-hidden="true"
					/>{/if}
			</Button>
			<div class={styles.headerContent}>{@render header?.()}</div>
		</header>
		<main id="{id}-main" tabindex="-1" class={styles.main}>{@render children()}</main>
	</div>
</div>

src/lib/components/shells/rail-shell/rail-shell.module.css

@layer composition {
	/* Measures itself, so it lays out correctly in a preview frame as well as a
     full window. */
	.root {
		height: 100dvh;
		min-width: 0;
		background: var(--surface-ground);
		color: var(--ink);
		font-family: var(--font-sans);
		font-size: var(--text-body, var(--text-13));
		container: rail-shell / inline-size;
	}
	.frame {
		position: relative;
		display: grid;
		height: 100%;
		grid-template-columns: 56px 0 minmax(0, 1fr);
		grid-template-rows: auto minmax(0, 1fr);
		grid-template-areas:
			'rail sidebar header'
			'rail sidebar main';
		overflow: hidden;
	}
	.frame[data-sidebar-open] {
		grid-template-columns: 56px 15rem minmax(0, 1fr);
	}
	.rail {
		grid-area: rail;
		overflow-y: auto;
		border-right: 1px solid var(--line);
		background: var(--surface-rail);
	}
	.sidebar {
		grid-area: sidebar;
		min-width: 0;
		overflow-y: auto;
		padding: var(--space-6) var(--space-4);
		border-right: 1px solid var(--line);
		background: var(--surface-panel);
	}
	.sidebar[hidden] {
		display: none;
	}
	.header {
		display: flex;
		min-width: 0;
		align-items: center;
		gap: var(--space-4);
		grid-area: header;
		padding: var(--space-3) var(--space-6);
		border-bottom: 1px solid var(--line);
		background: var(--surface-panel);
	}
	.headerContent {
		min-width: 0;
		flex: 1;
	}
	.main {
		min-width: 0;
		min-height: 0;
		grid-area: main;
		overflow: auto;
		padding: var(--space-9) clamp(var(--space-7), 4vw, var(--space-10));
		overscroll-behavior: contain;
	}
	.main:focus {
		outline: none;
	}
	.skip {
		position: absolute;
		z-index: var(--z-toast);
		top: var(--space-3);
		left: var(--space-3);
		padding: var(--space-3) var(--space-5);
		border-radius: var(--radius-2);
		background: var(--fill);
		color: var(--fill-ink);
		transform: translateY(-200%);
	}
	.skip:focus {
		transform: none;
	}
	/* Narrow: the sidebar opens below the header instead of beside it. */
	@container rail-shell (max-width: 40rem) {
		.frame,
		.frame[data-sidebar-open] {
			grid-template-columns: 56px minmax(0, 1fr);
			grid-template-rows: auto auto minmax(0, 1fr);
			grid-template-areas:
				'rail header'
				'rail sidebar'
				'rail main';
		}
		.sidebar {
			max-height: 16rem;
			border-right: 0;
			border-bottom: 1px solid var(--line);
		}
	}
}

src/lib/components/shells/navigation-rail/doc.ts

/**
 * NavigationRail / RailLink — a narrow column of icon links to the app's
 * top-level sections.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     NavigationRail   brand?, footer?, label? (the landmark's name), children
 *     RailLink         href and label REQUIRED, description?, active?, icon
 *
 * # Behaviour
 *
 * R1  A rail link is named by `label`. An icon is never the only name.
 * R2  The name is shown as a tooltip on hover and focus, so sighted users
 *     get what screen reader users hear.
 * R3  The active link is marked aria-current="true": it is a section, and
 *     the page inside it may be anything.
 * R4  Footer links (settings, help) are pinned to the bottom but are still
 *     part of the navigation landmark. An account menu is not navigation:
 *     put it in the sidebar's footer instead.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * RailLink wraps its anchor in the shared Tooltip, placed to the right, and
 * sets aria-label, which the tooltip does not replace.
 */
export {};

src/lib/components/shells/navigation-rail/NavigationRail.svelte

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

	/* A narrow column of icon links to the app's top-level sections. */
	let {
		brand,
		footer,
		label = 'Application sections',
		children,
		class: className = ''
	}: {
		brand?: Snippet;
		/** Pinned to the bottom of the navigation: help, settings. */
		footer?: Snippet;
		/** Names the navigation landmark. */
		label?: string;
		children: Snippet;
		class?: string;
	} = $props();
</script>

<div class={cn(styles.root, className)}>
	{#if brand}{@render brand()}{:else}<span></span>{/if}
	<nav aria-label={label} class={styles.nav}>
		<div class={styles.items}>{@render children()}</div>
		{#if footer}<div class={styles.items}>{@render footer()}</div>{/if}
	</nav>
</div>

src/lib/components/shells/navigation-rail/RailLink.svelte

<script lang="ts">
	import type { HTMLAnchorAttributes } from 'svelte/elements';
	import { Tooltip } from '$lib/components/overlays/tooltip';
	import type { LayoutGrid } from '$lib/components/utility/icon';
	import { cn } from '$lib/utils/cn';
	import styles from './navigation-rail.module.css';

	/* `href` (required) arrives through rest, like NavLink's: the caller
	   resolves it. An icon-only link. Named by `label`, never by its icon; the tooltip
	   repeats the name for sighted pointer users. */
	let {
		label,
		icon: Icon,
		description,
		active = false,
		class: className = '',
		...rest
	}: Omit<HTMLAnchorAttributes, 'aria-label' | 'aria-current' | 'children' | 'class'> & {
		class?: string;
		href: string;
		/** The accessible name; also the tooltip unless description is given. */
		label: string;
		icon: typeof LayoutGrid;
		description?: string;
		active?: boolean;
	} = $props();
</script>

<Tooltip content={description ?? label} side="right">
	{#snippet trigger(props)}
		<a
			{...rest}
			{...props}
			aria-label={label}
			aria-current={active ? 'true' : undefined}
			class={cn(styles.link, className)}
		>
			<Icon aria-hidden="true" />
		</a>
	{/snippet}
</Tooltip>

src/lib/components/shells/navigation-rail/navigation-rail.module.css

@layer composition {
	.root {
		display: grid;
		height: 100%;
		grid-template-rows: auto minmax(0, 1fr);
		justify-items: center;
		gap: var(--space-6);
		padding: var(--space-5) 0;
	}
	.nav {
		display: grid;
		min-height: 0;
		align-content: space-between;
		gap: var(--space-6);
	}
	.items {
		display: grid;
		align-content: start;
		gap: var(--space-2);
	}
	.link {
		display: grid;
		width: 36px;
		height: 36px;
		place-items: center;
		border-radius: var(--radius-2);
		color: var(--ink-3);
	}
	.link svg {
		width: 18px;
		height: 18px;
	}
	.link:hover {
		background: var(--surface-hover-2);
		color: var(--ink);
	}
	.link:focus-visible {
		outline: 2px solid var(--accent);
		outline-offset: 0;
	}
	.link[aria-current]:not([aria-current='false']) {
		background: var(--accent-tint);
		color: var(--accent);
	}
}

src/lib/components/shells/context-sidebar/doc.ts

/**
 * ContextSidebar — a sidebar that says which section it belongs to.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     title         the section or workspace, REQUIRED
 *     description?  a line under it
 *     footer?       pinned to the bottom: the account, a switcher
 *     level?        heading level, default 2
 *     children      the section's navigation
 *
 * # Behaviour
 *
 * R1  The title is a heading, so the sidebar can be found by heading.
 * R2  The footer sits at the bottom of the sidebar even when the navigation
 *     is short, and after it when the navigation is long.
 */
export {};

src/lib/components/shells/context-sidebar/ContextSidebar.svelte

<script lang="ts">
	import type { Snippet } from 'svelte';
	import { Heading } from '$lib/components/typography/heading';
	import { Text } from '$lib/components/typography/text';
	import { cn } from '$lib/utils/cn';
	import styles from './context-sidebar.module.css';

	let {
		title,
		description,
		footer,
		level = 2,
		children,
		class: className = ''
	}: {
		/** The section or workspace the sidebar belongs to. */
		title: string;
		description?: string;
		/** Pinned to the bottom: the account, the workspace switcher. */
		footer?: Snippet;
		level?: 1 | 2 | 3 | 4 | 5 | 6;
		children: Snippet;
		class?: string;
	} = $props();
</script>

<div class={cn(styles.root, className)}>
	<div class={styles.head}>
		<Heading {level} size="sm">{title}</Heading>
		{#if description}<Text size="sm" tone="quiet">{description}</Text>{/if}
	</div>
	<div>{@render children()}</div>
	{#if footer}<div class={styles.footer}>{@render footer()}</div>{/if}
</div>

src/lib/components/shells/context-sidebar/context-sidebar.module.css

@layer composition {
	.root {
		display: grid;
		min-height: 100%;
		grid-template-rows: auto minmax(0, 1fr) auto;
		gap: var(--space-7);
	}
	.head {
		display: grid;
		gap: var(--space-2);
		padding: 0 var(--space-4);
	}
	.footer {
		padding: var(--space-5) var(--space-4) 0;
		border-top: 1px solid var(--line);
	}
}

AuthShell

one column · required h1 · used by sign-in and sign-up

The title is required and is the page’s h1; the wordmark is not a heading. The real sign-in and sign-up pages use this shell.

Source src/lib/components/shells/auth-shell/doc.ts · src/lib/components/shells/auth-shell/AuthShell.svelte · src/lib/components/shells/auth-shell/auth-shell.module.css

src/lib/components/shells/auth-shell/doc.ts

/**
 * AuthShell — the frame every signed-out screen shares.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     title         REQUIRED, the page's h1
 *     description?  under the title
 *     actions?      beside the brand: theme, language
 *     brand?        replaces the wordmark
 *     footer?       under the card: the link to the other door
 *     layout?       "centered" (default) | "split"
 *     aside?        split only: the other half — the product, a quote
 *     children      the form
 *
 * # Behaviour
 *
 * R1  Centred in the viewport in one narrow column; on a short viewport it
 *     scrolls instead of clipping.
 * R2  The title is required and is the h1: the brand is not a heading.
 * R3  The shell is the main landmark. It owns arrangement only; the form,
 *     its state, and its errors belong to the page.
 * R4  Split puts the form on the right half, without a card, and the aside
 *     on the left, held in view while the form scrolls. The aside is
 *     supplementary: it follows the form in reading order, and below a
 *     tablet's width it is left out and the form takes the screen.
 */
export {};

src/lib/components/shells/auth-shell/AuthShell.svelte

<script lang="ts">
	import type { Snippet } from 'svelte';
	import { Mark } from '$lib/components/chrome/mark';
	import { Card } from '$lib/components/display/card';
	import { Container } from '$lib/components/layout/container';
	import { Heading } from '$lib/components/typography/heading';
	import { Text } from '$lib/components/typography/text';
	import { cn } from '$lib/utils/cn';
	import styles from './auth-shell.module.css';

	/* The frame every signed-out screen shares: centred, one narrow column,
	   one card. It owns the arrangement only — the form belongs to the page. */
	let {
		title,
		description,
		actions,
		brand,
		footer,
		layout = 'centered',
		aside,
		children,
		class: className = ''
	}: {
		/** Required, and rendered as the page's h1: the wordmark is not a
		 *  heading, and a reader needs something to orient on. */
		title: string;
		description?: string;
		/** Above the card, beside the brand: the theme toggle, a language menu. */
		actions?: Snippet;
		/** Replaces the wordmark; wrap it in a link home if there is one. */
		brand?: Snippet;
		/** Under the card: the link to the other door. */
		footer?: Snippet;
		/** One centred card (default), or the form on one half of the screen
		 *  and `aside` on the other. */
		layout?: 'centered' | 'split';
		/** The other half, in split: the product, a promise, a quote. It is
		 *  supplementary, so it follows the form in reading order and is left
		 *  out when the screen is narrow. */
		aside?: Snippet;
		children: Snippet;
		class?: string;
	} = $props();
</script>

{#snippet head()}
	<div class={styles.head}>
		<Heading level={1} size={layout === 'split' ? 'lg' : 'md'}>{title}</Heading>
		{#if description}<Text tone="muted">{description}</Text>{/if}
	</div>
{/snippet}

{#if layout === 'split'}
	<main class={cn(styles.root, styles.split, className)}>
		<div class={styles.pane}>
			<div class={styles.top}>
				{#if brand}{@render brand()}{:else}<Mark />{/if}
				{@render actions?.()}
			</div>
			<div class={styles.body}>
				{@render head()}
				{@render children()}
				{#if footer}<div class={styles.footer}>{@render footer()}</div>{/if}
			</div>
		</div>
		{#if aside}<aside class={styles.aside}>{@render aside()}</aside>{/if}
	</main>
{:else}
	<main class={cn(styles.root, className)}>
		<Container width="narrow">
			<div class={styles.column}>
				<div class={styles.top}>
					{#if brand}{@render brand()}{:else}<Mark />{/if}
					{@render actions?.()}
				</div>
				<Card as="div" elevation="raised" class={styles.card}>
					{@render head()}
					{@render children()}
				</Card>
				{#if footer}<div class={styles.footer}>{@render footer()}</div>{/if}
			</div>
		</Container>
	</main>
{/if}

src/lib/components/shells/auth-shell/auth-shell.module.css

@layer composition {
	.root {
		display: grid;
		min-height: 100dvh;
		align-content: center;
		padding-block: var(--space-9);
		background: var(--surface-ground);
		color: var(--ink);
		font-family: var(--font-sans);
		font-size: var(--text-body, var(--text-13));
	}
	.column {
		display: grid;
		gap: var(--space-7);
	}
	.top {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: var(--space-5);
	}
	.card {
		display: grid;
		gap: var(--space-7);
		padding: var(--space-9);
	}
	.head {
		display: grid;
		gap: var(--space-3);
	}
	.footer {
		color: var(--ink-2);
		font-size: var(--text-body, var(--text-13));
		text-align: center;
	}

	/* ── Split: the form on one half, the aside on the other ── */
	.split {
		grid-template-areas: 'aside pane';
		grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
		align-content: stretch;
		padding-block: 0;
	}
	.pane {
		display: grid;
		min-width: 0;
		grid-area: pane;
		grid-template-rows: auto 1fr;
		padding: var(--space-7) var(--gutter);
	}
	.body {
		display: grid;
		width: min(100%, var(--narrow-max));
		align-content: center;
		gap: var(--space-7);
		margin-inline: auto;
		padding-block: var(--space-10);
	}
	.split .footer {
		text-align: start;
	}
	/* In reading order it comes after the form; on screen it is the left
     half, held in view while the form scrolls. */
	.aside {
		position: sticky;
		top: 0;
		height: 100dvh;
		min-width: 0;
		grid-area: aside;
		overflow: hidden;
		border-right: 1px solid var(--line);
	}
	@media (max-width: 56rem) {
		.split {
			grid-template-areas: 'pane';
			grid-template-columns: minmax(0, 1fr);
		}
		.aside {
			display: none;
		}
	}
}

SidebarNav

groups as data · current by segment

Current is decided by path segment. On /projects/atlas, Projects is current (a page inside it), Overview at / is not, and /app would never match /apples.

Source src/lib/components/shells/sidebar-nav/doc.ts · src/lib/components/shells/sidebar-nav/SidebarNav.svelte · src/lib/components/shells/sidebar-nav/current.ts · src/lib/components/shells/sidebar-nav/types.ts · src/lib/components/shells/sidebar-nav/sidebar-nav.module.css

src/lib/components/shells/sidebar-nav/doc.ts

/**
 * SidebarNav — a sidebar's links, grouped, as data.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     groups       { label?, items: { href, label, icon?, badge?, exact? }[] }[]
 *     current?     the path the reader is on
 *     label?       names the navigation landmark, default "Sections"
 *
 * # Behaviour
 *
 * R1  One navigation landmark; each group is a list, named by its label.
 * R2  A link is current on its own path, and (unless exact) on any path
 *     inside it, so a detail page still marks its section.
 * R3  Paths are matched by segment: /app is not current on /apples.
 * R4  "/" is current only on "/".
 * R5  Several links may match; each is marked. Give overlapping links
 *     `exact` rather than relying on order.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * isCurrent() in current.ts implements R2–R4 and is exported for callers
 * that mark current links elsewhere (a rail). Each row is a NavLink.
 */
export {};

src/lib/components/shells/sidebar-nav/SidebarNav.svelte

<script lang="ts">
	import { NavLink } from '$lib/components/navigation/nav-link';
	import { isCurrent } from './current';
	import styles from './sidebar-nav.module.css';
	import type { NavGroup } from './types';

	/* The sidebar's links, as data: a navigation is a list of links, and a
	   product edits one array rather than hunting links through markup. */
	let {
		groups,
		current,
		label = 'Sections'
	}: {
		groups: readonly NavGroup[];
		/** The path the reader is on. A prop, never read from the router, so
		 *  the component is the same in every framework. */
		current?: string;
		/** Names the navigation landmark. */
		label?: string;
	} = $props();

	const id = $props.id();
</script>

<nav aria-label={label} class={styles.root}>
	{#each groups as group, index (group.label ?? index)}
		{@const headingId = group.label ? `${id}-${index}` : undefined}
		<div class={styles.group}>
			{#if group.label}<p id={headingId} class={styles.groupLabel}>{group.label}</p>{/if}
			<ul class={styles.list} aria-labelledby={headingId}>
				{#each group.items as item (item.href)}
					<li>
						<NavLink href={item.href} active={isCurrent(item.href, current, item.exact)}>
							{#if item.icon}<item.icon aria-hidden="true" />{/if}
							{item.label}
							{#if item.badge}
								<span class={styles.badge}>
									{#if typeof item.badge === 'string'}{item.badge}{:else}{@render item.badge()}{/if}
								</span>
							{/if}
						</NavLink>
					</li>
				{/each}
			</ul>
		</div>
	{/each}
</nav>

src/lib/components/shells/sidebar-nav/current.ts

/** Whether a nav link is where the reader is: the path itself, or (unless
 *  exact) a page inside it, so a detail route still marks its section.
 *  Matched by segment: /app is not current on /apples. "/" is never current
 *  by containment, or it would be current everywhere. */
export function isCurrent(href: string, current?: string, exact = false) {
	if (!current) return false;
	const path = current.split(/[?#]/)[0];
	const target = href.split(/[?#]/)[0];
	if (href === current || (!href.includes('?') && target === path)) return true;
	if (exact || target === '/') return false;
	return path.startsWith(`${target.replace(/\/$/, '')}/`);
}

src/lib/components/shells/sidebar-nav/types.ts

import type { Snippet } from 'svelte';
import type { LayoutGrid } from '$lib/components/utility/icon';

export type NavItem = {
	href: string;
	label: string;
	icon?: typeof LayoutGrid;
	/** A count or status after the label. */
	badge?: string | Snippet;
	/** Current only on this exact path, not on pages inside it. */
	exact?: boolean;
};

export type NavGroup = {
	/** Heads the group, so a reader knows which part of the app it is. */
	label?: string;
	items: readonly NavItem[];
};

src/lib/components/shells/sidebar-nav/sidebar-nav.module.css

@layer composition {
	.root {
		display: grid;
		gap: var(--space-7);
	}
	.group {
		display: grid;
		gap: var(--space-1);
	}
	.groupLabel {
		margin: 0 0 var(--space-2);
		padding: 0 var(--space-4);
		color: var(--ink-3);
		font-size: var(--text-11);
		font-weight: var(--weight-bold);
		letter-spacing: var(--tracking-label);
		text-transform: uppercase;
	}
	.list {
		display: grid;
		gap: var(--space-1);
		margin: 0;
		padding: 0;
		list-style: none;
	}
	.badge {
		margin-left: auto;
	}
}

In the app

/w/[workspace] · RailShell with real routes · placeholder data

The signed-in app, not a preview. Every page has its own address under the workspace, so links can be shared and the shell stays mounted between pages. The rail, the section's pages, the breadcrumb, and ⌘K all come from one navigation list; the numbers are placeholders until the screens are wired to the backend.

Mark

name · compact

full
Bento
compact
Bento
Source src/lib/components/chrome/mark/doc.ts · src/lib/components/chrome/mark/Mark.svelte · src/lib/components/chrome/mark/mark.module.css

src/lib/components/chrome/mark/doc.ts

/**
 * Mark — the product's wordmark.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     name?      the product name, default "Bento"
 *     compact?   glyph only, default false
 *
 * # Behaviour
 *
 * R1  Every place that shows the brand renders this, so replacing the brand
 *     is one file.
 * R2  Compact hides the name visually only; it is still read out.
 * R3  The mark is not a heading and not a link; wrap it in a link home where
 *     one exists.
 */
export {};

src/lib/components/chrome/mark/Mark.svelte

<script lang="ts">
	import { cn } from '$lib/utils/cn';
	import styles from './mark.module.css';

	/* The product's wordmark: a glyph and the name. Swap this file for a real
	   logo; everything that shows the brand renders it through here. */
	let {
		name = 'Bento',
		compact = false,
		class: className = ''
	}: {
		/** The product name. */
		name?: string;
		/** Glyph only; the name stays available to assistive technology. */
		compact?: boolean;
		class?: string;
	} = $props();
</script>

<span class={cn(styles.root, compact && styles.compact, className)}>
	<span class={styles.glyph} aria-hidden="true">{name.charAt(0)}</span>
	<span class={styles.word}>{name}</span>
</span>

src/lib/components/chrome/mark/mark.module.css

@layer primitive {
	.root {
		display: inline-flex;
		align-items: center;
		gap: var(--space-4);
		color: var(--ink);
		font-size: var(--text-15);
		font-weight: var(--weight-bold);
		letter-spacing: var(--tracking-tight);
		text-decoration: none;
	}
	.glyph {
		display: grid;
		width: 28px;
		height: 28px;
		flex: none;
		place-items: center;
		border-radius: var(--radius-2);
		background: var(--fill);
		color: var(--fill-ink);
		font-size: var(--text-15);
	}
	.compact .word {
		position: absolute;
		width: 1px;
		height: 1px;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
	}
}