Amsterdam Design System
Production guidance for building City of Amsterdam digital products using the official design system. Components, tokens, layout patterns, and integration with Tailwind CSS v4.
Docs: https://designsystem.amsterdam/ Repo: https://github.com/Amsterdam/design-system Storybook: https://storybook.designsystem.amsterdam/
Overview
The design system ships as 5 npm packages:
| Package | Purpose |
|---|---|
@amsterdam/design-system-assets | Amsterdam Sans font files |
@amsterdam/design-system-css | BEM component styles (ams-* classes) |
@amsterdam/design-system-tokens | CSS custom properties (--ams-*) in Spacious + Compact modes |
@amsterdam/design-system-react | React components (66 components, all with forwardRef) |
@amsterdam/design-system-react-icons | Icon components for the AMS icon set |
No provider or context wrapper required — import CSS, use components.
Setup
Install
npm install @amsterdam/design-system-assets @amsterdam/design-system-css @amsterdam/design-system-react @amsterdam/design-system-react-icons @amsterdam/design-system-tokens
CSS Imports — ORDER MATTERS
// ⚠️ CRITICAL: This exact order is required. Fonts → CSS → Tokens.
import "@amsterdam/design-system-assets/font/index.css" // 1. Font files
import "@amsterdam/design-system-css/dist/index.css" // 2. Component styles
import "@amsterdam/design-system-tokens/dist/index.css" // 3. Design tokens
For compact mode (internal tools), add one more import AFTER tokens:
import "@amsterdam/design-system-tokens/dist/compact.css" // 4. Compact overrides
Root Element
Add the ams-body class to your body or root element:
// Next.js (app/layout.tsx)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="nl">
<body className="ams-body">{children}</body>
</html>
)
}
Bold Text Fix
Amsterdam Sans uses weight 800 for bold, not the browser default 700. The ams-body class handles this, but if you scope differently:
.your-root {
font-weight: var(--ams-typography-body-text-font-weight); /* 400 */
}
.your-root strong, .your-root b {
font-weight: var(--ams-typography-body-text-bold-font-weight); /* 800 */
}
Component Patterns
Simple Components
import { Heading, Paragraph, Button, Alert } from "@amsterdam/design-system-react"
<Heading level={1}>Page Title</Heading>
<Paragraph>Body text uses Amsterdam Sans at 18-20px fluid.</Paragraph>
<Paragraph size="small">Secondary text at 16px.</Paragraph>
<Button variant="primary">Submit</Button>
<Button variant="secondary">Cancel</Button>
<Alert heading="Let op" headingLevel={2} severity="warning">
Check your input before proceeding.
</Alert>
Compound Components (dot notation)
Many components use Component.SubComponent pattern via Object.assign:
import { Accordion, Grid, Table, Tabs } from "@amsterdam/design-system-react"
{/* Accordion */}
<Accordion headingLevel={2}>
<Accordion.Section label="Section title">
<Paragraph>Section content.</Paragraph>
</Accordion.Section>
</Accordion>
{/* Grid */}
<Grid paddingVertical="large">
<Grid.Cell span={8}>Main content</Grid.Cell>
<Grid.Cell span={4}>Sidebar</Grid.Cell>
</Grid>
{/* Table */}
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Name</Table.HeaderCell>
<Table.HeaderCell>Value</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell>Item</Table.Cell>
<Table.Cell>100</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
{/* Tabs */}
<Tabs>
<Tabs.List>
<Tabs.Button aria-controls="tab1">First</Tabs.Button>
<Tabs.Button aria-controls="tab2">Second</Tabs.Button>
</Tabs.List>
<Tabs.Panel id="tab1">First panel content</Tabs.Panel>
<Tabs.Panel id="tab2">Second panel content</Tabs.Panel>
</Tabs>
Form Field Composition
AMS forms use a composition pattern: Field wraps Label + input + ErrorMessage.
import { Field, Label, TextInput, TextArea, Select, ErrorMessage, Checkbox, Radio, FieldSet } from "@amsterdam/design-system-react"
{/* Text field */}
<Field invalid={hasError}>
<Label htmlFor="name">Naam</Label>
<ErrorMessage>Vul uw naam in</ErrorMessage>
<TextInput id="name" invalid={hasError} />
</Field>
{/* Textarea */}
<Field>
<Label htmlFor="message">Bericht</Label>
<TextArea id="message" rows={4} />
</Field>
{/* Select */}
<Field>
<Label htmlFor="city">Stadsdeel</Label>
<Select id="city">
<Select.Option value="centrum">Centrum</Select.Option>
<Select.Option value="west">West</Select.Option>
<Select.Option value="oost">Oost</Select.Option>
</Select>
</Field>
{/* Checkbox/Radio group */}
<FieldSet legend="Voorkeur" invalid={hasError}>
<Checkbox>Optie A</Checkbox>
<Checkbox>Optie B</Checkbox>
</FieldSet>
<FieldSet legend="Type">
<Radio name="type" value="a">Type A</Radio>
<Radio name="type" value="b">Type B</Radio>
</FieldSet>
Page Layout
import { Grid, Page, PageHeader, PageFooter, PageHeading, Paragraph } from "@amsterdam/design-system-react"
<Page>
<PageHeader brandName="Mijn Amsterdam" logoLink="/" />
<Grid paddingVertical="large">
<Grid.Cell span="all">
<PageHeading>Welkom</PageHeading>
</Grid.Cell>
<Grid.Cell span={8}>
<Paragraph>Main content area.</Paragraph>
</Grid.Cell>
<Grid.Cell span={4}>
<Paragraph>Sidebar content.</Paragraph>
</Grid.Cell>
</Grid>
<PageFooter>
<PageFooter.Spotlight>
<Paragraph>Contact info</Paragraph>
</PageFooter.Spotlight>
</PageFooter>
</Page>
Dialog
import { Button, Dialog, Paragraph } from "@amsterdam/design-system-react"
<Button onClick={() => Dialog.open("confirm-dialog")}>Open Dialog</Button>
<Dialog
id="confirm-dialog"
heading="Bevestiging"
footer={
<>
<Button variant="primary" onClick={() => Dialog.close()}>Bevestigen</Button>
<Button variant="secondary" onClick={() => Dialog.close()}>Annuleren</Button>
</>
}
>
<Paragraph>Weet u het zeker?</Paragraph>
</Dialog>
Available Components
Full props reference: read
references/components.md
Layout
Grid (.Cell) · Column · Row · Breakout (.Cell) · Overlap · Page · Spotlight
Page Structure
PageHeader (.GridCellNarrowWindowOnly, .MenuLink) · PageFooter (.Menu, .MenuLink, .Spotlight) · PageHeading
Typography
Heading · Paragraph · Blockquote · Link · StandaloneLink · CallToActionLink · Mark
Buttons & Actions
Button · IconButton · ActionGroup
Form Controls
TextInput · TextArea · Select (.Group, .Option) · Checkbox · Radio · Switch · DateInput · TimeInput · PasswordInput · FileInput · SearchField (.Button, .Input) · CharacterCount
Form Structure
Field · FieldSet · Label · Hint · ErrorMessage · InvalidFormAlert
Navigation
Breadcrumb (.Link) · LinkList (.Link) · Menu (.Link) · Pagination · SkipLink · Tabs (.Button, .List, .Panel) · TableOfContents (.Link, .List)
Data Display
Accordion (.Section) · Card (.Heading, .HeadingGroup, .Image, .Link) · DescriptionList (.Description, .Section, .Term) · Figure (.Caption) · Table (.Body, .Caption, .Cell, .Footer, .Header, .HeaderCell, .Row) · ImageSlider
Feedback
Alert · Dialog (.open(), .close()) · Badge · Avatar
Utility
Icon · Logo · FileList (.Item) · OrderedList (.Item) · UnorderedList (.Item) · ProgressList (.Step, .Substep, .Substeps)
Grid System
The AMS grid is responsive with 3 breakpoints:
| Breakpoint | Columns | Viewport | Padding |
|---|---|---|---|
| Narrow | 4 | < 576px | --ams-space-l (24-36px) |
| Medium | 8 | 576px – 1023px | --ams-space-xl (36-60px) |
| Wide | 12 | ≥ 1024px | --ams-space-2xl (48-90px) |
Grid.Cell span prop
{/* Fixed span across all breakpoints */}
<Grid.Cell span={6}>Half width on wide</Grid.Cell>
{/* Full width */}
<Grid.Cell span="all">Full width row</Grid.Cell>
{/* Responsive spans: { narrow, medium, wide } */}
<Grid.Cell span={{ narrow: 4, medium: 4, wide: 8 }}>
Responsive content
</Grid.Cell>
{/* Start position */}
<Grid.Cell span={6} start={4}>Offset cell</Grid.Cell>
<Grid.Cell span={{ narrow: 4, medium: 6, wide: 8 }} start={{ narrow: 1, medium: 2, wide: 3 }}>
Responsive offset
</Grid.Cell>
Grid props
<Grid
as="main" // Semantic element
paddingVertical="large" // Vertical padding: 'large' | 'x-large' | '2x-large'
gapVertical="large" // Row gap: 'none' | 'large' | '2x-large'
>
Design Tokens
Full token catalog: read
references/tokens.md
The token system uses a 3-layer hierarchy. All tokens are CSS custom properties prefixed with --ams-.
Brand tokens → Common tokens → Component tokens
(core values) (shared patterns) (per-component)
Reference chain example:
Brand: --ams-color-interactive-default: #004699
Common: --ams-links-color: var(--ams-color-interactive-default)
Component: --ams-link-color: var(--ams-links-color)
Key Token Categories
| Category | Prefix | Examples |
|---|---|---|
| Colors | --ams-color- | text, text-inverse, text-secondary, background, interactive, interactive-hover, feedback-error, feedback-success, separator |
| Spacing | --ams-space- | xs (4-6px), s (8-12px), m (16-24px), l (24-36px), xl (36-60px), 2xl (48-90px) — all fluid clamp() |
| Typography | --ams-typography- | font-family ('Amsterdam Sans', Arial, sans-serif), body-text-font-size, body-text-line-height, heading sizes per level |
| Borders | --ams-border-width- | s (1px), m (2px), l (3px), xl (4px) |
| Focus | --ams-focus- | outline-offset (4px) |
Using Tokens in CSS
.my-component {
color: var(--ams-color-text);
background: var(--ams-color-background);
padding: var(--ams-space-m);
font-family: var(--ams-typography-font-family);
border: var(--ams-border-width-s) solid var(--ams-color-separator);
}
Using Tokens in JS
import tokens from "@amsterdam/design-system-tokens/dist/index.json"
const primaryColor = tokens.ams.color.interactive.default // "#004699"
Spacious vs Compact
| Aspect | Spacious (default) | Compact |
|---|---|---|
| Use for | Public websites | Internal tools, dashboards |
| Body text | 18-20px fluid | 16px fixed |
| H1 | 32-48px fluid | 24-28px fluid |
| Line height | 1.8 | 1.5 |
| Space m | 16-24px fluid | 12-16px fluid |
| Space 2xl | 48-90px fluid | 32-48px fluid |
| Borders | Thicker (m=2px, xl=4px) | Thinner (m=1px, xl=3px) |
Decision rule: Public-facing site → Spacious. Back-office/admin/dashboard → Compact.
Setup difference — one extra import:
// Spacious (default)
import "@amsterdam/design-system-tokens/dist/index.css"
// Compact (add after tokens)
import "@amsterdam/design-system-tokens/dist/index.css"
import "@amsterdam/design-system-tokens/dist/compact.css"
Compact overrides the same CSS custom properties with smaller values. No code changes needed — components adapt automatically.
Router Integration
AMS Link components render <a> by default. For SPA routing, use polymorphic rendering:
Next.js (App Router)
import NextLink from "next/link"
import { Link, Breadcrumb, Pagination } from "@amsterdam/design-system-react"
{/* Regular link */}
<Link href="/about" legacyBehavior passHref>
<NextLink>Over ons</NextLink>
</Link>
{/* Or simpler: just use Next.js Link with AMS classes */}
<NextLink href="/about" className="ams-link">Over ons</NextLink>
{/* Pagination with router links */}
<Pagination
totalPages={10}
page={currentPage}
linkTemplate={(page) => `/results?page=${page}`}
linkComponent={NextLink}
/>
{/* PageHeader logo */}
<PageHeader
brandName="Mijn Amsterdam"
logoLink="/"
logoLinkComponent={NextLink}
/>
React Router
import { Link as RouterLink } from "react-router-dom"
import { Link } from "@amsterdam/design-system-react"
<RouterLink to="/about" className="ams-link">Over ons</RouterLink>
Tailwind v4 Integration
Full bridge config: read
references/tailwind-bridge.md
When using Tailwind CSS v4 alongside AMS, map AMS tokens to Tailwind's @theme so utilities use the design system values:
/* app.css */
@import "tailwindcss";
@import "@amsterdam/design-system-assets/font/index.css";
@import "@amsterdam/design-system-css/dist/index.css";
@import "@amsterdam/design-system-tokens/dist/index.css";
/* Disable Tailwind's preflight — AMS CSS handles base styles */
@layer base {
/* AMS body styles take precedence */
}
@theme {
/* Map AMS spacing */
--spacing-ams-xs: var(--ams-space-xs);
--spacing-ams-s: var(--ams-space-s);
--spacing-ams-m: var(--ams-space-m);
--spacing-ams-l: var(--ams-space-l);
--spacing-ams-xl: var(--ams-space-xl);
--spacing-ams-2xl: var(--ams-space-2xl);
/* Map AMS colors */
--color-ams-text: var(--ams-color-text);
--color-ams-text-secondary: var(--ams-color-text-secondary);
--color-ams-text-inverse: var(--ams-color-text-inverse);
--color-ams-bg: var(--ams-color-background);
--color-ams-interactive: var(--ams-color-interactive);
--color-ams-interactive-hover: var(--ams-color-interactive-hover);
--color-ams-error: var(--ams-color-feedback-error);
--color-ams-success: var(--ams-color-feedback-success);
--color-ams-warning: var(--ams-color-feedback-warning);
--color-ams-info: var(--ams-color-feedback-info);
--color-ams-separator: var(--ams-color-separator);
/* Map AMS font */
--font-ams: var(--ams-typography-font-family);
}
Usage rule: Use AMS React components for all standard UI (buttons, forms, headings, grids, etc.). Use Tailwind utilities only for custom layout (flex, positioning) and one-off spacing that AMS components don't cover.
{/* AMS component — always preferred */}
<Button variant="primary">Submit</Button>
{/* Tailwind for custom layout around AMS components */}
<div className="flex items-center gap-ams-m">
<Icon svg={SearchIcon} />
<Paragraph>Search results</Paragraph>
</div>
Custom Components
When building components not in the AMS library, follow these conventions:
BEM Naming
/* Block: ams-status-badge */
.ams-status-badge { }
.ams-status-badge--active { }
.ams-status-badge__icon { }
.ams-status-badge__label { }
Token-Only Styling
.ams-status-badge {
display: inline-flex;
align-items: center;
gap: var(--ams-space-xs);
padding-block: var(--ams-space-xs);
padding-inline: var(--ams-space-s);
font-family: var(--ams-typography-font-family);
font-size: var(--ams-typography-body-text-small-font-size);
line-height: var(--ams-typography-body-text-small-line-height);
border: var(--ams-border-width-s) solid var(--ams-color-separator);
/* NO hardcoded colors, sizes, or spacing */
}
Component Pattern
import { forwardRef } from "react"
import clsx from "clsx"
export interface StatusBadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
status: "active" | "inactive" | "pending"
}
export const StatusBadge = forwardRef<HTMLSpanElement, StatusBadgeProps>(
({ status, className, children, ...restProps }, ref) => (
<span
ref={ref}
className={clsx("ams-status-badge", `ams-status-badge--${status}`, className)}
{...restProps}
>
{children}
</span>
)
)
StatusBadge.displayName = "StatusBadge"
Checklist for Custom Components
- Uses
forwardRef - Extends relevant HTML element attributes
- Uses
clsxfor className composition - Spreads
...restPropson root element - BEM classes with
ams-prefix - All styling via
--ams-*tokens (no hardcoded values) - Sets
displayName
TypeScript Patterns
Prop Types
// Intersect with HTML attributes
interface MyComponentProps extends React.HTMLAttributes<HTMLDivElement> {
variant: "primary" | "secondary"
}
// For form elements
interface MyInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
invalid?: boolean
}
Compound Component Export
// Following AMS pattern with Object.assign
const ListRoot = forwardRef<HTMLUListElement, ListProps>(/* ... */)
const ListItem = forwardRef<HTMLLIElement, ListItemProps>(/* ... */)
export const List = Object.assign(ListRoot, { Item: ListItem })
// Usage: <List><List.Item>...</List.Item></List>
Common Mistakes
| Mistake | Fix |
|---|---|
| Wrong import order | Fonts → CSS → Tokens (always) |
Missing ams-body class | Add to <body> or root element |
Hardcoded colors (#004699) | Use var(--ams-color-interactive) |
Hardcoded spacing (16px) | Use var(--ams-space-m) |
Using font-weight: 700 for bold | Use 800 or var(--ams-typography-body-text-bold-font-weight) |
Setting font-size: 62.5% on html | Don't — AMS uses rem values calibrated to 16px base |
Missing invalid prop on both Field and input | Both <Field invalid> and <TextInput invalid> need it |
Using <h1> instead of <Heading level={1}> | Always use AMS Heading component |
Missing aria-controls on Tabs.Button | Required prop — must match Tabs.Panel id |
Using Tailwind bg-blue-500 instead of AMS tokens | Use bg-ams-interactive or AMS component |
Icon Usage
Full icon catalog and naming conventions: read
references/icons.md
Icons are visual symbols for quick communication. They must always be wrapped in the Icon component for consistent sizing and alignment. The icon set ships in @amsterdam/design-system-react-icons (345+ icons, v2.0.0+).
Basic Usage
import { Icon, Button, IconButton } from "@amsterdam/design-system-react"
import { SearchIcon, CloseIcon, NotificationIcon } from "@amsterdam/design-system-react-icons"
{/* Standalone decorative icon — hidden from assistive tech by default */}
<Icon svg={SearchIcon} />
{/* Sized to match text */}
<Icon svg={SearchIcon} size="large" /> {/* matches large body text */}
<Icon svg={SearchIcon} size="heading-3" /> {/* matches heading level 3 */}
{/* Inverse color for dark backgrounds */}
<Icon svg={SearchIcon} color="inverse" />
{/* Square bounding box (useful for alignment in grids) */}
<Icon svg={SearchIcon} square />
{/* Button with icon (icon appears after text by default) */}
<Button icon={SearchIcon}>Zoeken</Button>
<Button icon={SearchIcon} iconBefore>Zoeken</Button>
{/* Icon-only button — label is REQUIRED for accessibility */}
<IconButton svg={CloseIcon} label="Sluiten" />
Icon Props
| Prop | Type | Default | Description |
|---|---|---|---|
svg | Function | ReactNode | required | Icon component from the icon package or custom SVG |
size | 'small' | 'large' | 'heading-1' | 'heading-2' | 'heading-3' | 'heading-4' | 'heading-5' | — | Size aligned to text line heights |
color | 'inverse' | — | White icon for dark backgrounds |
square | boolean | false | Square bounding box |
Icons With Other Components
import { StandaloneLink, Badge } from "@amsterdam/design-system-react"
import { SearchIcon, StarIcon } from "@amsterdam/design-system-react-icons"
<StandaloneLink href="/search" icon={SearchIcon}>Zoek op de website</StandaloneLink>
<Badge label="Nieuw" icon={StarIcon} color="azure" />
v2.0.0 Renames (Breaking)
These icons were renamed in v2.0.0 — use the new names:
| Old name | New name |
|---|---|
BellIcon / BellFillIcon | NotificationIcon / NotificationFillIcon |
PersonCircleIcon / PersonCircleFillIcon | UserAccountIcon / UserAccountFillIcon |
TrashBinIcon | DeleteIcon |
CogwheelIcon | SettingsIcon |
CheckMarkCircleIcon | SuccessIcon |
Custom SVGs
{/* Must use viewBox="0 0 24 24" and fill="currentColor" */}
<Icon svg={
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
</svg>
} />
Guidelines
- Icons accompany text in buttons and links — standalone icons only for universal conventions (hamburger menu, search, playback controls)
- Default color: black/white matching container. Interactive state: blue. Disabled: grey
- Icons align left of text, vertically centered to the first line
- The
Iconcomponent setshiddenon the<span>— icons are decorative by default. For meaningful icons, useIconButtonwith alabelprop - WCAG contrast requirements apply to icons same as typography
Reference Files
For detailed API docs, token catalogs, and templates, read the reference files in references/:
components.md— Full props and code examples for each componenttokens.md— Complete--ams-*token catalog with values for both modeslayout-patterns.md— Page layout templates (public site, dashboard, form page)tailwind-bridge.md— Complete Tailwind v4 + AMS integration guideicons.md— Icon catalog from@amsterdam/design-system-react-icons