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.
Twelve members, two pending invitations.
Team plan, renews on 1 October.
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
Page 1 of 12
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;
}
}
}