Skip to content

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.

TokenLight modeDark modeUse
bg-backgroundwhitenear-blackPage canvas
bg-cardwhiteelevatedCard, dialog, popover surfaces
bg-mutedlight graydark graySecondary surfaces, hover fills
bg-sidebarcool graydeeperNav chrome (icon rail, sidebar)
text-foregroundnear-blackwhitePrimary text
text-muted-foregroundmid graylight graySecondary text, metadata, timestamps
bg-primarybrand bluebrand bluePrimary action, success state, active
text-primary-foregroundwhitewhiteText on primary bg
bg-destructiveredredDestructive actions, error states
text-destructiveredredError text, error icons
border-borderlightdarkAll borders
ring-ringaccentaccentFocus ring

Semantic meaning (sacred — don’t customize)

MeaningTokenWhat it says
Primarybg-primary”This is the main action / active state”
Successbg-primary / bg-emerald-600 for checks”Complete, passing, enabled”
Warningtext-muted-foreground”Pending, in-progress, needs attention”
Errorbg-destructive”Failed, blocked, cannot proceed”
Infotext-foreground”Neutral information”

Color rules

  1. Raw hex or Tailwind color scale (bg-gray-100, text-red-500) is forbidden. Use semantic tokens.
  2. Charts and data viz are the only exception — categorical palettes are allowed there.
  3. Disabled states: opacity-50, not a different color. Never grey out entire cards.
  4. Contrast floor: WCAG AA (4.5:1) for all body text against its background. Check at both light and dark.
  5. 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.

RoleSizeComponentWeight
Page title24px<H1>600
Detail title20px<H2>600
Section heading16px<H3>600
Card/form title14px<H4>600
Minor heading12px<H5> / <H6>600
Body14px<Text> or text-sm500
Secondary14px<Text variant="muted">400
Label12px<Text variant="label"> / <Label>500
Caption / meta12px<Text variant="caption">400
Data / code14pxtext-sm font-mono400

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> in index.css and 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.

LevelValueTailwindUse
Small8pxgap-2, p-2, space-y-2Related items within a group (label↔field, icon↔text)
Medium16pxgap-4, p-4, space-y-4Items in the same section (form fields, card padding)
Large24pxgap-6, p-6, space-y-6Distinct sections (content sections, card groups)
Extra Large40pxgap-10, space-y-10Major 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

ContextClass
Page bar → contentHandled 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 → descriptionspace-y-2 (8px) inside <FormSection> header
Section description → first field24px gap (via <FormSection> internal spacing)
Between form fieldsspace-y-4 (16px)
Label → inputspace-y-1.5 (6px)
Table cell paddingpx-3 py-3
Card internalp-4
Button groupsgap-2
Sidebar item vertical gapgap-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.

SurfaceRow heightBase fontPaddingIcon
Admin tables40pxtext-smpx-3 py-2.516px
Admin list items40-48pxtext-smpx-3 py-216px
Admin form rowsautotext-smgap + space-y-416px
Admin form input32pxtext-smpx-3 py-116px
Admin sidebar32pxtext-smpx-3 py-1.516px
Chat messagesautotext-base (16px)p-420px
Chat thread list56pxtext-smpx-3 py-324px (avatar)
Embed widget48pxtext-smpx-3 py-2.516px

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.

LayerUseTailwind
0 (none)Cards at rest, resting surfacesno shadow, border only
1 (low)Card on hover (if whole card clickable)shadow-sm
2 (mid)Dropdowns, popovers, tooltipsshadow-md
3 (high)Dialogs, sheets, drawersshadow-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

ElementClassValue
Buttons, inputs, badges, tooltipsrounded-md6px
Cards, dialogs, sheets, popoversrounded-lg8px
Pills, chipsrounded-full9999
Avatarrounded-full9999
Icon buttons (ghost)rounded-md6px

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)

CategoryDurationEasingUsed for
Adding150-200msease-outElement enters scene (dialog open, dropdown)
Receding80-100msease-inElement leaves scene (dialog close, toast out)
Normal150-250msease-in-outElement 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

TokenDurationUse
Fast100msToggles, checkboxes, button hover, receding
Medium200msDropdowns, tooltips, color transitions, adding
Slow300msPanels, drawers, sidebar collapse, page slide

Motion rules

  1. Every animation names its category. “Why 200ms?” → “Adding, default adding tier.”
  2. Disappearing < appearing. Never the reverse.
  3. Use transitions to improve perceived performance: skeleton swap, optimistic update, progress bar.
  4. Animate one property per element. Don’t both slide and fade and scale a thing simultaneously.
  5. Respect prefers-reduced-motion. If set, drop to instantaneous. See 08-accessibility.md.
  6. 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 motionReduced-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 pulseStatic neutral fill (no pulse)
Spinner (loading)Static “Loading…” text with animated char ( cycling)
Toast slide-upFade-in, fixed position
Page transitionInstantaneous 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.

InteractionTimingCategory
Hover card open (pointer enter stable)200msAdding
Hover card close (pointer leave)100msReceding
Tooltip open (Radix default matches)400msAdding
Drawer / Sheet slide-in160ms ease-outAdding
Drawer / Sheet slide-out100ms ease-inReceding
Sidebar collapse / expand180ms ease-in-outNormal
Inline-edit commit → read-mode0ms + 200ms ✓ pulseAdding
Optimistic update → rollback deadlineup to 3s, then error
Dropdown menu / Popover open150msAdding
Dialog open180ms + overlay fadeAdding
Tab switch (within a tab bar)100ms color transitionNormal

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.

InteractionBudgetIf exceeded
Local state change (toggle, tab)≤16ms (1 frame)Never — local state is always instant
Hover feedback≤100msAlways — pseudo-class is synchronous
Route transition animation150-200msSee motion categories
Optimistic mutation feedback≤100msUI updates before network; rollback on error
Real mutation feedback (pending)≤500msShow skeleton, spinner, or inline pending
Page load with fetch≤500msShow skeleton within 500ms, real content ≤2s
White screen0msNever. 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).

NameMin widthTailwindTarget
Mobile0defaultChat, embed widget
Tablet768pxmd:Chat side-open
Desktop1024pxlg:Admin baseline
Wide1280pxxl:Admin dashboards
Ultrawide1536px2xl:Admin with chat split

Responsive rules

  • Admin: design for lg first. Below md, 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 at md and 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 — Plus left of the label (“New agent”).
  • The control is a status/state indicator (not a button) — the icon clarifies state at a glance (AlertTriangle on a warning, CheckCircle on 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

ContextSizeClass
Button / input inline16pxh-4 w-4
Icon-only button (small)14pxh-3.5 w-3.5
Sidebar nav16pxh-4 w-4
Table row action16pxh-4 w-4
Toolbar action16pxh-4 w-4
Empty state illustration40px+h-10 w-10 or bigger
Icon rail (L1 nav)20pxh-5 w-5
Chat avatar24pxh-6 w-6

Rules

  1. Decorative icon → aria-hidden="true" (most icons).
  2. Icon with no visible label → needs an accessible name via aria-label or wrapping tooltip.
  3. Match icon weight across a screen — don’t mix stroke thicknesses. lucide is uniform by default; just don’t override.
  4. Use the same icon for the same concept across the app. Search is always Search. Delete is always Trash (lucide Trash not Trash2 — pick one, stick to it).
  5. Color by context: text-muted-foreground for secondary, text-destructive for destructive, text-foreground for primary. Never random hues.
  6. Icon + label: 8px gap (gap-2). Icon on the left.

Iconography glossary (stick to these mappings)

ConceptIcon
Add / createPlus
EditPencil
Delete / removeTrash
Confirm / saveCheck
Cancel / closeX
SearchSearch
FilterFilter
SortArrowUpDown
CopyCopy
LinkLink
External linkExternalLink
More actionsMoreHorizontal
InfoInfo
WarningAlertTriangle
ErrorAlertCircle / CircleAlert
SuccessCheckCircle / CircleCheck
LoadingLoader2 (spinning)
SettingsSettings
UserUser
LogoutLogOut
HelpHelpCircle
Keyboard shortcutsKeyboard

Adding a new concept-to-icon mapping is a design-spec change, not an ad-hoc call.


Z-index

Layerz-indexUse
Content0Everything in the page flow
Sticky page bar10Only if page bar is within content (usually isn’t — it’s shell)
Dropdown / popover / tooltip50Radix default
Dialog overlay50Radix default
Dialog content50Radix default
Toast (sonner)100Above everything

Don’t invent new z-indexes. Radix handles stacking correctly via portal + focus trap.


Cursor

  • Buttons always render with cursor-pointer (our Button component 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-grab resting, cursor-grabbing active.
  • Never cursor-none.

Next: 03-layout.md.