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".

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

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.

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;
    }
  }
}