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".
Sourcecomponents/navigation/doc.ts · components/navigation/tabs/doc.ts · components/navigation/tabs/tabs.tsx · components/navigation/tabs/tabs.module.css
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 {};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 {};components/navigation/tabs/tabs.tsx
"use client";
import * as Primitive from "@radix-ui/react-tabs";
import type { ComponentPropsWithRef } from "react";
import { cn } from "@/lib/utils/cn";
import styles from "./tabs.module.css";
export type TabsProps = ComponentPropsWithRef<typeof Primitive.Root>;
export type TabsListProps = ComponentPropsWithRef<typeof Primitive.List>;
export type TabProps = ComponentPropsWithRef<typeof Primitive.Trigger>;
export type TabPanelProps = ComponentPropsWithRef<typeof Primitive.Content>;
/** Arrowing to a tab shows its panel (automatic activation), which is what a
* keyboard user expects. Pass activationMode="manual" when showing a panel is
* expensive: arrows then move focus and Enter commits. */
export function Tabs(props: TabsProps) {
return <Primitive.Root {...props} />;
}
export function TabsList({ className, ...props }: TabsListProps) {
return <Primitive.List {...props} className={cn(styles.list, className)} />;
}
export function Tab({ className, ...props }: TabProps) {
return <Primitive.Trigger {...props} className={cn(styles.tab, className)} />;
}
export function TabPanel({ className, ...props }: TabPanelProps) {
return (
<Primitive.Content {...props} className={cn(styles.panel, className)} />
);
}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.
Sourcecomponents/navigation/pagination/doc.ts · components/navigation/pagination/pagination.tsx · components/navigation/pagination/pages.ts · components/navigation/pagination/pagination.module.css
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 {};components/navigation/pagination/pagination.tsx
"use client";
import { Button } from "@/components/forms/button";
import { ChevronLeft, ChevronRight } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import { clampPage, pageItems } from "./pages";
import styles from "./pagination.module.css";
export type PaginationProps = {
/** The current page, 1-based. */
page: number;
totalPages: number;
onPageChange: (page: number) => void;
label?: string;
className?: string;
};
export function Pagination({
page,
totalPages,
onPageChange,
label = "Pagination",
className,
}: PaginationProps) {
const total = Number.isFinite(totalPages)
? Math.max(0, Math.floor(totalPages))
: 0;
// One page needs no navigation.
if (total <= 1) return null;
const current = clampPage(page, total);
return (
<nav aria-label={label} className={cn(styles.root, className)}>
<Button
size="sm"
variant="quiet"
disabled={current === 1}
aria-label="Previous page"
onClick={() => onPageChange(current - 1)}
>
<ChevronLeft aria-hidden="true" />
<span className={styles.word}>Previous</span>
</Button>
<ol className={styles.pages}>
{pageItems(current, total).map((item) => (
<li key={item}>
{typeof item === "number" ? (
<Button
size="sm"
variant={item === current ? "secondary" : "quiet"}
className={styles.page}
aria-label={`Page ${item}`}
aria-current={item === current ? "page" : undefined}
onClick={() => onPageChange(item)}
>
{item}
</Button>
) : (
<span className={styles.gap} aria-hidden="true">
…
</span>
)}
</li>
))}
</ol>
<Button
size="sm"
variant="quiet"
disabled={current === total}
aria-label="Next page"
onClick={() => onPageChange(current + 1)}
>
<span className={styles.word}>Next</span>
<ChevronRight aria-hidden="true" />
</Button>
</nav>
);
}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));
}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;
}
}
}