Skip to examples
Bento / Kitchen sink
Bento / primitives

Navigation

Moving between views and places. Every navigation region is named, and the current place is marked for assistive technology, not only by colour.

Tabs

arrows move · one tab stop

Northstar at a glance.

Arrow keys move and show. The list is one tab stop; the disabled tab is skipped. For panels that are expensive to show, pass activationMode="manual".

Source src/lib/components/navigation/doc.ts · src/lib/components/navigation/tabs/doc.ts · src/lib/components/navigation/tabs/Tab.svelte · src/lib/components/navigation/tabs/tabs.module.css

src/lib/components/navigation/doc.ts

/**
 * navigation — moving between places and views.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Rules for every member
 *
 * R1  A navigation region is named, because a page can have several and
 *     "navigation" said twice tells a reader nothing.
 * R2  The current place is marked with aria-current, never by colour alone.
 * R3  Keyboard users can reach and operate everything a pointer can.
 */
export {};

src/lib/components/navigation/tabs/doc.ts

/**
 * Tabs — views of one subject, one at a time.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     Tabs       value?, defaultValue?, onValueChange?, activationMode?
 *     TabsList   the tabs; give it an aria-label when nothing else names it
 *     Tab        value, REQUIRED; disabled?
 *     TabPanel   value, REQUIRED
 *
 * # Behaviour
 *
 * R1  The list is one tab stop. Arrow keys move between tabs, Home and End
 *     jump to the ends; disabled tabs are skipped.
 * R2  Moving to a tab shows its panel (automatic activation). With
 *     activationMode="manual", arrows only move focus and Enter or Space
 *     shows the panel — for panels that are expensive to show.
 * R3  The selected tab is underlined in `--accent` and set in `--ink`; the
 *     others are `--ink-3` and darken on hover.
 * R4  Each panel is labelled by its tab and follows the list with a
 *     `--space-7` gap. A list wider than its container scrolls sideways.
 *
 * # Tabs or navigation?
 *
 * Tabs swap views in place. Links to other pages are navigation, even when
 * they look like tabs.
 */
export {};

src/lib/components/navigation/tabs/Tab.svelte

<script lang="ts">
	import { Tabs as Primitive, type TabsTriggerProps } from 'bits-ui';
	import { cn } from '$lib/utils/cn';
	import styles from './tabs.module.css';

	let { class: className = '', ...rest }: Omit<TabsTriggerProps, 'class'> & { class?: string } =
		$props();
</script>

<Primitive.Trigger {...rest} class={cn(styles.tab, className)} />

src/lib/components/navigation/tabs/tabs.module.css

@layer primitive {
	.list {
		display: flex;
		gap: var(--space-2);
		overflow-x: auto;
		border-bottom: 1px solid var(--line);
	}
	.tab {
		flex: none;
		height: var(--control-md);
		margin-bottom: -1px;
		padding: 0 var(--space-5);
		border: 0;
		border-bottom: 2px solid transparent;
		background: none;
		color: var(--ink-3);
		font: inherit;
		font-size: var(--text-control, var(--text-13));
		font-weight: var(--weight-medium);
		white-space: nowrap;
		cursor: pointer;
		transition:
			color var(--dur-2) var(--ease),
			border-color var(--dur-2) var(--ease);
	}
	.tab:where(:hover:not(:disabled)) {
		color: var(--ink);
	}
	/* Selected is a state the primitive reports, not a class a caller sets. */
	.tab[data-state='active'] {
		border-bottom-color: var(--accent);
		color: var(--ink);
	}
	.tab:focus-visible {
		outline: 2px solid var(--accent);
		outline-offset: -2px;
		border-radius: var(--radius-1);
	}
	.tab:disabled {
		opacity: 0.45;
		cursor: not-allowed;
	}
	.panel {
		padding-top: var(--space-7);
	}
	.panel:focus-visible {
		outline: 2px solid var(--accent);
		outline-offset: 2px;
		border-radius: var(--radius-1);
	}
}

Pagination

at most seven pages shown

12 pages

Page 1 of 12

5 pages

Page 1 of 5

The current page is marked, not just coloured. It carries aria-current="page", and every button is named “Page N”. One page renders nothing at all.

Source src/lib/components/navigation/pagination/doc.ts · src/lib/components/navigation/pagination/Pagination.svelte · src/lib/components/navigation/pagination/pages.ts · src/lib/components/navigation/pagination/pagination.module.css

src/lib/components/navigation/pagination/doc.ts

/**
 * Pagination — pages of one list.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     page           the current page, 1-based
 *     totalPages     how many pages
 *     onPageChange   called with the chosen page
 *     label?         default "Pagination"
 *
 * # Behaviour
 *
 * R1  With one page or fewer, nothing renders.
 * R2  Shows at most seven page buttons: the first, the last, the current page
 *     and its neighbours, and an ellipsis (hidden from readers) where pages are
 *     skipped.
 * R3  The current page is marked aria-current="page" and drawn as a secondary
 *     button; the others are quiet. Every page button is named "Page N".
 * R4  Previous and Next are disabled at the ends and hide their words below
 *     480px, keeping their accessible names.
 * R5  A page outside 1..totalPages, or not a number, is treated as the
 *     nearest valid page.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * `pages.ts` holds the page-list arithmetic with no framework in it, so React
 * and Svelte share the identical file.
 */
export {};

src/lib/components/navigation/pagination/Pagination.svelte

<script lang="ts">
	import { Button } from '$lib/components/forms/button';
	import { ChevronLeft, ChevronRight } from '$lib/components/utility/icon';
	import { cn } from '$lib/utils/cn';
	import { clampPage, pageItems } from './pages';
	import styles from './pagination.module.css';

	let {
		page = $bindable(1),
		totalPages,
		onPageChange,
		label = 'Pagination',
		class: className = ''
	}: {
		/** The current page, 1-based. Bindable. */
		page?: number;
		totalPages: number;
		onPageChange?: (page: number) => void;
		label?: string;
		class?: string;
	} = $props();

	const total = $derived(Number.isFinite(totalPages) ? Math.max(0, Math.floor(totalPages)) : 0);
	const current = $derived(clampPage(page, total));

	function go(next: number) {
		page = next;
		onPageChange?.(next);
	}
</script>

<!-- One page needs no navigation. -->
{#if total > 1}
	<nav aria-label={label} class={cn(styles.root, className)}>
		<Button
			size="sm"
			variant="quiet"
			disabled={current === 1}
			aria-label="Previous page"
			onclick={() => go(current - 1)}
		>
			<ChevronLeft aria-hidden="true" /><span class={styles.word}>Previous</span>
		</Button>
		<ol class={styles.pages}>
			{#each pageItems(current, total) as item (item)}
				<li>
					{#if typeof item === 'number'}
						<Button
							size="sm"
							variant={item === current ? 'secondary' : 'quiet'}
							class={styles.page}
							aria-label={`Page ${item}`}
							aria-current={item === current ? 'page' : undefined}
							onclick={() => go(item)}>{item}</Button
						>
					{:else}
						<span class={styles.gap} aria-hidden="true">…</span>
					{/if}
				</li>
			{/each}
		</ol>
		<Button
			size="sm"
			variant="quiet"
			disabled={current === total}
			aria-label="Next page"
			onclick={() => go(current + 1)}
		>
			<span class={styles.word}>Next</span><ChevronRight aria-hidden="true" />
		</Button>
	</nav>
{/if}

src/lib/components/navigation/pagination/pages.ts

export type PageItem = number | 'start-gap' | 'end-gap';

/** The page buttons to show: first, last, the current page and its
 *  neighbours, and a gap marker where pages are skipped. Never more than seven
 *  entries, however many pages there are. */
export function pageItems(current: number, total: number): PageItem[] {
	if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
	const start = Math.max(2, Math.min(current - 1, total - 4));
	const end = Math.min(total - 1, Math.max(current + 1, 5));
	return [
		1,
		...(start > 2 ? (['start-gap'] as const) : []),
		...Array.from({ length: end - start + 1 }, (_, i) => start + i),
		...(end < total - 1 ? (['end-gap'] as const) : []),
		total
	];
}

/** A finite page number within 1..total, whatever the caller passed. */
export function clampPage(page: number, total: number): number {
	if (!Number.isFinite(page)) return 1;
	return Math.max(1, Math.min(Math.floor(page), total));
}

src/lib/components/navigation/pagination/pagination.module.css

@layer primitive {
	.root {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		justify-content: center;
		gap: var(--space-3);
		font-variant-numeric: tabular-nums;
	}
	.pages {
		display: flex;
		align-items: center;
		gap: var(--space-2);
		margin: 0;
		padding: 0;
		list-style: none;
	}
	.page {
		min-width: var(--control-sm);
	}
	.gap {
		display: block;
		padding-inline: var(--space-3);
		color: var(--ink-3);
	}
	@media (max-width: 480px) {
		.word {
			display: none;
		}
	}
}