---
title: "craft skill: look designed, not generated"
description: "The void visual system. Color, typography, layout, spacing, radius, shadows, hairlines, focus rings, texture, imagery and icons for websites and web apps…"
canonical: https://void-design.vercel.app/docs/craft
lastModified: 2026-09-16
---

# craft — look designed, not generated

Every rule here comes from measured teardowns of 24 top sites (Linear, Vercel, Stripe, Resend, Granola, hellohello, …) or from bugs in shipped apps.
Workflow lives in `website`. Motion in `motion`. Performance in `speed`. Accessibility in `a11y`.

## The 15 non-negotiables

1. **One direction per project.** Import exactly one `directions/<name>.css`. Never mix two directions' fonts, radii or motion.
2. **Semantic token utilities only.** `bg-surface`, `text-fg-muted`, `border-line`, `bg-brand`. No hex, no `bg-zinc-900`, no `text-[#777]` in components (`lint/hardcoded-colors`). The default palette is removed, so raw palette classes emit nothing.
3. **One accent, rationed to 1–5% of the page.** Brand color only on: the primary CTA, links, focus, the active state, selection, and at most one highlight. Measured: Vercel's blue on 2 elements, Anthropic's clay on 1 (`design/multiple-accents`).
4. **No pure `#000` text on `#fff`.** Neutrals are tinted (the tokens already do this). Exception: monochrome-brutal brands (hellohello, basement) and `#000` canvas only when surfaces step up to ≥ L 18% with visible hairlines.
5. **Four text tiers, no more:** `text-fg` (headings, body), `text-fg-muted` (secondary), `text-fg-subtle` (meta, captions, placeholders — AA 4.5:1, never paragraphs), `text-fg-faint` (disabled controls, decorative/`aria-hidden` text only, 3:1 — never text someone must read).
6. **Display type is tight.** `text-display*` tokens bake line-height 0.92–1.04 and negative tracking (−0.028 to −0.045em × direction knob). Never add `leading-normal`, `tracking-normal` or `font-bold` on top (`design/heading-tracking`).
7. **Display weight 400–600.** 18 of 23 measured H1s are ≤ 600. 700 is allowed only when the direction sets it (`swiss`) or the brand face is built for it.
8. **Workhorse UI text is 13px (`text-sm`); body is 15–16px (`text-base`/`text-md`); long reads 17–19px (`text-lg`).** Real sites run on 13–14px UI + 12px mono texture, not 16px everywhere.
9. **Max 2 families + 1 mono, ≤ 4 font files, `next/font` only** (`design/too-many-font-families`, `perf/font-budget`).
10. **4px spacing grid.** Allowed steps: 1 2 3 4 5 6 8 10 12 16 20 24 32 40 (×4px). No `p-[13px]` (`design/spacing-off-grid`). Space between groups ≥ 2× space within a group.
11. **Left-align by default.** ≤ 25% of h1–h3 centered (14 of 18 sites). Center only a short hero lockup or a closing CTA.
12. **Radius comes from the `--radius` knob.** `rounded-xs … rounded-3xl`. Cards ≤ `rounded-xl`; 16px+ only for device frames and media. Nested corners concentric (`radius-nested`) (`design/radius-sprawl`).
13. **One separation method per element:** border **or** shadow **or** background shift. Shadows are `shadow-1/2/3` only (layered, low alpha, ring included).
14. **Visible, double-contrast focus.** The base layer ships `:focus-visible { outline: 2px solid var(--line-focus); outline-offset: 2px }`. Never `outline-none` without a replacement (`lint/outline-none-no-replacement`, `a11y/focus-not-visible`).
15. **Run the anti-slop checklist (below) on screenshots at 1440 and 390 before calling anything done.**

## 1. Direction

Directions live in `packages/tokens/css/directions/` (copied into a project at `src/styles/void/directions/`). Each sets fonts, radius, display weight/tracking, durations, and light + dark color roles.

| Direction | Pick for | Scheme | Fonts | Radius | Feel |
|---|---|---|---|---|---|
| `precision` | dev tools, AI/infra, pro SaaS, technical launches | dark | Geist + Geist Mono | 6px | Linear/Vercel: hairlines, mono labels, indigo signal |
| `editorial` | essays, research, studios, premium brands, long docs | light | Newsreader + IBM Plex Mono | 2px | paper, ink, oxblood red pencil, rules not boxes |
| `swiss` | agencies, architecture, events, portfolios, data journalism | light | Schibsted Grotesk + JetBrains Mono | 0 | poster grid, black rules, signal red |
| `warm` | consumer SaaS, notes, education, health, community | light | Bricolage Grotesque + Figtree | 10px | notebook app, clay accent, soft warm shadows |
| `terminal` | CLIs, infra, hacker tools, dev launch pages | dark | Geist Mono only | 0 | graphite, amber phosphor, hard edges |
| `instrument` | dashboards, admin, finance, ops, data docs | system | IBM Plex Sans + Plex Mono | 6px | dense, tabular, chart-grade blue |

Unsure and the product is technical: `precision`. Unsure and it is content: `editorial`. Full specs, next/font snippets, signature details and example heroes: `references/directions.md`.

Switching direction = 3 edits that must agree: the `@import` in `src/app/globals.css`, the `next/font` loaders in `src/app/layout.tsx` (variable names are in the direction file header), and `direction` in `src/site.config.ts`.

## 2. Color

- Components reference roles: surfaces `bg-bg` `bg-bg-subtle` `bg-surface` `bg-surface-raised` `bg-surface-hover` `bg-surface-active`; lines `border-line-subtle` `border-line` `border-line-strong`, focus `outline-line-focus`; text `text-fg` `text-fg-muted` `text-fg-subtle` `text-fg-on-brand`; brand `bg-brand` `hover:bg-brand-hover` `bg-brand-subtle` `border-brand-line` `text-brand-text`; status `text-success` `bg-danger-subtle` ….
- Text on a tinted surface uses that family's text token: `text-brand-text` on `bg-brand-subtle`, never `text-fg-muted`.
- Hover must **raise** contrast (ghost `text-fg-muted` → `hover:text-fg`), never lower it.
- Lines are alpha of the foreground (7–17%), so they work on every surface. Measured: Linear `rgba(255,255,255,.08)`, Vercel `rgba(0,0,0,.08)`.
- Dark mode is a separate scale, not an inversion: bg L 14–18%, elevated surfaces **lighter** (+3% L per level), text L ≈ 96%, brand solid unchanged. The direction files already do this. Never write `dark:bg-…` on semantic tokens.
- Status colors are for status only, always with an icon or text label.
- Custom brand hex from the user: `references/color.md` (OKLCH procedure + `packages/tokens/scripts/generate.ts scale`).
- Selection is brand-tinted (base layer). Set `<meta name="theme-color">` per scheme (`design/theme-color-missing`, `design/color-scheme-missing`).

## 3. Typography

| Role | Token / classes | Measured basis |
|---|---|---|
| Hero H1 | `text-display-lg` (42→80px, lh 0.96) or `text-display-xl` (46→96px) + `max-w-[14ch]`–`max-w-[20ch]` | H1 46–96px desktop, 28–48px mobile; lh 0.85–1.15 |
| Section H2 | `text-display-sm` (34→54px) or `text-4xl` (30→44px) | H2 32–56px |
| H3 / card title | `text-xl` or `text-2xl`, `font-medium` | 20–24px |
| Lead / hero sub | `text-lg text-fg-muted max-w-[44ch]` | 18–24px, max-width 480–672px |
| Body | `text-base` (15px) product, `text-md` (16px) marketing, `text-lg` long-form | 16px/1.5; long reads 1.625–1.75 |
| UI, nav, buttons | `text-sm` (13px), `font-medium` | most frequent style = 14px |
| Label / eyebrow | `label-mono text-fg-muted` (11px, uppercase, +0.06–0.12em) | mono labels +0.1 to +0.15em |
| Numbers | `tabular-nums` (auto on table/time/output/kbd) | Linear: 65 tabular declarations |

- Tracking tightens with size; the text tokens carry it. Never set positive tracking on lowercase body.
- `text-wrap: balance` on h1–h4 and `pretty` on paragraphs are in the base layer (`design/heading-no-balance`).
- Headline widths in `ch`: 10–20ch for H1, ≤ 28ch for H2. Paragraph measure 45–72ch (`design/line-length-long`).
- Two-tone headline (Linear, Stripe): `<h2>First phrase. <span className="text-fg-muted">continuation that explains.</span></h2>`.
- No single serif-italic word inside a sans headline (a 2025–26 template tell). Commit to the whole line or none.
- Tables of pairings, OpenType features, fluid clamps: `references/typography.md`.

## 4. Layout and spacing

- Page wrapper: `container-page` (76rem, fluid gutter 20→40px). Reading column: `max-w-prose mx-auto` (65ch). Wide/dashboard: `max-w-wide`.
- Section rhythm: `section-y` (64→128px). Vary it on purpose: hero `pt-16 pb-20 lg:pt-32 lg:pb-28`, a dense proof strip `py-12`, a showcase `py-24 lg:py-40`. Identical `py-20` everywhere is a tell. Measured desktop 96–160px, mobile 60–96px.
- Grids: 12 columns with asymmetric spans (7/5, 8/4, 3/9). Avoid 4/4/4 rows of identical cards.
- Header 56–72px (`--header-h` 3.5rem default), sticky, `bg-bg/92` with an optional blur. Blur only on the header.
- Heading-to-content gap is smaller than the gap above the heading (1:2 to 1:3).
- Full-height heroes use `min-h-svh`, never `h-screen` (`lint/vh-hero`). `overflow-x: clip`, never `hidden`, on html/body (`lint/overflow-x-hidden-sticky`). No horizontal overflow at 390px (`design/horizontal-overflow`).
- Grid recipes, container queries, density table: `references/layout.md`.

## 5. Depth

- Radius knob per direction; utilities derive: `rounded-xs` (×0.25) `sm` (×0.5) `md` (×0.75) `lg` (×1) `xl` (×1.5) `2xl` (×2) `3xl` (×3). Buttons `rounded-lg`, cards `rounded-xl`, dialogs `rounded-2xl`. Pill only if the direction or brand picks pills for CTAs, and then consistently.
- Nested corners: parent `radius-nested` (child radius = parent − padding), or compute `outer = inner + padding` by hand.
- Elevation: `shadow-1` resting cards (light: 1px contact; dark: inset highlight only), `shadow-2` popovers/menus, `shadow-3` dialogs and hero product frames. Primary buttons add `shadow-(--elevation-highlight)` (inset top light, bottom shade).
- Dark depth = lighter surface + 1px alpha ring + inset top highlight, not bigger black shadows.
- `surface-card` = surface + hairline + `rounded-xl` + `shadow-1`. Do not add another shadow to it.
- Recipes (Linear/Vercel/Stripe shadow stacks, product frames, hairline grids): `references/depth-and-detail.md`.

## 6. Details that read as expensive

- **Hairlines:** `border-line-subtle` dividers; `gap-px bg-line` over a grid for 1px cell rules; `hairline` utility for a bordered box.
- **Mono labels:** `label-mono` for eyebrows, table heads, metadata, kbd hints, status. Use eyebrows on ≤ 1/3 of sections.
- **Press:** `press` utility on every pressable (scale 0.97 on `:active`, 100ms). Never `hover:scale-105`.
- **Hit areas:** `hit-area` on targets under 24px (44px on coarse pointers) (`a11y/target-size`).
- **Grain:** `grain` utility, opacity from the direction (0–0.06). On near-black keep it ≤ 0.02–0.035. One grain layer per page region.
- **One light source:** at most one low-alpha same-hue radial gradient (`color-mix(in oklch, var(--brand) 10–14%, transparent)`), anchored at an edge. No multi-color blobs.
- **Scrollbar:** `scrollbar-gutter: stable` is in base. Thin styled scrollbars only on inner scroll areas: `[scrollbar-width:thin] [scrollbar-color:var(--line-strong)_transparent]`.
- **Theme switch:** blocking head script + transitions frozen for one frame (template `src/lib/theme.ts`, `components/theme-toggle.tsx`; recipe in `motion/references/recipes.md`).
- **Typography hygiene:** `…` not `...`, curly quotes, en dash for ranges (9–5), non-breaking space in `10&nbsp;MB`.

## 7. Imagery and icons

- **Show the product, a real artifact, or type.** Best heroes: real product UI (Linear, Cursor, Attio), an art-directed asset (Stripe ribbon, Railway pixel art), or typography alone (Granola, Dia, hellohello). Never a stock photo of people at laptops, a gradient blob, or a 3D abstract render as filler.
- **Product screenshots:** real UI at real scale, framed with `rounded-xl border border-line shadow-3 overflow-clip bg-surface`. No fake macOS traffic lights, no browser chrome, no glow halo. A hand-built DOM mock beats a PNG (sharp, themable, indexable) if it matches the real product.
- **Icons:** one set (lucide), one stroke width (1.5), sized to the text: 16px in UI, 20px max in marketing. `text-fg-muted`, inline with labels. No icons in colored circles, no emoji as icons (`design/emoji-icons`).
- **Logos of customers:** only real, permitted ones; monochrome `text-fg-subtle` via SVG `currentColor`, equal optical area, 4–8 max.
- **Images:** AVIF/WebP via `next/image`, explicit size, only the LCP image eager (`speed`). Dark mode: dim non-photo art slightly, provide dark variants of diagrams.
- **No placeholder art in shipped work.** Missing asset → a clearly marked TODO box in dev, removed or replaced before done (see `website` §3).

## 8. Anti-slop checklist (30 tells → fix)

Check every screenshot against this. Before/after code for each: `references/anti-slop.md`.

| # | Tell | Fix |
|---|---|---|
| 1 | Purple→blue/pink gradient on hero, buttons or text | Flat `bg-brand`; one same-hue light at ≤ 14% |
| 2 | Gradient headline text (`bg-clip-text text-transparent`) | Solid `text-fg`; two-tone with `text-fg-muted` span (`design/gradient-text`) |
| 3 | Several saturated accents (green badge, purple icon, orange tag) | One brand hue; icons `text-fg-muted`; status colors only for status |
| 4 | Pure #000/#fff with default gray-500 secondary | Direction tokens (tinted neutrals, 3 text tiers) |
| 5 | `border-gray-200` solid lines everywhere | Alpha `border-line-subtle`; fewer borders, more spacing |
| 6 | Glassmorphism cards over blobs | Solid `bg-surface`; blur only on the sticky header |
| 7 | Blurred color orbs / radial glow as the only hero visual | Product UI, real asset, or type; one edge light max |
| 8 | Default blue selection and browser focus ring | Base layer selection + `line-focus` ring (don't override) |
| 9 | Inter/system font at default tracking for 48px+ headings | Direction display font + `text-display*` tokens |
| 10 | `font-bold`/`font-extrabold` H1 | Remove; direction weight (400–600) applies |
| 11 | Display line-height 1.2–1.5 | `text-display*` (0.92–1.04); never `leading-normal` on display |
| 12 | Everything 16px+, no small type texture | `text-sm` UI, `label-mono` metadata |
| 13 | Hero sub wider than 700px, 3+ lines | `max-w-[44ch]`, ≤ 2 lines |
| 14 | Orphans in headings | Base `text-wrap: balance`; `max-w-[Nch]` |
| 15 | 3+ font families or random Google fonts | Direction pair + mono only |
| 16 | Centered everything | Left-align; center only hero lockup or final CTA |
| 17 | Hero → 3 icon cards → testimonials → CTA template | Section archetypes (`website/references/section-archetypes.md`) |
| 18 | Row of 3 identical cards with icon-in-circle + title + 2 lines | Feature deep-dive rows, editorial list with hairlines, real UI |
| 19 | Bento with random spans and no content logic | Spans by importance; each tile shows real product or data |
| 20 | Uniform `py-16`/`py-20` sections | `section-y` + deliberate variation (96–160 desktop) |
| 21 | `max-w-7xl` text lines of 120+ characters | `max-w-prose` / `max-w-[60ch]` |
| 22 | 80px navbar with gradient "Get Started" | 56–64px sticky header, solid neutral or brand CTA `size="sm"` |
| 23 | `rounded-2xl shadow-xl` on everything | `rounded-lg`/`xl` + `shadow-1`, or border only |
| 24 | Border + shadow + bg change on one card | One separation method |
| 25 | Non-concentric nested corners | `radius-nested` |
| 26 | Emoji feature icons, or icons in tinted circles | One lucide set, 16px, inline, muted |
| 27 | `hover:scale-105` buttons/cards, gradient buttons with glow | `press` (scale 0.97 on active); hover = color/border step |
| 28 | Fake logos, 5-star testimonial carousel, "10K+ users · 99.9% uptime" | Real proof only, or omit the section |
| 29 | Every section fades up 40px on scroll; `transition-all duration-300` | `motion`: 1–3 reveals, `reveal` utility, explicit properties |
| 30 | Buzzword copy ("Unlock", "Supercharge", "Seamless", "Elevate") | Concrete claim with a number or a named outcome (`website/references/copy-and-content.md`) |

## Tailwind v4 traps (silent failures)

- `duration-(--duration-fast)`, never `duration-[--duration-fast]` (`lint/tw-v3-arbitrary-var`).
- Undefined utilities emit nothing: `bg-primary`, `shadow-lg`, `text-5xl`, `bg-zinc-800` are **removed** in void tokens (`lint/tw-unknown-class`). Use `shadow-1..3`, `text-display*`, semantic colors.
- JS `tailwind.config.*` is ignored (`lint/tw-js-config-ignored`). `dark:` needs the `@custom-variant` from base.css (`lint/tw-dark-variant-os`).
- v3→v4 renames: `shadow-sm`→`shadow-xs`, `rounded-sm`→`rounded-xs`, `outline-none`→`outline-hidden`, `bg-gradient-to-*`→`bg-linear-to-*`, `ring` = 1px (`lint/tw-v3-renamed`).
- Full list and fixes: `references/tailwind-v4.md`.

## References

- `references/directions.md` — six directions: mood, fonts (next/font), token file, signature details, don'ts, hero markup
- `references/typography.md` — pairings, measured tracking/leading table, features, fluid sizes
- `references/color.md` — OKLCH roles, dark mode, custom brand accent procedure
- `references/layout.md` — containers, grids, rhythm, density, responsive rules
- `references/depth-and-detail.md` — radius, shadows, frames, hairlines, focus, grain, scrollbars
- `references/anti-slop.md` — all 30 tells with before/after snippets
- `references/tailwind-v4.md` — every silent failure and the v4 form
