02 - Foundations
Browse icons live → Storybook · Foundations / Icon Browser. Search 5,000+ Tabler icons and click to copy the import.
Tokens, scales, and contracts. Every visual decision traces back here.
Nothing in this doc is decorative. Every number is a load-bearing constraint. Deviating means proving the existing number is wrong.
Color
Semantic tokens only
We use Tailwind-variable semantic tokens. Never raw Tailwind colors like text-gray-500, bg-blue-600. The tokens adapt to light/dark mode; raw colors don’t.
| Token | Light mode | Dark mode | Use |
|---|---|---|---|
bg-background | white | near-black | Page canvas |
bg-card | white | elevated | Card, dialog, popover surfaces |
bg-muted | light gray | dark gray | Secondary surfaces, hover fills |
bg-sidebar | cool gray | deeper | Nav chrome (icon rail, sidebar) |
text-foreground | near-black | white | Primary text |
text-muted-foreground | mid gray | light gray | Secondary text, metadata, timestamps |
bg-primary | brand blue | brand blue | Primary action, success state, active |
text-primary-foreground | white | white | Text on primary bg |
bg-destructive | red | red | Destructive actions, error states |
text-destructive | red | red | Error text, error icons |
border-border | light | dark | All borders |
ring-ring | accent | accent | Focus ring |
Semantic meaning (sacred — don’t customize)
| Meaning | Token | What it says |
|---|---|---|
| Primary | bg-primary | ”This is the main action / active state” |
| Success | bg-primary / bg-emerald-600 for checks | ”Complete, passing, enabled” |
| Warning | text-muted-foreground | ”Pending, in-progress, needs attention” |
| Error | bg-destructive | ”Failed, blocked, cannot proceed” |
| Info | text-foreground | ”Neutral information” |
Color rules
- Raw hex or Tailwind color scale (
bg-gray-100,text-red-500) is forbidden. Use semantic tokens. - Charts and data viz are the only exception — categorical palettes are allowed there.
- Disabled states:
opacity-50, not a different color. Never grey out entire cards. - Contrast floor: WCAG AA (4.5:1) for all body text against its background. Check at both light and dark.
- On neutral decisions (Accept/Reject, Approve/Deny), both buttons are
variant="outline"— do not lead the user.
Dark mode parity
Every screen must look good in both modes. If you hardcode bg-white, dark mode breaks. Test both before shipping. The tokens above take care of this automatically.
Typography
Scale — typography primitives
Base is 14px (text-sm). This is an admin app — users live in it. 14 feels cramped until you use it for a week; then 16 feels bloated.
Use <H1>–<H6> and <Text> from @na/ui/components/typography. Don’t write raw <h2 className="text-xl font-semibold"> — the primitives encode the design system.
| Role | Size | Component | Weight |
|---|---|---|---|
| Page title | 24px | <H1> | 600 |
| Detail title | 20px | <H2> | 600 |
| Section heading | 16px | <H3> | 600 |
| Card/form title | 14px | <H4> | 600 |
| Minor heading | 12px | <H5> / <H6> | 600 |
| Body | 14px | <Text> or text-sm | 500 |
| Secondary | 14px | <Text variant="muted"> | 400 |
| Label | 12px | <Text variant="label"> / <Label> | 500 |
| Caption / meta | 12px | <Text variant="caption"> | 400 |
| Data / code | 14px | text-sm font-mono | 400 |
Chat app (end-user, not admin) can step up one size: body = 16px, headings = 18-20px. See density contract below.
Three weights
- 400 (Regular) — descriptions, timestamps, metadata, secondary text
- 500 (Medium) — body text (the global default), sidebar items, form values. This is set on
<body>inindex.cssand produces the slightly-thicker reading weight that Attio uses across all body copy. - 600 (Semibold) — headings, labels, emphasis, the single focal element per row
font-bold (700) is banned. If you need more emphasis than semibold gives, the problem isn’t weight — it’s that you’re trying to promote too many things. See A3 Contrast in 01-principles.md.
Font smoothing
The global stylesheet applies -webkit-font-smoothing: antialiased and -moz-osx-font-smoothing: grayscale on all elements. This produces the thin, precise character rendering that Attio uses. Do not remove or override these. Without antialiased smoothing, Inter at weight 500 looks bloated on macOS.
Numbers
Numbers in tables and stats use tabular-nums so columns align vertically. Right-align numeric columns. Keep decimal places consistent within a column.
<td className="text-sm tabular-nums text-right">1,234.56</td><td className="text-sm tabular-nums text-right"> 0.04</td>Tabular-nums extends beyond tables. Use it anywhere numbers change in-place and jitter would distract: counter badges, file-size chips, percentages in progress bars, versioned timestamps (v1.4.3), countdowns, keystroke latency displays. Rule of thumb: if a number swaps under the user’s eye, tabular-nums.
Readable line length
Body text wraps at 60–80 characters. Beyond that, the eye struggles to track. <PageContent narrow> hits 60ch at the 2xl max-width. For loose prose (help text, descriptions inside a wide container), constrain with max-w-prose (65ch) — don’t let descriptions run the full width of a 1200px page.
Copywriting punctuation (from Ant)
- Labels, buttons, tooltips, table headers: no trailing period.
- Sentences in body/description text: period is fine.
- Exclamation marks: greetings and congratulations only (“Welcome back!”, “Reloaded!”).
Full copy rules in 07-voice.md.
Spacing — the 8px ladder
Ant-style. y = 8 + 8n. Four canonical sizes.
| Level | Value | Tailwind | Use |
|---|---|---|---|
| Small | 8px | gap-2, p-2, space-y-2 | Related items within a group (label↔field, icon↔text) |
| Medium | 16px | gap-4, p-4, space-y-4 | Items in the same section (form fields, card padding) |
| Large | 24px | gap-6, p-6, space-y-6 | Distinct sections (content sections, card groups) |
| Extra Large | 40px | gap-10, space-y-10 | Major section breaks on settings/form pages |
When to use Extra Large (40px): Attio uses 40–60px of whitespace between major settings sections (e.g., between “Voice” and “RAG” config groups). This macro-whitespace creates visual grouping without needing <Separator> lines. Use space-y-10 (40px) between <FormSection> blocks on pages that use PageContent narrow. On full-width list/dashboard pages, space-y-6 (24px) is sufficient.
Internal fine-tuning on the 4px subgrid is acceptable: gap-1 (4px), gap-1.5 (6px), gap-3 (12px). Off-grid values (p-5, p-7, gap-3.5) are forbidden.
Row-action icon buttons sit on a 4–6px gap (gap-1 or gap-1.5) — the 8px ladder is too loose for icon-xs (24px) buttons that need to read as a single row-level action cluster. Max three per row (see 03-layout.md § Action placement); beyond three, collapse into a <DropdownMenu>.
Canonical contexts
| Context | Class |
|---|---|
| Page bar → content | Handled by <PageContent> — p-4 md:p-6 |
| Between major sections (full-width) | <PageContent spaced> → space-y-6 (24px) |
| Between major sections (narrow/form) | space-y-10 (40px) — Attio settings rhythm |
| Section title → description | space-y-2 (8px) inside <FormSection> header |
| Section description → first field | 24px gap (via <FormSection> internal spacing) |
| Between form fields | space-y-4 (16px) |
| Label → input | space-y-1.5 (6px) |
| Table cell padding | px-3 py-3 |
| Card internal | p-4 |
| Button groups | gap-2 |
| Sidebar item vertical gap | gap-0.5 |
What NOT to do
<div className="space-y-4">for form sections → use<FormSection>.<div className="grid grid-cols-3 gap-4">for card grids → use<CardGrid columns={3}>.- Layout components own the spacing. App code doesn’t set padding on outer containers.
Density contract
One number changes per surface: row height. Everything else (font size, padding, icon size) is derived.
| Surface | Row height | Base font | Padding | Icon |
|---|---|---|---|---|
| Admin tables | 40px | text-sm | px-3 py-2.5 | 16px |
| Admin list items | 40-48px | text-sm | px-3 py-2 | 16px |
| Admin form rows | auto | text-sm | gap + space-y-4 | 16px |
| Admin form input | 32px | text-sm | px-3 py-1 | 16px |
| Admin sidebar | 32px | text-sm | px-3 py-1.5 | 16px |
| Chat messages | auto | text-base (16px) | p-4 | 20px |
| Chat thread list | 56px | text-sm | px-3 py-3 | 24px (avatar) |
| Embed widget | 48px | text-sm | px-3 py-2.5 | 16px |
Rule: admin = dense, chat = warm. Don’t mix: admin pages don’t suddenly get chat padding, and chat bubbles don’t suddenly get admin compactness. The tokens stay the same; the density tier changes.
Elevation
Four layers. Use consistently.
| Layer | Use | Tailwind |
|---|---|---|
| 0 (none) | Cards at rest, resting surfaces | no shadow, border only |
| 1 (low) | Card on hover (if whole card clickable) | shadow-sm |
| 2 (mid) | Dropdowns, popovers, tooltips | shadow-md |
| 3 (high) | Dialogs, sheets, drawers | shadow-lg |
Rules:
- Cards at rest have border only, no shadow. Shadow attracts attention; a resting card shouldn’t.
- Drawers/sheets cast shadow away from the edge they enter from.
- Don’t stack shadows. One elevation per element.
Radius
| Element | Class | Value |
|---|---|---|
| Buttons, inputs, badges, tooltips | rounded-md | 6px |
| Cards, dialogs, sheets, popovers | rounded-lg | 8px |
| Pills, chips | rounded-full | 9999 |
| Avatar | rounded-full | 9999 |
| Icon buttons (ghost) | rounded-md | 6px |
No element uses rounded-xl (12px) or larger — we’re not a landing page.
Motion — taxonomy + budget
Every animation declares itself as one of three categories. No undeclared motion.
The three categories (Ant)
| Category | Duration | Easing | Used for |
|---|---|---|---|
| Adding | 150-200ms | ease-out | Element enters scene (dialog open, dropdown) |
| Receding | 80-100ms | ease-in | Element leaves scene (dialog close, toast out) |
| Normal | 150-250ms | ease-in-out | Element moves/morphs within scene (tab switch, hover, tooltip) |
Receding is faster than Adding. Disappearing elements must not attract attention; arriving elements deserve a beat.
Duration tokens
| Token | Duration | Use |
|---|---|---|
| Fast | 100ms | Toggles, checkboxes, button hover, receding |
| Medium | 200ms | Dropdowns, tooltips, color transitions, adding |
| Slow | 300ms | Panels, drawers, sidebar collapse, page slide |
Motion rules
- Every animation names its category. “Why 200ms?” → “Adding, default adding tier.”
- Disappearing < appearing. Never the reverse.
- Use transitions to improve perceived performance: skeleton swap, optimistic update, progress bar.
- Animate one property per element. Don’t both slide and fade and scale a thing simultaneously.
- Respect
prefers-reduced-motion. If set, drop to instantaneous. See 08-accessibility.md. - A menu chevron rotates. That’s the whole animation. Don’t add a bounce, a morph, or a fade.
Reduced-motion fallbacks
When prefers-reduced-motion: reduce, swap each animation category to its cheapest readable fallback. Never drop motion to literally zero if the user needs state-change signal — trade movement for opacity instead.
| Original motion | Reduced-motion fallback |
|---|---|
| Slide-in (drawer, sheet) | Fade-in (opacity 0 → 1, 100ms) |
| Transform scale (zoom) | Opacity transition only |
| Chevron rotate (menu) | Instantaneous (no transition) |
| Skeleton pulse | Static neutral fill (no pulse) |
| Spinner (loading) | Static “Loading…” text with animated char (… cycling) |
| Toast slide-up | Fade-in, fixed position |
| Page transition | Instantaneous swap |
The rule: information survives motion loss. If removing the animation makes a state change invisible, the fallback is wrong.
Attio timing reference
Live-measured across Attio (April 2026). Use these as defaults when matching a pattern; deviate with justification.
| Interaction | Timing | Category |
|---|---|---|
| Hover card open (pointer enter stable) | 200ms | Adding |
| Hover card close (pointer leave) | 100ms | Receding |
| Tooltip open (Radix default matches) | 400ms | Adding |
| Drawer / Sheet slide-in | 160ms ease-out | Adding |
| Drawer / Sheet slide-out | 100ms ease-in | Receding |
| Sidebar collapse / expand | 180ms ease-in-out | Normal |
| Inline-edit commit → read-mode | 0ms + 200ms ✓ pulse | Adding |
| Optimistic update → rollback deadline | up to 3s, then error | — |
| Dropdown menu / Popover open | 150ms | Adding |
| Dialog open | 180ms + overlay fade | Adding |
| Tab switch (within a tab bar) | 100ms color transition | Normal |
Anything slower than 300ms on a content-level interaction feels sluggish. Anything faster than 80ms on an arriving element feels like a teleport.
Latency budget
The rule that makes the product feel fast.
| Interaction | Budget | If exceeded |
|---|---|---|
| Local state change (toggle, tab) | ≤16ms (1 frame) | Never — local state is always instant |
| Hover feedback | ≤100ms | Always — pseudo-class is synchronous |
| Route transition animation | 150-200ms | See motion categories |
| Optimistic mutation feedback | ≤100ms | UI updates before network; rollback on error |
| Real mutation feedback (pending) | ≤500ms | Show skeleton, spinner, or inline pending |
| Page load with fetch | ≤500ms | Show skeleton within 500ms, real content ≤2s |
| White screen | 0ms | Never. Ever. Skeleton on mount, always. |
Rule: the user sees feedback within 100ms. If the real work takes longer, something visual appears within 500ms. Silence is a bug.
Latency → feedback strategy
Decision tree for “how should this mutation feel”:
Click triggers mutation├── Expected <100ms P95 (e.g. toggle, select) → OPTIMISTIC UPDATE, no spinner│ └── On error: rollback + inline error next to the thing├── Expected 100-500ms P95 (typical API call) → <ActionButton> pending state│ └── Button enters "pending", click is disabled, success fades to idle├── Expected 500ms-30s (long form submit, upload) → inline progress│ └── Progress bar or <MutationStatus> with percentage / stage name└── Expected >30s (batch job, indexing, deploy) → background + toast └── "Started — we'll notify you when it's done." User moves on.The mapping is observable, not subjective. Measure a P95 latency on staging before picking. Wrong-fit feedback (spinner on a 50ms toggle) is worse than no feedback.
Breakpoints
Admin is desktop-first (the users have 27” monitors). Chat is mobile-aware (end users on phones).
| Name | Min width | Tailwind | Target |
|---|---|---|---|
| Mobile | 0 | default | Chat, embed widget |
| Tablet | 768px | md: | Chat side-open |
| Desktop | 1024px | lg: | Admin baseline |
| Wide | 1280px | xl: | Admin dashboards |
| Ultrawide | 1536px | 2xl: | Admin with chat split |
Responsive rules
- Admin: design for
lgfirst. Belowmd, degrade gracefully (sidebar becomes sheet, tables get horizontal scroll). Nobody administers from a phone; degrade, don’t duplicate. - Chat: design mobile-first. Stack above
md, split atmdand up. - Do not design separate mobile and desktop layouts. Use the same layout; adjust density.
- Icon rail hides below
md(sheet drawer instead).
Icons
Library: lucide-react (import { IconName } from 'lucide-react'). Never inline SVG for decorative icons. Never use emoji as UI icons (emoji are fine for empty-state illustration, playfully).
When to use an icon
Default position: don’t add an icon. Labels carry meaning; icons only earn their place when they add scannability or hazard signal that the label alone misses.
USE an icon when at least one applies:
- The action is on the iconography glossary below AND lives in a tight zone (toolbar, table row, page bar action) — typically as icon-only with tooltip per 03-layout.md § Action placement.
- The action is destructive — the icon reinforces the hazard (
Trash,X). - The action is the primary “create new” affordance —
Plusleft of the label (“New agent”). - The control is a status/state indicator (not a button) — the icon clarifies state at a glance (
AlertTriangleon a warning,CheckCircleon success). - The icon disambiguates two visually-similar adjacent labels (e.g., “Send” vs. “Schedule send” with a clock icon).
DO NOT use an icon when:
- The label is already 1–2 words and the action is mundane:
Save,Cancel,Apply,Continue,OK is forbidden anyway, dialog footer buttons. Adding an icon doubles weight without adding clarity. - The icon is not in the glossary AND the action is once-on-the-page. Inventing icons fragments the visual language.
- The icon is “decoration” of a section heading. Section identity comes from the title text; an icon next to “Settings” or “Voice” is redundant chrome.
- The icon would precede body copy or a paragraph. Body text is read; icons are scanned. Mixing them disrupts both modes.
- Inline-edit fields, form labels, and list-row text never carry icons.
- The same row already shows an icon for a related concept. Two icons in a row halve scannability — pick the one that matters and drop the other.
Tie-breaker. If you’re unsure, leave the icon off and ship the label-only version. Adding an icon later if scannability suffers is cheap; removing one after it ships is friction.
Sizing
| Context | Size | Class |
|---|---|---|
| Button / input inline | 16px | h-4 w-4 |
| Icon-only button (small) | 14px | h-3.5 w-3.5 |
| Sidebar nav | 16px | h-4 w-4 |
| Table row action | 16px | h-4 w-4 |
| Toolbar action | 16px | h-4 w-4 |
| Empty state illustration | 40px+ | h-10 w-10 or bigger |
| Icon rail (L1 nav) | 20px | h-5 w-5 |
| Chat avatar | 24px | h-6 w-6 |
Rules
- Decorative icon →
aria-hidden="true"(most icons). - Icon with no visible label → needs an accessible name via
aria-labelor wrappingtooltip. - Match icon weight across a screen — don’t mix stroke thicknesses. lucide is uniform by default; just don’t override.
- Use the same icon for the same concept across the app. Search is always
Search. Delete is alwaysTrash(lucideTrashnotTrash2— pick one, stick to it). - Color by context:
text-muted-foregroundfor secondary,text-destructivefor destructive,text-foregroundfor primary. Never random hues. - Icon + label: 8px gap (
gap-2). Icon on the left.
Iconography glossary (stick to these mappings)
| Concept | Icon |
|---|---|
| Add / create | Plus |
| Edit | Pencil |
| Delete / remove | Trash |
| Confirm / save | Check |
| Cancel / close | X |
| Search | Search |
| Filter | Filter |
| Sort | ArrowUpDown |
| Copy | Copy |
| Link | Link |
| External link | ExternalLink |
| More actions | MoreHorizontal |
| Info | Info |
| Warning | AlertTriangle |
| Error | AlertCircle / CircleAlert |
| Success | CheckCircle / CircleCheck |
| Loading | Loader2 (spinning) |
| Settings | Settings |
| User | User |
| Logout | LogOut |
| Help | HelpCircle |
| Keyboard shortcuts | Keyboard |
Adding a new concept-to-icon mapping is a design-spec change, not an ad-hoc call.
Z-index
| Layer | z-index | Use |
|---|---|---|
| Content | 0 | Everything in the page flow |
| Sticky page bar | 10 | Only if page bar is within content (usually isn’t — it’s shell) |
| Dropdown / popover / tooltip | 50 | Radix default |
| Dialog overlay | 50 | Radix default |
| Dialog content | 50 | Radix default |
| Toast (sonner) | 100 | Above everything |
Don’t invent new z-indexes. Radix handles stacking correctly via portal + focus trap.
Cursor
- Buttons always render with
cursor-pointer(ourButtoncomponent handles this — browsers don’t by default). - Disabled buttons always render with
cursor-not-allowed. - Clickable rows:
cursor-pointer. - Text-input-like elements: default (
cursor-text). - Draggable elements:
cursor-grabresting,cursor-grabbingactive. - Never
cursor-none.
Next: 03-layout.md.