# void: full documentation

> void is an open-source design system for AI coding agents: Claude Code skills, Tailwind v4 tokens and a CLI that audits speed, accessibility, SEO and design.

Generated from https://github.com/ParthKapoor-dev/design. 60 documents. Individual rule pages are not inlined; see https://void-design.vercel.app/rules.md.


---

<!-- https://void-design.vercel.app/docs · updated 2026-09-16 -->

# Getting started with void

void is a Claude Code plugin plus a Bun CLI. Install the plugin and Claude Code loads the skills when a task needs them. Run the CLI to check the result. Neither needs an account or an API key.

## What you get

| Part | Where | What it does |
|---|---|---|
| Skills | `skills/` | Instructions Claude Code loads on demand: the website workflow, visual craft, motion, performance, SEO and AI search, accessibility, the audit loop |
| Tokens | `packages/tokens/` | Tailwind v4 CSS: one semantic base and six aesthetic directions, light and dark, contrast-checked |
| CLI | `packages/cli/` | `void lint` for source, `void audit` for a running site, `void extract` for a site you admire, `void rules` to explain any finding |
| Starter | `templates/next/` | Next.js 16 app with the tokens, metadata, sitemap, robots, llms.txt and budgets already wired |

## 1. Install the plugin

Add the marketplace, then install the plugin from it:

```bash
claude plugin marketplace add ParthKapoor-dev/design
claude plugin install void@void
```

Working on void itself, or trying a branch? Point Claude Code at a checkout instead:

```bash
git clone https://github.com/ParthKapoor-dev/design.git void
claude --plugin-dir ./void
```

The skills trigger on their own when you ask for a website, a landing page, a redesign or an audit. To be explicit, name one: *"use the website skill to build a landing page for …"*.

## 2. Start from the Next.js starter

The starter is a complete, verified Next.js 16 app. Copy it, set your production URL, and edit `src/site.config.ts`, the one file that holds your site's facts.

```bash
cp -r void/templates/next my-site
cd my-site
bun install
echo 'NEXT_PUBLIC_SITE_URL=https://example.com' > .env.local
bun dev
```

Agents read `AGENTS.md` in the starter first. It lists where tokens live, which direction is active, and the two commands that must pass before anything ships.

To change the look, pick a direction. Change the `@import` in `src/app/globals.css`, the `next/font` loaders in `src/app/layout.tsx`, and `direction` in `src/site.config.ts`. The [directions](/directions) pages show each one live.

## 3. Run the CLI

The CLI runs on Bun 1.2 or newer and drives a local Chromium (it looks at `VOID_CHROMIUM`, then `CHROME_PATH`, then the usual install paths). From the void checkout:

```bash
bun void/packages/cli/src/index.ts doctor          # check Bun, Chromium, axe, Lighthouse
bun void/packages/cli/src/index.ts lint my-site/src  # static checks, no browser, seconds
```

Audit a production build, never `next dev`. `--start` builds nothing; it starts your server, waits for it, audits every route on a throttled mobile and a desktop profile, and stops it:

```bash
cd my-site && bun run build
bun ../void/packages/cli/src/index.ts audit --start "bun run start -p 3100" --port 3100 --routes /,/pricing --format md
```

Alias it while you work: `alias void="bun $PWD/void/packages/cli/src/index.ts"`.

## 4. Fix by rule id

Every finding carries an id like `perf/lcp-image-lazy`. `void rules <id>` prints why it matters, the fix and an example, and each id has a page on this site under [/rules](/rules). Fix gates first, then errors, then budgets, then warnings. The [audit skill](/docs/audit) has the full loop and when to stop.

## 5. Know when you're done

- `bun run build` passes and every route is prerendered.
- `void lint` reports no errors.
- `void audit` exits 0: no error findings and every budget holds on mobile and desktop.
- Screenshots at 1440 and 390 pass the [anti-slop checklist](/docs/craft/anti-slop).

## Next

- [website](/docs/website): the end-to-end workflow the agent follows.
- [craft](/docs/craft): the visual system and the 30 tells of generated UI.
- [CLI reference](/cli): every command, flag, exit code and config key.


---

<!-- https://void-design.vercel.app/docs/website · updated 2026-09-16 -->

# website — the void workflow

Follow the phases in order. Each has exit criteria; don't start the next phase until they hold. The skills you'll pull in:

| Skill | Owns |
|---|---|
| `inspire` | building from an inspiration URL ("make it look like linear.app") |
| `craft` | direction, color, type, layout, depth, anti-slop checklist |
| `motion` | what moves, timing, effect tiers |
| `components` | sourcing effects/components from libraries (React Bits, Magic UI, skiper-ui…) as lean void code |
| `speed` | Server Components, budgets, images, fonts, LCP/INP/CLS |
| `seo` | metadata, JSON-LD, sitemap, robots, llms.txt, content that gets cited |
| `a11y` | landmarks, keyboard, focus, contrast, forms |
| `audit` | the `void` CLI fix loop |

**Tooling:** `void` is the void CLI. If it isn't on PATH: `bun "${CLAUDE_PLUGIN_ROOT}/packages/cli/src/index.ts" <command>` (run `bun install` in that folder once; `void doctor` checks Chromium).

## The 10 rules

1. **Write the brief before any markup.** No brief, no page.
2. **One direction, one accent, one signature moment.** Decide them in writing in phase 1.
3. **Real content first.** Sections come from what the page must prove, not from a template. No lorem ipsum, no invented logos, quotes, stats or ratings. Ever.
4. **Don't default to hero → 3 icon cards → testimonials → CTA.** Pick archetypes by the job of each section.
5. **Tokens only.** Semantic utilities from `@void/tokens`; zero hex, zero default palette, zero arbitrary spacing.
6. **Server Components by default.** Client islands only for real interactivity. The hero H1 and LCP image are in the HTML and visible at first paint.
7. **Left-aligned, varied rhythm, small type texture.** Measured top sites center ≤ 25% of headings and run on 13–14px UI text.
8. **Motion is budgeted:** 1 signature + ≤ 1 secondary effect, CSS first, reduced motion honored.
9. **Verify with tools and eyes:** `void lint` → `next build` → `void audit` → screenshots at 1440 and 390 against the anti-slop checklist.
10. **Only publish what exists.** Every claim, number, logo and schema entry is real or removed.

## Phase 0 — Brief

Write this block into the conversation (or `BRIEF.md`) before anything else. Ask the user only for what you can't infer; don't stall on optional fields.

```md
## Brief
- Product / person: <what it is in one sentence, no adjectives>
- Audience: <who, how technical, what they already use>
- The one job of this page: <the single action or belief after reading, e.g. "install the CLI">
- Primary CTA: <label + destination>   Secondary: <text link or none>
- Proof we actually have: <screenshots, demo, real numbers with dates, customer names with permission, quotes with attribution, GitHub stars, awards> (or "none yet")
- Tone: <3 words, e.g. "precise, calm, dry">
- Constraints: <existing brand color/font/logo, dark or light, pages needed, deadline>
- Inspiration: <URLs or "none">
```

- **Inspiration URL given** ("like linear.app", "inspired by <url>") → run the `inspire` skill now; it produces the direction and overrides for phase 1.
- **Existing site to redesign** → first run `void audit <url> --screenshots .void/before --format md` and look at the screenshots; list what to keep (content, brand assets, URLs for SEO) and what fails.

**Exit:** one job, one CTA, audience and the real proof inventory are written down.

## Phase 1 — Direction and design statement

Map the brief to exactly one direction (specs in `craft` §1 and `craft/references/directions.md`):

| Brief signals | Direction | Fonts (fixed) | Accent (default) | Radius |
|---|---|---|---|---|
| developer tool, AI/infra, API, B2B SaaS for technical teams, "Linear/Vercel-like" | `precision` (dark) | Geist + Geist Mono | indigo | 6px |
| CLI, open-source infra, hackathon, audience lives in a terminal | `terminal` (dark) | Geist Mono only | amber (dark text on solids) | 0 |
| writing, research, publication, studio with a point of view, premium/editorial brand, long docs | `editorial` (light) | Newsreader + IBM Plex Mono | oxblood | 2px |
| agency, architecture, design/type tool, event, portfolio about craft, data journalism | `swiss` (light) | Schibsted Grotesk + JetBrains Mono | signal red | 0 |
| consumer app, notes/productivity, education, health, community, friendly SMB | `warm` (light) | Bricolage Grotesque + Figtree | clay | 10px |
| analytics, admin, finance, internal tools, docs for a data product | `instrument` | IBM Plex Sans + Plex Mono | blue | 6px |
| personal dev portfolio (owner's taste default) | `precision` or `terminal`, dark, mono label voice | as above | as above | as above |

**Take fonts, accent, radius and durations from the direction file** (`src/styles/void/directions/<name>.css` header). Don't invent a font or accent. Change the accent only when the user has a brand color (then follow `craft/references/color.md` §5) or when `inspire` requires a departure.

Then write the **design statement** (5 lines + signature) into the brief:

```md
## Design statement
1. Direction: precision (dark-first; light theme kept honest)
2. Fonts: Geist 500 display via `text-display-lg` (42→80px, tracking baked in), Geist `text-sm`/`text-md` UI/body, Geist Mono `label-mono` labels
3. Accent: indigo `--brand` oklch(54.5% .19 272) copied from the direction file, only on the primary CTA, links, focus and active nav (≤ 2% of the fold)
4. Radius & depth: 6px knob (buttons 6, cards 9, frames 12); hairlines + lighter surfaces, shadow-3 only on the product frame
5. Motion personality: quick and exact, 100–150ms UI, one 450ms hero settle, 2 scroll reveals
Signature detail: the real deploy log streaming in a framed terminal beside the H1 (T1 CSS, no JS)
```

The signature detail is one specific, ownable thing (a live-looking product surface, a typographic poster hero, a numbered editorial list, a boot-log, a split serif hero). Not "animations" or "gradient".

**Exit:** direction chosen, statement written, custom brand hex (if any) converted per `craft/references/color.md` §5.

## Phase 2 — Scaffold

**New project** (Next 16.3.5, React 19, Tailwind 4.3.3, tokens and SEO wired):

```bash
cp -R "${CLAUDE_PLUGIN_ROOT}/templates/next" ./my-site    # or from a void checkout: cp -R <void>/templates/next ./my-site
cd my-site && bun install
cp .env.example .env.local                                  # set NEXT_PUBLIC_SITE_URL
bun dev
```

Then set the direction (three places must agree): `src/app/globals.css` import, `next/font` loaders in `src/app/layout.tsx` (variable names from the direction file header), `direction` + `theme.default` in `src/site.config.ts`. Replace every fact in `src/site.config.ts` and all copy in `src/content/*`. Template primitives: `components/ui/{button,card,container,section,eyebrow,badge,link,kbd}.tsx`.

**Existing Next/Tailwind v4 project (retrofit):**

1. Copy `${CLAUDE_PLUGIN_ROOT}/packages/tokens/css/base.css`, one `directions/<name>.css` (and `shadcn.css` if the project uses shadcn/ui) into `src/styles/void/`.
2. `globals.css`: `@import "tailwindcss";` → `@import "../styles/void/base.css";` → `@import "../styles/void/directions/<name>.css";` → (optional) `shadcn.css`.
3. Delete `tailwind.config.*` after moving any real theme values into `@theme`.
4. Add the direction's `next/font` loaders with the exact `variable` names; put `.variable` classes on `<html>`.
5. Add the blocking theme script from `templates/next/src/lib/theme.ts` to `<head>`.
6. Run `void lint`: every `lint/tw-unknown-class` and `lint/hardcoded-colors` finding is a class to migrate to semantic tokens (`bg-zinc-900` → `bg-surface`, `shadow-lg` → `shadow-2`, `text-6xl` → `text-display-lg`).

Tailwind v3 project: migrate to v4 first (`npx @tailwindcss/upgrade`), then retrofit.

**Exit:** `bun dev` renders the template (or existing site) with the chosen direction's fonts and colors in both themes; `void lint` has no `lint/tw-*` errors.

## Phase 3 — Content and structure (before styling)

1. **Section plan.** Write a numbered list: section → its job → archetype → the real content it uses. Each section answers one question a skeptical visitor has, in the order they'd ask it (What is it? → Does it work? → For me? → Proof? → Cost? → How do I start?).
2. **Pick archetypes** from `references/section-archetypes.md`. Default page for a product: typographic or product-shot hero → proof strip (only if real) → product showcase → 2–3 feature deep-dive rows → metrics or quote (only if real) → how it works / install → pricing or FAQ → CTA band → footer. Vary adjacent sections: never two card grids in a row; at most one bento; at most one centered section besides the hero.

   | Archetype | Use when |
   |---|---|
   | Hero: editorial left | clear one-line value prop, no strong visual |
   | Hero: product shot | UI is the proof (dev tools, SaaS) |
   | Hero: typographic poster | brand/voice-led, agency, launch, portfolio |
   | Hero: split | H1 needs a paragraph of nuance beside it (Anthropic, Supabase) |
   | Proof strip / logo wall | ≥ 4 real, permitted customer logos |
   | Product showcase | one real screen explains the product |
   | Feature deep-dive rows | 2–4 capabilities, each with a real visual |
   | Editorial capability list | services or many features without visuals |
   | Bento | 4–6 peers of different weight, each tile shows something real |
   | Metrics strip | 3–4 measured numbers with units, dates and sources |
   | Quote | one long, specific, attributed quote |
   | How it works / install | developer products; steps or a copyable command |
   | Pricing / comparison table | real plans or a vs-table |
   | FAQ | real objections from sales/support, 4–8 questions |
   | CTA band | closing ask repeating the primary CTA |

3. **Write real copy** (`references/copy-and-content.md`): specific headline ≤ 10 words, sub ≤ 2 lines, numbers with units, answer-first paragraphs (GEO, see `seo`). CTAs are verb + object ("Install the CLI", not "Get started"/"Learn more"). No buzzwords (unlock, seamless, lightning-fast, supercharge, next-generation). Put copy in `src/content/*.ts`, not inline JSX.
4. **Placeholders are loud, never fake.** Missing proof → the section is omitted, or during development a visible dashed box that says exactly what's needed:

   ```tsx
   <div data-placeholder className="grid min-h-40 place-items-center rounded-xl border border-dashed border-line-strong bg-bg-subtle p-6 label-mono text-fg-subtle">
     TODO(content): customer quote with name, role, company, permission
   </div>
   ```
   Content fields: `quote: null, // TODO(content): …`. Every `TODO(content)` is listed in your final report and removed or resolved before done.
5. Page-type specifics (portfolio, docs home, blog/article, pricing, changelog): `references/page-types.md`.

**Exit:** section plan with job + archetype + real content for every section; copy in content files; no invented proof.

## Phase 4 — Build

Order: layout shell → hero → sections top to bottom → responsive pass → dark/light pass.

- **Craft:** tokens only; direction type tokens (`text-display-lg`, `text-sm`, `label-mono`); `container-page`, `section-y` with deliberate variation; `surface-card`, `shadow-1..3`, `press`, `hit-area`, `grain`. Check `craft` §8 as you go, not only at the end.
- **Speed:** every section is a Server Component; `'use client'` only on leaves (menu, toggle, tabs); `next/image` with `sizes`, one `fetchPriority="high"` LCP image; fonts only via the direction loaders; no client data fetching of public APIs.
- **SEO:** `pageMetadata()` per page (template `src/lib/seo.ts`), exactly one `<h1>`, landmarks, JSON-LD for what's visible, sitemap/robots/llms.txt already wired. Update `src/content/pages.ts` with real `lastModified`.
- **A11y:** real `<button>`/`<a>`, labels on icon buttons, visible focus (base layer), contrast from tokens (don't lower it), `lang`, skip link (template has it), headings in order.
- **Components:** before hand-building an interactive widget or effect, check `components` for the lean version (marquee, number ticker, spotlight…) and its tier.

**Exit:** page complete at 1440 and 390, both themes, `void lint` clean of errors, `bun run build` passes.

## Phase 5 — One signature moment

- Pick exactly one signature (from the design statement) and at most one secondary effect per page. Budget and tiers: `motion` §3. Default to T0–T1 (CSS).
- Candidates: hero lockup settle + staggered sub/CTA (T1), a scroll-revealed product showcase (`reveal`, T1), a masked statement reveal (T1), a live-looking terminal/log (T1), one pointer spotlight on a feature grid (T2), one canvas/shader backdrop (T3/T4, only with all `motion/references/webgl-canvas.md` rules).
- Everything else is still: color/border hover steps, `press`, instant keyboard UI.
- Sourcing from a library: `components` (licence, audit, rewrite on tokens).

**Exit:** ≤ 1 signature + ≤ 1 secondary; reduced-motion screenshot shows every piece of content.

## Phase 6 — Verify and fix

```bash
void lint                                                     # seconds; fix all errors
bun run build                                                 # must pass; check route sizes
void audit --start "bun run start -p 3100" --port 3100 --screenshots .void/shots --format md
```

1. Fix in order: errors → budget failures → warnings. Look up any id with `void rules <id>`. Loop details: `audit`.
2. **Look at the screenshots** (open the PNGs in `.void/shots`): desktop fold, desktop full page, mobile fold. If a screenshot is missing, capture it with `void design --start "bun run start -p 3100" --port 3100 --screenshots .void/shots`.
3. Review each screenshot against `craft` §8 (30 tells). Write down every tell you find and fix it. Then ask: *would this page be mistaken for a template?* If yes, strengthen the signature detail or the typography, not the effects.
4. Toggle OS reduced motion and dark/light once; check the hero and one reveal.
5. Re-run `void audit` until no errors remain.

## Definition of done

- [ ] Brief + design statement written; one direction, one accent, one signature.
- [ ] Every section has a job and a non-template archetype; no two identical grids in a row.
- [ ] No lorem, no invented logos/quotes/stats/ratings; `grep -r "TODO(content)" src` is empty or each item is reported to the user.
- [ ] Tokens only: `void lint` shows no `lint/hardcoded-colors`, `lint/tw-unknown-class`, `lint/tw-v3-arbitrary-var`.
- [ ] Headings: one `<h1>`; display tokens; ≤ 25% of headings centered; balanced wrapping.
- [ ] Accent visible on ≤ ~5 spots in the desktop fold; `design/multiple-accents` clean.
- [ ] Hero H1 and LCP image visible at first paint; `perf/*` budgets pass (marketing ≤ 170 KB first-load JS).
- [ ] Motion: ≤ 1 signature + ≤ 1 secondary; reduced motion shows all content; no `transition-all`, no `hover:scale-*`.
- [ ] Mobile 390: no horizontal scroll, tap targets ≥ 24px (44px primary), text ≥ 13px, H1 ≤ 3 lines.
- [ ] Both themes (or the single declared scheme) look intentional; `theme-color` and `color-scheme` set.
- [ ] `void audit` has 0 errors; screenshots at 1440 and 390 pass the 30-tell checklist.
- [ ] Final report lists: direction + statement, signature, open TODO(content) items, audit summary.

## References

- `references/section-archetypes.md` — markup skeletons for every archetype (4 hero variants → footer)
- `references/copy-and-content.md` — headlines, specificity, numbers, CTAs, answer-first, banned phrases
- `references/page-types.md` — landing, portfolio, docs home, blog/article, pricing, changelog


---

<!-- https://void-design.vercel.app/docs/website/copy-and-content · updated 2026-09-16 -->

# Copy and content

Generic copy makes a well-styled page look generated. Specific copy makes a plain page look designed. Write copy before styling; keep it in `src/content/*.ts` so the HTML and the Markdown mirror stay in sync.

## 1. Headlines

**Rules**
- ≤ 10 words for H1 (fits 2–3 lines at 390px). H2 ≤ 12 words.
- Say what it does or what changes, for whom. A reader who sees only the H1 can explain the product.
- Concrete nouns and verbs. At least one of: a number, a named object (branch, invoice, standup), a named outcome (in 14 seconds, without a dashboard).
- No colon-splits ("X: The Y for Z") and no question headlines unless the page answers a real search question.
- Sentence case. End H1 with a period if it's a full sentence (Linear, Granola style); pick one convention for the site.

**Formulas that work (measured on the teardown sites)**

| Formula | Example |
|---|---|
| Outcome + time/number | "Deploy previews for every branch in 14 seconds." |
| Object + where it lives | "Feature flags that live in your repo." |
| Plain category claim (confident brands) | "The AI notepad for back-to-back meetings." (Granola) |
| Before → after | "Standups in 4 minutes, written by the tickets." |
| Two-tone: claim + explanation | "Built for review. *Every change gets a link, a diff and a rollback.*" (Linear/Stripe pattern) |
| Poster statement (agency/portfolio) | "Brands that look like nobody else." (only with a portfolio that proves it) |

**Before → after**

| Generic | Specific |
|---|---|
| Revolutionize your workflow with AI | Turns a 40-minute incident review into a 3-paragraph summary |
| The future of payments | Accept cards, wallets and bank debits in 46 countries |
| Build faster, ship smarter | Every pull request gets its own database in under a second |
| Your all-in-one platform | Docs, tickets and on-call in one keyboard-first app |
| Designed for developers | Install with one command; configure with one YAML file |

## 2. Subheads and body

- Hero sub: 1–2 sentences, ≤ 2 lines at desktop (`max-w-[44ch]`), adds the *how* or the *proof* the H1 didn't.
- Section intro: one sentence. If you need three, the section has two jobs.
- **Answer first.** The first sentence of every section and FAQ answer states the answer; detail follows. This is also what AI search engines quote (`seo/references/content-for-citation.md`).
- One idea per paragraph, 1–3 sentences, active voice, second person ("you") or plain description; no "we believe".
- Numbers: digits, units, context and date. "Median build 14 s (Aug 2026, 1.2M builds)". Non-breaking space between number and unit in JSX: `14&nbsp;s`.
- Name things the audience already knows (Postgres, Slack, GitHub) instead of abstract categories ("your tools").
- Cut adverbs and intensifiers: really, truly, seamlessly, effortlessly, incredibly.

## 3. Banned phrases (rewrite on sight)

unlock · supercharge · revolutionize · elevate · empower · seamless(ly) · effortless(ly) · cutting-edge · next-generation · game-changing · world-class · best-in-class · robust · leverage · synergy · the future of · all-in-one platform · take X to the next level · designed with you in mind · built for scale (without a number) · trusted by thousands (without names) · lightning-fast · blazing fast · magic / magical (unless it's a literal trick) · "Welcome to" as a headline · "Get started today" as a heading · emoji in headings or buttons

## 4. CTAs

- Verb + object, specific: "Start deploying", "Install the CLI", "Book a 20-min demo", "Read the quickstart". Not "Get started", "Learn more", "Submit".
- One primary CTA label per page, repeated exactly in the hero, header (short form ok) and closing band.
- Secondary action is a text link or ghost button, never a second filled button of equal weight.
- Say what happens: "Download for Mac (38 MB)", "Start free — no card".
- Developer products: a copyable command is often the best secondary CTA (`npx acme init`).

## 5. Proof and the placeholder protocol

Only publish what exists. For every proof element, record where it came from.

| Proof | Needs | If missing |
|---|---|---|
| Customer logos | written permission, SVG from the customer's brand page | omit the strip |
| Quote | exact words, name, role, company, permission | omit, or `TODO(content)` box during dev |
| Metric | measurement method, date, source you can link | omit, or state it as a promise ("builds cached by default") |
| Rating / review count | real platform (G2, App Store) with link | omit (never in JSON-LD either) |
| Awards / press | link to the listing or article | omit |
| Screenshot | real product, staging data allowed, no fake data that implies customers | a clearly labeled mock of real UI |
| Team photos | real photos with consent | initials/avatars or omit |

Development placeholders are visible dashed boxes reading `TODO(content): <what exactly is needed>` (markup in `website` §3). Never grey logo blocks, stock avatars, "John Doe", "Lorem ipsum", "10K+ users", "4.9★".

## 6. Structure that reads well to people and machines

- One `<h1>` per page matching the page title's topic; section headings in order (h2 → h3).
- Question-style H2s only where users ask questions (FAQ, docs, pricing). Answer in the first sentence below.
- Comparison or spec facts → a real `<table>`, not cards or images of text.
- Every key fact visible in HTML text (not only in images, tabs that require JS, or canvas).
- The first paragraph of the home page states what it is, for whom, and the main differentiator in ≤ 2 sentences (used for meta description and llms.txt summary in `src/site.config.ts`).

## 7. Microcopy and typography in text

- `…` not `...`; curly quotes “ ” ‘ ’; en dash for ranges (9–5, 2024–2026); em dash sparingly; `×` for dimensions.
- Keyboard shortcuts in `<Kbd>`; platform symbol (⌘ vs Ctrl) resolved after mount.
- Dates: absolute ("16 Sep 2026") on changelogs and posts; relative only in app UI.
- Buttons and nav: sentence case, 1–3 words. Labels (`label-mono`): 1–4 words, uppercase via the utility only.
- Error and empty states say what happened and what to do next ("No deploys yet. Push a branch to create one.").
- Legal/footer text `text-xs text-fg-subtle`, but never below 12px.

## 8. Tone by direction

| Direction | Voice |
|---|---|
| precision | dry, exact, numbers, short sentences, developer nouns |
| terminal | imperative, lowercase commands, `$` prompts, zero marketing adjectives, real output |
| editorial | complete sentences, a point of view, longer paragraphs, italic decks |
| swiss | declarative statements, numbered sections, few words, big claims backed by a list |
| warm | friendly second person, plain words, reassurance with specifics ("works offline") |
| instrument | labels over sentences, units everywhere, status words (normal, degraded) |


---

<!-- https://void-design.vercel.app/docs/website/page-types · updated 2026-09-16 -->

# Page types

Section plans are starting points; drop any section you have no real content for. Archetype numbers refer to `section-archetypes.md`.

## Landing / product homepage

- **Direction:** from the brief (usually precision, warm or editorial).
- **Plan:** hero 1b or 1a → proof strip 2 (if real) → product showcase 3 → feature rows 4 (2–3) → metrics 7 or quote 8 (if real) → how it works 9 (dev) → pricing teaser 10 or FAQ 11 → CTA band 12 → footer 13.
- **Signature:** the product surface (framed real UI, a live-looking log) or a typographic hero.
- **Must:** one CTA label everywhere; the H1 names the category and outcome; `SoftwareApplication` JSON-LD only with real offers (`seo`); first-load JS ≤ 170 KB.
- **Avoid:** feature grid of icons; a second bento; testimonials carousel.

## Portfolio / personal site

- **Direction:** precision or terminal (dark, mono voice) for engineers; swiss or editorial for designers and writers.
- **Plan:** poster or editorial hero 1c/1a with name + what you do + where → selected work (3–6 projects, each: title, role, year, one-line outcome, real image) → writing or talks (dated list with hairlines, archetype 5) → about (short, first person, a real photo or none) → contact (email as text + 2–3 real profiles) → footer.
- **Work list, not a card grid:** rows with `divide-y divide-line-subtle`, image thumbnails revealed on hover only under `pointer-fine` (portal-free: `absolute` inside the row, `pointer-events-none`).
- **Signature:** one brand-level detail: a live local time, "now playing", a contribution heatmap rendered on the server, a mono decoder on the name. Only one.
- **Must:** `Person` JSON-LD with `sameAs` for real profiles; H1 is your name + role; each project page has its own title/description/OG.
- **Avoid:** skill bars, "Hi, I'm X 👋", tech-logo clouds, 3D avatars, fake testimonials.
- **Density:** the owner's taste favors a one-screen home at 1280×800 with dense 13–15px text; heading ≤ 54px; test at 1366×768.

## Docs homepage

- **Direction:** the product's direction; instrument or precision for data/dev products; editorial for long-form docs.
- **Plan:** compact hero (H1 "Acme docs", one-line scope, search field or ⌘K hint, quickstart link) → start paths (3–4 task links as a divided list: "Deploy your first app", "Migrate from X") → sections index (grouped link lists in columns, `label-mono` group heads) → popular references → changelog/latest release strip → community/support links.
- **Must:** search reachable in one keystroke (no open animation, `motion` rule 1); every link a real `<a>`; `TechArticle`/`BreadcrumbList` on pages (`seo`); `.md` mirrors and llms.txt (template has them).
- **Layout:** `max-w-wide` shell with sticky sidebar `top-(--header-h)`, content `max-w-prose`, right TOC ≥ 1280px; `section-y` compact.
- **Avoid:** marketing hero art, reveals, big display type (≤ `text-display-sm`).

## Blog index and article

- **Direction:** editorial (or the site's direction with editorial typography rules).
- **Index:** H1 + one-line description → featured post (title, dek, date, reading time; image only if it adds meaning) → dated list (`<ol>` with hairlines: date `label-mono`, title `text-xl`, dek `text-md text-fg-muted`) → tags only if they have ≥ 3 posts → RSS link.
- **Article:** kicker (`label-mono`: category · reading time) → H1 `text-display` max 20ch → italic dek → byline (name, date `<time dateTime>`, updated date if changed) → body `max-w-prose text-lg` (17–19px, lh 1.6–1.7) → figures that break out to `max-w-page` → footnotes → author box → next/previous.
- **Details:** links underlined; code blocks highlighted at build time; headings get anchor links (`[id]` scroll margin is in base); `text-wrap: pretty` (base); pull quotes `text-2xl` italic, `border-l-2 border-brand-line pl-6`.
- **Must:** `BlogPosting` JSON-LD with `dateModified`; OG image per post; answer-first intro paragraph.
- **Avoid:** progress bars, share-button rails, sticky newsletter popups, reveals on paragraphs.

## Pricing page

- **Plan:** H1 stating the model in plain words ("Pay per build minute. Free for personal projects.") → plan columns (archetype 10, 2–4 plans) → full comparison `<table>` → FAQ about billing (archetype 11) → enterprise/contact band.
- **Comparison table:**

```tsx
<div className="overflow-x-auto rounded-xl border border-line-subtle">
  <table className="w-full min-w-[640px] text-left text-sm">
    <thead className="sticky top-(--header-h) bg-bg-subtle">
      <tr className="border-b border-line-subtle">
        <th scope="col" className="px-4 py-3 font-medium text-fg-muted">Limit</th>
        {plans.map((p) => <th key={p.name} scope="col" className="px-4 py-3 font-medium text-fg">{p.name}</th>)}
      </tr>
    </thead>
    <tbody className="divide-y divide-line-subtle">
      {rows.map((r) => (
        <tr key={r.label}>
          <th scope="row" className="px-4 py-3 font-normal text-fg-muted">{r.label}</th>
          {r.values.map((v, i) => <td key={i} className="px-4 py-3 text-fg tabular-nums">{v ?? <span className="text-fg-subtle">—</span>}</td>)}
        </tr>
      ))}
    </tbody>
  </table>
</div>
```

- **Must:** prices with currency and period; what happens at the limit; tax note; annual/monthly toggle (if any) is a real radio group, prices `tabular-nums`, no animated counting; `Offer` JSON-LD only for real public prices.
- **Avoid:** scaled middle card, "Most popular" on a plan nobody chose, hidden "contact us" for everything.

## Changelog

- **Direction:** the product's; typography from precision/editorial.
- **Plan:** H1 "Changelog" + subscribe (RSS/email if real) → entries newest first: date (`<time>`, `label-mono`, sticky left column ≥ lg) · version badge · title `text-2xl` · 1–3 paragraph summary · real screenshot or short muted video (poster, `preload="none"`) · list of fixes → pagination by page, not infinite scroll.
- **Layout:** `grid lg:grid-cols-12`: date column `lg:col-span-3 lg:sticky lg:top-24 lg:self-start`, entry `lg:col-span-8 lg:col-start-5`; `divide-y divide-line-subtle` between entries.
- **Must:** permalink per entry (`id` + anchor link), per-entry OG if pages exist, `dateModified` in sitemap from the newest entry.
- **Signature (optional):** the newest entry's hero media with one `reveal`.
- **Avoid:** confetti, emoji headers, marketing adjectives in fix notes.

## Agency / studio site

- **Direction:** swiss or editorial; occasionally precision (dark) for technical studios.
- **Plan:** poster hero 1c with corner metadata → selected work (large media rows, 1 per viewport, title + client + year + discipline) → capabilities list 5 → clients (real list as text or logos) → approach (3–5 numbered statements) → contact (email, address, hiring link).
- **Signature:** one: masked statement reveal on scroll (`motion/references/recipes.md` §10), or project media with a subtle `view()` parallax (≤ 6%).
- **Must:** `ProfessionalService`/`Organization` + `ItemList` of work only if real (hellohello pattern); time-to-content < 1.5s (no preloader); reduced motion shows everything.
- **Avoid:** smooth-scroll hijack, custom cursor that hides the real one, 8-second intros.


---

<!-- https://void-design.vercel.app/docs/website/section-archetypes · updated 2026-09-16 -->

# Section archetypes

Skeletons use the template primitives and void tokens. Copy, then replace content with real content from `src/content/*`. All are Server Components unless marked.

```tsx
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Container } from "@/components/ui/container";
import { Eyebrow } from "@/components/ui/eyebrow";
import { Section } from "@/components/ui/section";
import { TextLink } from "@/components/ui/link";
import { cn } from "@/lib/cn";
```

Rules for every section: one job; left-aligned unless noted; heading `max-w-[Nch]`; eyebrows on ≤ 1/3 of sections; adjacent sections differ in archetype and rhythm.

---

## 1. Heroes

Measured patterns (24 sites): left H1 + short sub + 1–2 CTAs then real product (Linear, Cursor, Framer); split H1 left / paragraph right (Anthropic, Supabase); centered with product UI (Attio, Clerk, Railway); poster/type-as-image (Granola, Dia, hellohello). Headers 56–72px; H1 46–96px desktop; sub 18–24px, max 480–672px.

### 1a. Editorial left (default when there's no strong visual)

```tsx
<section aria-labelledby="hero-title" className="grain relative overflow-clip border-b border-line-subtle">
  <Container className="pt-16 pb-20 sm:pt-24 lg:pt-36 lg:pb-28">
    <Eyebrow>Open source · MIT · v3.2</Eyebrow>
    <h1 id="hero-title" className="mt-6 max-w-[15ch] text-display-xl text-fg">Feature flags that live in your repo.</h1>
    <div className="mt-8 grid gap-8 lg:grid-cols-12">
      <p className="max-w-[46ch] text-lg text-fg-muted lg:col-span-6">
        Flags are YAML next to the code they gate. Reviews, rollbacks and audit history come from git, not a dashboard.
      </p>
      <div className="flex flex-wrap items-center gap-6 lg:col-span-6 lg:justify-end lg:self-end">
        <Button href="/docs/quickstart" size="lg">Read the quickstart</Button>
        <TextLink href="https://github.com/acme/flags" tone="muted" className="font-mono text-sm">github.com/acme/flags</TextLink>
      </div>
    </div>
  </Container>
</section>
```

### 1b. Product shot (UI is the proof)

```tsx
<section aria-labelledby="hero-title" className="relative overflow-clip">
  <div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-144 bg-[radial-gradient(60%_70%_at_20%_0%,color-mix(in_oklch,var(--brand)_12%,transparent),transparent_70%)]" />
  <Container className="pt-16 lg:pt-28">
    <div className="flex flex-wrap items-end justify-between gap-6">
      <h1 id="hero-title" className="max-w-[16ch] text-display-lg text-fg">Deploy previews for every branch in 14 seconds.</h1>
      <TextLink href="/changelog" tone="muted" className="text-sm">New · Rollback from Slack →</TextLink>
    </div>
    <p className="mt-6 max-w-[44ch] text-lg text-fg-muted">Push a branch, get a URL. Logs, env and rollbacks live on the same page.</p>
    <div className="mt-8 flex flex-wrap gap-3">
      <Button href="/signup" size="lg">Start deploying</Button>
      <Button href="/demo" size="lg" variant="secondary">Book a demo</Button>
    </div>
    <figure className="mt-14 lg:mt-20">
      <div className="overflow-clip rounded-2xl border border-line bg-surface shadow-3">
        <Image src={deployShot} alt="Deploy view listing three branch previews with build times" sizes="(min-width: 1280px) 1136px, 100vw"
          fetchPriority="high" loading="eager" className="h-auto w-full" />
      </div>
    </figure>
  </Container>
</section>
```

Frame rules: real UI, no browser chrome, no glow, no tilt. On mobile crop to the most legible region (`object-left-top` in a fixed `aspect-[4/3]` box) instead of shrinking.

### 1c. Typographic poster (voice-led, agency, launch)

```tsx
<section aria-labelledby="hero-title" className="relative min-h-[80svh] border-b border-line">
  <Container className="flex min-h-[80svh] flex-col justify-between pt-10 pb-10">
    <div className="flex justify-between label-mono text-fg-muted">
      <span>Studio Norte</span><span>Montevideo · est. 2014</span>
    </div>
    <div>
      <h1 id="hero-title" className="sr-only">Studio Norte — brand and product design studio in Montevideo</h1>
      <p aria-hidden="true" className="text-[clamp(3rem,1.2rem+6.2vw,6.75rem)] leading-[0.9] [font-weight:var(--weight-display)] tracking-[-0.03em] text-fg">
        Brands that<br />look like<br /><span className="text-fg-muted">nobody else.</span>
      </p>
      <p className="mt-8 max-w-[40ch] text-lg text-fg-muted">Identity, product and web for 41 companies since 2014.</p>
    </div>
    <div className="flex items-end justify-between label-mono text-fg-subtle"><span>(Scroll)</span><span>Selected work ↓</span></div>
  </Container>
</section>
```

One semantic `<h1>` (visually hidden is fine), the visual lockup `aria-hidden`. Test the lockup at 320px.

### 1d. Split (H1 needs nuance beside it)

```tsx
<Container className="grid gap-10 border-b border-line-subtle pt-16 pb-16 lg:grid-cols-12 lg:pt-32 lg:pb-24">
  <h1 className="text-display-lg text-fg lg:col-span-7">Postgres with branching, for teams that ship daily.</h1>
  <div className="lg:col-span-5 lg:self-end">
    <p className="text-lg text-fg-muted">Every pull request gets its own database copy in under a second. Schema changes merge like code.</p>
    <div className="mt-6 flex gap-3"><Button href="/signup">Start free</Button><Button href="/docs" variant="ghost">Docs</Button></div>
  </div>
</Container>
```

Centered variant: only for a ≤ 3-line H1 + ≤ 2-line sub + product UI below (Attio, Clerk). Everything after the hero goes back to left-aligned.

## 2. Proof strip / logo wall (only real, permitted logos)

```tsx
<section aria-label="Customers" className="border-b border-line-subtle py-10 lg:py-12">
  <Container className="grid items-center gap-8 lg:grid-cols-12">
    <p className="text-sm text-fg-muted lg:col-span-3">Runs production deploys for 1,200 teams, including</p>
    <ul className="grid grid-cols-3 items-center gap-x-10 gap-y-6 text-fg-subtle sm:grid-cols-6 lg:col-span-9">
      {logos.map((l) => (
        <li key={l.name} className="flex justify-center"><l.Svg aria-label={l.name} role="img" className="h-5 w-auto" /></li>
      ))}
    </ul>
  </Container>
</section>
```

- 4–8 logos, SVG with `fill="currentColor"`, equal optical area (wide wordmarks shorter), no hover color bloom unless brand-mixed.
- A specific claim beats "Trusted by". No real logos yet → skip the section; do not use grey placeholder logos.
- Marquee only with 12+ logos and never as the primary proof (`components` → Marquee).

## 3. Product showcase (one screen explains the product)

```tsx
<Section aria-labelledby="showcase-title">
  <Container>
    <h2 id="showcase-title" className="max-w-[24ch] text-display-sm text-fg">
      One timeline for every deploy. <span className="text-fg-muted">Commits, builds, errors and rollbacks in order.</span>
    </h2>
    <figure className="reveal mt-12 lg:mt-16">
      <div className="overflow-clip rounded-2xl border border-line bg-surface shadow-3"><Image src={timeline} alt="…" sizes="(min-width:1280px) 1136px, 100vw" /></div>
      <figcaption className="mt-6 grid gap-6 text-sm text-fg-muted sm:grid-cols-3">
        <p><span className="text-fg">Build logs</span> stream as they run.</p>
        <p><span className="text-fg">Errors</span> link to the commit that caused them.</p>
        <p><span className="text-fg">Rollback</span> is one click or `/rollback` in Slack.</p>
      </figcaption>
    </figure>
  </Container>
</Section>
```

## 4. Feature deep-dive rows (alternating)

```tsx
<Section aria-labelledby="features-title">
  <Container>
    <h2 id="features-title" className="max-w-[20ch] text-display-sm text-fg">What changes on day one</h2>
    <div className="mt-16 space-y-24 lg:space-y-32">
      {features.map((f, i) => (
        <article key={f.slug} className="grid items-center gap-10 lg:grid-cols-12">
          <div className={i % 2 ? "lg:order-2 lg:col-span-5 lg:col-start-8" : "lg:col-span-5"}>
            <p className="label-mono text-fg-subtle">{String(i + 1).padStart(2, "0")} · {f.kicker}</p>
            <h3 className="mt-4 text-2xl text-fg">{f.title}</h3>
            <p className="mt-4 text-md text-fg-muted">{f.body}</p>
            <TextLink href={f.href} className="mt-6 inline-block text-sm">{f.linkLabel}</TextLink>
          </div>
          <div className={i % 2 ? "lg:order-1 lg:col-span-7" : "lg:col-span-7"}>
            <div className="overflow-clip rounded-xl border border-line-subtle bg-surface">{/* real UI crop, code, or diagram */}</div>
          </div>
        </article>
      ))}
    </div>
  </Container>
</Section>
```

2–4 rows. Each visual shows the actual feature, cropped to the relevant 400–600px. Reveal at most the visuals, not the text.

## 5. Editorial capability list (no visuals, many items)

```tsx
<Section aria-labelledby="services-title">
  <Container className="grid gap-10 lg:grid-cols-12">
    <h2 id="services-title" className="text-display-sm text-fg lg:col-span-4">What we do</h2>
    <ol className="divide-y divide-line-subtle border-y border-line-subtle lg:col-span-8">
      {services.map((s, i) => (
        <li key={s.name} className="grid gap-2 py-6 sm:grid-cols-12 sm:gap-6">
          <span className="font-mono text-sm text-fg-subtle tabular-nums sm:col-span-1">({i + 1})</span>
          <h3 className="text-xl text-fg sm:col-span-4">{s.name}</h3>
          <p className="text-md text-fg-muted sm:col-span-7">{s.detail}</p>
        </li>
      ))}
    </ol>
  </Container>
</Section>
```

The honest replacement for "3 icon cards". Measured on hellohello (numbered rows, 1px rules) and Anthropic.

## 6. Bento that isn't slop

```tsx
<Section aria-labelledby="bento-title">
  <Container>
    <h2 id="bento-title" className="max-w-[22ch] text-display-sm text-fg">Everything the on-call engineer opens at 3 a.m.</h2>
    <div className="mt-12 grid gap-4 lg:grid-cols-6 lg:grid-rows-2">
      <Card className="lg:col-span-4 lg:row-span-2 p-0">{/* biggest tile = most important real UI */}</Card>
      <Card className="lg:col-span-2">{/* real metric or chart */}</Card>
      <Card className="lg:col-span-2">{/* real code or config */}</Card>
    </div>
  </Container>
</Section>
```

- 3–6 tiles; spans follow importance; every tile shows something concrete (UI crop, number with unit, code, log).
- Tile text: `text-sm text-fg-muted` caption + `text-lg text-fg` title. No icon-in-circle tiles.
- Uneven heights → independent columns (`flex flex-col gap-4` per column) instead of a stretched row grid.

## 7. Metrics strip (only measured numbers)

```tsx
<section aria-labelledby="numbers-title" className="border-y border-line-subtle py-12 lg:py-16">
  <Container>
    <h2 id="numbers-title" className="label-mono text-fg-muted">Measured on production, Aug 2026</h2>
    <dl className="mt-8 grid grid-cols-2 gap-px overflow-clip rounded-xl border border-line-subtle bg-line-subtle lg:grid-cols-4">
      {metrics.map((m) => (
        <div key={m.label} className="bg-bg p-6">
          <dt className="label-mono text-fg-subtle">{m.label}</dt>
          <dd className="mt-3 text-display-sm text-fg tabular-nums">{m.value}<span className="ml-1 text-xl text-fg-muted">{m.unit}</span></dd>
          {m.source ? <dd className="mt-2 text-xs text-fg-subtle">{m.source}</dd> : null}
        </div>
      ))}
    </dl>
  </Container>
</section>
```

Numbers need a unit, a date/context and a source you can defend. "99.9% uptime" without a status page is fake.

## 8. Quote

```tsx
<Section spacing="default" aria-label="Customer quote">
  <Container className="grid gap-8 lg:grid-cols-12">
    <figure className="lg:col-span-9 lg:col-start-3">
      <blockquote className="text-3xl text-fg lg:text-4xl">
        <p>“We cut our release checklist from 42 steps to 6. Nobody on the team has opened the old runbook since March.”</p>
      </blockquote>
      <figcaption className="mt-8 flex items-center gap-4 text-sm">
        <Image src={avatar} alt="" width={40} height={40} className="size-10 rounded-full" />
        <span><span className="text-fg">Dana Ortiz</span> <span className="text-fg-muted">· Staff Engineer, Northwind</span></span>
      </figcaption>
    </figure>
  </Container>
</Section>
```

One long specific quote > a carousel. No star ratings unless they are real reviews with a source (and then `seo` rules for schema).

## 9. How it works / install (developer products)

```tsx
<Section aria-labelledby="start-title">
  <Container className="grid gap-12 lg:grid-cols-12">
    <div className="lg:col-span-5">
      <h2 id="start-title" className="text-display-sm text-fg">Running in three commands</h2>
      <p className="mt-4 text-md text-fg-muted">No account needed for local use.</p>
    </div>
    <ol className="space-y-6 lg:col-span-7">
      {steps.map((s, i) => (
        <li key={s.cmd} className="grid grid-cols-[2rem_1fr] gap-4">
          <span className="font-mono text-sm text-fg-subtle tabular-nums">{i + 1}.</span>
          <div>
            <p className="text-md text-fg">{s.title}</p>
            <pre className="mt-3 overflow-x-auto rounded-lg border border-line-subtle bg-bg-subtle px-4 py-3 font-mono text-sm text-fg"><code>{s.cmd}</code></pre>
          </div>
        </li>
      ))}
    </ol>
  </Container>
</Section>
```

Add a copy button as a tiny client island. Code highlighting at build time (Shiki), never a client highlighter (`speed`).

## 10. Pricing

```tsx
<Section aria-labelledby="pricing-title">
  <Container>
    <h2 id="pricing-title" className="text-display-sm text-fg">Pricing</h2>
    <div className="mt-12 grid gap-px overflow-clip rounded-xl border border-line bg-line lg:grid-cols-3">
      {plans.map((p) => (
        <div key={p.name} className={cn("flex flex-col bg-bg p-6 lg:p-8", p.recommended && "bg-brand-subtle")}>
          <div className="flex items-center justify-between">
            <h3 className="text-xl text-fg">{p.name}</h3>
            {p.recommended ? <span className="label-mono text-brand-text">Most teams</span> : null}
          </div>
          <p className="mt-6 text-display-sm text-fg tabular-nums">{p.price}<span className="ml-1 text-sm text-fg-muted">{p.per}</span></p>
          <p className="mt-2 text-sm text-fg-muted">{p.summary}</p>
          <ul className="mt-6 space-y-2 text-sm text-fg-muted">{p.features.map((f) => <li key={f}>— {f}</li>)}</ul>
          <Button href={p.href} variant={p.recommended ? "primary" : "secondary"} className="mt-8">{p.cta}</Button>
        </div>
      ))}
    </div>
    <p className="mt-6 text-sm text-fg-muted">Prices in USD, excluding tax. <TextLink href="/pricing#compare">Compare every limit</TextLink></p>
  </Container>
</Section>
```

No scaled-up middle card, no gradient border. Highlight = `bg-brand-subtle` + label. Full comparison as a real `<table>` (`page-types.md`).

## 11. FAQ (real objections only)

```tsx
<Section aria-labelledby="faq-title">
  <Container className="grid gap-10 lg:grid-cols-12">
    <h2 id="faq-title" className="text-display-sm text-fg lg:col-span-4">Questions teams ask before switching</h2>
    <div className="border-t border-line-subtle lg:col-span-8">
      {faqs.map((q) => (
        <details key={q.q} name="faq" className="void-accordion border-b border-line-subtle py-5">
          <summary className="flex cursor-pointer items-center justify-between gap-4 text-md text-fg">{q.q}</summary>
          <p className="pt-3 text-md text-fg-muted">{q.a}</p>
        </details>
      ))}
    </div>
  </Container>
</Section>
```

Answers start with the answer ("Yes. …", "Per build minute. …"). Accordion CSS: `motion/references/recipes.md` §8. Keep the text in the HTML (details content is indexable).

## 12. CTA band

```tsx
<section aria-labelledby="cta-title" className="grain relative overflow-clip border-t border-line-subtle py-24 lg:py-32">
  <Container className="flex flex-col items-start gap-8 lg:flex-row lg:items-end lg:justify-between">
    <h2 id="cta-title" className="max-w-[18ch] text-display text-fg">Your next branch deploys itself.</h2>
    <div className="flex flex-wrap gap-3"><Button href="/signup" size="lg">Start deploying</Button><Button href="/demo" size="lg" variant="ghost">Talk to an engineer</Button></div>
  </Container>
</section>
```

Repeat the primary CTA label exactly. Centering is acceptable here if the hero was left-aligned.

## 13. Footer

```tsx
<footer className="border-t border-line-subtle pt-16 pb-10">
  <Container>
    <div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-12">
      <div className="lg:col-span-4">
        <Logo className="h-5 text-fg" />
        <p className="mt-4 max-w-[32ch] text-sm text-fg-muted">{site.description}</p>
      </div>
      {columns.map((c) => (
        <nav key={c.title} aria-label={c.title} className="lg:col-span-2">
          <p className="label-mono text-fg-subtle">{c.title}</p>
          <ul className="mt-4 space-y-2 text-sm">{c.links.map((l) => <li key={l.href}><a href={l.href} className="text-fg-muted hover:text-fg">{l.label}</a></li>)}</ul>
        </nav>
      ))}
    </div>
    <div className="mt-16 flex flex-wrap justify-between gap-4 border-t border-line-subtle pt-6 text-xs text-fg-subtle">
      <p>© 2026 Acme, Inc.</p><p className="font-mono">All systems normal</p>
    </div>
  </Container>
</footer>
```

Only links that exist. Status text only if it's wired to a real status source.

---

## Page composition checks

- Rhythm alternates dense/airy: hero (airy) → proof (dense, `py-10`) → showcase (airy, `py-24 lg:py-40`) → rows (`section-y`) → metrics (dense) → CTA (airy).
- Background changes (`bg-bg-subtle` bands, `border-y` rules) at most 2–3 times per page.
- No more than one grid of equal cards on the page.
- The fold at 1440×900 shows: H1, sub, CTA, and the start of the signature visual. At 390×844: H1 (≤ 3 lines), sub, CTA.


---

<!-- https://void-design.vercel.app/docs/craft · updated 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


---

<!-- https://void-design.vercel.app/docs/craft/anti-slop · updated 2026-09-16 -->

# Anti-slop: 30 tells with before/after

Each tell reads as "generated" because the measured top sites don't do it. Numbers are from the 24-site teardown (2026-09-15). Run this list against screenshots at 1440 and 390.

## Color

**1. Purple→blue gradients.** 0 of 24 sites put a gradient on a CTA or headline at the fold; Stripe's gradient is an art-directed WebGL ribbon.
```tsx
// before
<a className="bg-gradient-to-r from-indigo-500 to-purple-600 text-white shadow-lg">Get started</a>
// after
<Button href="/signup" size="lg">Start free trial</Button>   // bg-brand, flat, inset highlight
```

**2. Gradient headline text.** (`design/gradient-text`)
```tsx
// before
<h1 className="bg-gradient-to-r from-sky-400 to-fuchsia-500 bg-clip-text text-transparent">The future of work</h1>
// after
<h1 className="max-w-[16ch] text-display-lg text-fg">Standups in 4 minutes. <span className="text-fg-muted">Written by the tickets.</span></h1>
```

**3. Rainbow accents.** Measured accent use ≤ 5% of elements, one hue. (`design/multiple-accents`)
```tsx
// before: <Zap className="text-yellow-500"/> <Shield className="text-green-500"/> <Globe className="text-purple-500"/>
// after:  every icon className="size-4 text-fg-muted"; brand only on the CTA and links
```

**4. Pure black on white + gray-500.** Foregrounds measured: #171717, #141413, #26251e; backgrounds #fafafa, #faf9f5.
```tsx
// before <body className="bg-white text-black"> <p className="text-gray-500">
// after  (nothing on body: base sets bg-bg text-fg) <p className="text-fg-muted">
```

**5. Solid gray borders everywhere.** Measured hairlines are 1px at 5–12% alpha.
```tsx
// before <div className="border border-gray-200 rounded-2xl shadow-md p-6">
// after  <div className="rounded-xl border border-line-subtle p-6">   // or surface-card
```

**6. Glass cards.** Blur measured only on headers (Linear 20px, Mercury 10px) and overlays.
```tsx
// before <div className="backdrop-blur-xl bg-white/10 border border-white/20 rounded-3xl">
// after  <div className="rounded-xl bg-surface border border-line-subtle">
```

**7. Blob/orb hero.**
```tsx
// before <div className="absolute -top-40 left-1/2 size-[600px] rounded-full bg-purple-500/30 blur-3xl" />
// after  one edge light at 12% brand, or none, plus a real product frame or a typographic hero
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-144 bg-[radial-gradient(60%_70%_at_20%_0%,color-mix(in_oklch,var(--brand)_12%,transparent),transparent_70%)]" />
```

**8. Default selection and focus.** 20/24 sites brand the selection. Fix: don't override base (`::selection`, `:focus-visible`); delete any `outline-none`.

## Typography

**9. Default tracking on big headings.** 14/18 sites with ≥ 40px display text use negative tracking. (`design/heading-tracking`)
```tsx
// before <h1 className="text-6xl font-bold tracking-normal leading-tight">
// after  <h1 className="text-display-lg">          // size, lh 0.96, −0.04em×knob, weight from direction
```

**10. Bold display.** 18/23 measured H1s ≤ 600. Remove `font-bold`/`font-extrabold` from headings.

**11. Loose display leading.** Measured 0.85–1.15. Never `leading-normal`/`leading-relaxed` on `text-display*`.

**12. No small-type texture.** Workhorse is 14px; mono labels add technical texture.
```tsx
// before <p className="text-base">Pipelines</p>
// after  <p className="label-mono text-fg-muted">01 · Pipelines</p>
```

**13. Wide grey hero sub.** Measured max-width 480–672px.
```tsx
// before <p className="mx-auto max-w-4xl text-xl text-gray-500">
// after  <p className="mt-6 max-w-[44ch] text-lg text-fg-muted">
```

**14. Orphans.** Base sets `text-wrap: balance` on h1–h4; also cap width: `max-w-[16ch]`.

**15. Too many fonts.** ≤ 2 families + mono (`design/too-many-font-families`, `lint/google-fonts-link`). Delete `<link href="fonts.googleapis.com…">`; use the direction's `next/font` loaders.

## Layout

**16. Everything centered.** Interior headings centered: Linear 1/14, Stripe 1/32.
```tsx
// before <div className="text-center"><h2>…</h2><p className="mx-auto">…</p></div>
// after  <div className="grid gap-6 lg:grid-cols-12"><h2 className="lg:col-span-5">…</h2><p className="lg:col-span-6 lg:col-start-7">…</p></div>
```

**17. The template page** (badge → centered H1 → 2 buttons → glow screenshot → 3 icon cards → logos → testimonials → pricing → FAQ → CTA). Fix: plan sections from the brief with archetypes (`website/references/section-archetypes.md`); each section must have a job.

**18. Three identical icon cards.** None of the top sites uses it as the primary feature explainer.
```tsx
// before
<div className="grid grid-cols-3 gap-8">{features.map(f => <div className="rounded-2xl p-8 shadow"><div className="size-12 rounded-full bg-indigo-100"><f.icon/></div><h3>{f.title}</h3><p>{f.body}</p></div>)}</div>
// after: feature rows with real UI, or an editorial divided list
<ul className="divide-y divide-line-subtle border-y border-line-subtle">
  {features.map((f, i) => (
    <li key={f.title} className="grid gap-3 py-6 lg:grid-cols-12">
      <span className="label-mono text-fg-subtle lg:col-span-2">{String(i + 1).padStart(2, "0")}</span>
      <h3 className="text-xl text-fg lg:col-span-4">{f.title}</h3>
      <p className="text-md text-fg-muted lg:col-span-6">{f.body}</p>
    </li>
  ))}
</ul>
```

**19. Random bento.** Spans must follow importance; each tile shows something real (UI, data, code). If tiles would hold only icon + title + text, use tell 18's list instead.

**20. Uniform section padding.** Measured 96–160px with variation. Replace every `py-20` with the rhythm table in `layout.md`.

**21. Unreadable line length.** `max-w-7xl` paragraphs → `max-w-prose` or `max-w-[60ch]` (`design/line-length-long`).

**22. Generic navbar** (80px, 5 centered links, gradient button). Measured headers 56–72px, CTA solid neutral or brand, small.
```tsx
// after: template SiteHeader — sticky h-(--header-h), text-sm links text-fg-muted, Button size="sm"
```

## Components and details

**23. `rounded-2xl shadow-xl` everywhere.** `shadow-xl` doesn't exist in void (emits nothing) and cards cap at `rounded-xl`.
```tsx
// before <div className="rounded-3xl shadow-2xl p-10">
// after  <Card className="p-6">          // surface-card: radius-xl + hairline + shadow-1
```

**24. Border + shadow + bg change on hover.**
```tsx
// before <a className="border shadow hover:shadow-xl hover:bg-gray-50 hover:border-indigo-500 hover:-translate-y-2">
// after  <a className="press rounded-xl border border-line-subtle p-6 transition-colors duration-(--duration-fast) hover:border-line-strong">
```

**25. Non-concentric corners.** `rounded-2xl` parent with `p-2` and `rounded-2xl` child → `radius-nested` on the parent.

**26. Emoji or circled icons.** (`design/emoji-icons`)
```tsx
// before <h3>🚀 Fast deploys</h3>   <div className="grid size-12 place-items-center rounded-full bg-blue-100"><Rocket/></div>
// after  <h3 className="flex items-center gap-2 text-xl"><Rocket aria-hidden className="size-4 text-fg-muted" strokeWidth={1.5}/>Fast deploys</h3>
```

**27. Hover scale-up and glowing buttons.** Measured press is scale **down** 0.96–0.98; hover is a color step in 100–200ms.
```tsx
// before <button className="transition-all duration-300 hover:scale-105 shadow-lg shadow-indigo-500/50">
// after  <Button>Deploy</Button>        // press utility + bg-brand → hover:bg-brand-hover
```

**28. Fake proof.** Invented logos, "10K+ users", 5-star carousels with stock avatars. Fix: real proof or omit. Placeholder protocol in `website` §3. A single long, specific, attributed quote beats a carousel.

## Motion and copy

**29. Fade-up on every section; `transition-all duration-300`.** (`lint/transition-all`, `smooth/transition-all`)
```tsx
// before <motion.section initial={{opacity:0,y:50}} whileInView={{opacity:1,y:0}} transition={{duration:1}}>
// after  <section className="section-y"> … <figure className="reveal">…</figure>   // 1–3 reveals per page, CSS only
```

**30. Buzzword copy.**
```
before  Unlock the power of AI to supercharge your workflow seamlessly.
after   Turns a 40-minute incident review into a 3-paragraph summary with links to every log line.
```
Banned list and headline formulas: `website/references/copy-and-content.md`.

## Omissions that also betray generation

No `:focus-visible` style · no dark `color-scheme` / flash of wrong theme · lorem ipsum or "John Doe" · no empty/error/loading states · arbitrary values (`p-[18px]`, `text-[#a1a1aa]`, `lint/tw-arbitrary-sprawl`) · mixed icon sets · missing favicon · `<title>` "Create Next App" · 12px tap targets · horizontal scroll at 390px.


---

<!-- https://void-design.vercel.app/docs/craft/color · updated 2026-09-16 -->

# Color

## 1. Roles (what components may use)

| Role | Utility | Rule |
|---|---|---|
| Page | `bg-bg` | set on `html`/`body` by base; don't repeat |
| Band / sidebar / code block | `bg-bg-subtle` | alternate section bands sparingly (≤ 2 per page) |
| Card / panel | `bg-surface` | in light mode often pure white on a tinted page |
| Popover / menu / dialog | `bg-surface-raised` | + `shadow-2` / `shadow-3` |
| Hover / pressed fill | `hover:bg-surface-hover`, `active:bg-surface-active` | one step per state |
| Hairline | `border-line-subtle` | dividers, card outlines on dark |
| Default border | `border-line` | secondary buttons, cards that need an outline |
| Input boundary | `border-line-strong` | form fields: the boundary must reach 3:1 against the surface (WCAG 1.4.11, see `a11y`) |
| Emphasis rule | `border-line-strong` | section-head rules, hovered input |
| Focus | `outline-line-focus` | base layer already applies it |
| Text | `text-fg` / `text-fg-muted` / `text-fg-subtle` | all three pass AA 4.5:1; subtle never for paragraphs (meta, captions, placeholders only) |
| Disabled / decorative text | `text-fg-faint` | 3:1 only — disabled controls and `aria-hidden` decoration, never text someone must read |
| Text on brand solid | `text-fg-on-brand` | amber/lime brands get dark text automatically |
| Brand solid | `bg-brand` → `hover:bg-brand-hover` | primary CTA only (one per viewport) |
| Brand tint | `bg-brand-subtle` + `text-brand-text` + `border-brand-line` | callouts, active nav item, selected row |
| Brand text | `text-brand-text` | links, `$` prompts, one highlighted word max |
| Status | `text-success` `bg-success-subtle`, same for `warning`, `danger` | status only, with icon or label |

Opacity modifiers work on all roles (`bg-bg/80`, `border-brand/40`) because v4 uses `color-mix`.

## 2. Accent budget

- **1 hue.** Measured: Vercel blue on 1–2 text elements; basement orange on 1 element + selection; Anthropic clay on 1 background; the owner's DevEx amber at 1–2% coverage.
- **Where it may appear:** primary CTA, links, focus ring, active nav/tab, selection, caret, one data highlight, one ambient light (≤ 14% mix). Everything else is neutral.
- **Where it may not:** section backgrounds, icon fills in feature lists, headings, borders of every card, badges for decoration.
- **Test:** squint at the 1440 screenshot. If you can count more than ~5 brand-colored spots in the fold, remove some. `design/multiple-accents` and `design/palette-sprawl` catch the rest.
- Status colors must differ from the brand hue by ≥ 30° or in lightness (terminal's amber brand vs yellower warning). Editorial and swiss have red brands: pair danger with an icon and text.

## 3. Neutrals

- Tinted toward the mood hue with chroma 0.002–0.02 (the directions already do). Warm (hue 60–90) for editorial/warm, cool (255–275) for precision/instrument, 0 for swiss.
- Three text tiers, measured: Linear #f7f8f8 / #8a8f98 / #62666d; Cursor fg at 100 / 60 / 40%.
- Pure `#000` canvas is a brand choice, not a default. Allowed only with surfaces ≥ L 18% and visible hairlines (the owner's portfolio uses `#000` + `#0e0e11` cards with a `#272730` hairline).

## 4. Dark mode (separate scale)

| Property | Light | Dark |
|---|---|---|
| Page L | 98–100% | 14–18% |
| Elevation | shadow grows | surface gets lighter (+3% L per level) |
| Lines | fg at 7–20% alpha | white at 6–17% alpha |
| Text | L 19–23% | L 93–96% (never #fff) |
| Muted text | L 45–50% | L 76–79% |
| Brand solid | same L as dark | same L as light (recognition) |
| Brand text | darker than solid | lighter than solid |
| Shadows | 2–3 soft layers | inset top highlight + 1 dark layer on overlays only |

Rules: never write `dark:bg-…` on semantic utilities (duplicated logic); set `color-scheme` (base does); theme-color meta per scheme (template layout reads `tokens.ts`); saturated brand colors vibrate on near-black, so lift L for text use (`--brand-text` does); dim non-photo images `dark:brightness-90`.

## 5. Custom brand accent from a hex

Use when the user gives a brand color. Keep the direction's neutrals; replace only the brand roles.

**Step 1: hex → OKLCH.** Paste into a file and run with Bun (or read the value from oklch.com):

```ts
// hex2oklch.ts — bun hex2oklch.ts "#d83831"
const hex = (process.argv[2] ?? "").replace("#", "");
const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
const C = Math.hypot(A, B), H = ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360;
console.log(`oklch(${(L * 100).toFixed(1)}% ${C.toFixed(3)} ${H.toFixed(1)})  --accent-hue ${H.toFixed(0)} --accent-chroma ${C.toFixed(2)} --s9 ${(L * 100).toFixed(0)}`);
// "#d83831" → oklch(58.4% 0.198 27.7)  --accent-hue 28 --accent-chroma 0.20 --s9 58
```

**Step 2: generate the scale** with the void generator (validates sRGB gamut and prints WCAG + APCA contrast per role):

```bash
# neutral: keep the direction's (read hue/chroma from its --bg); accent: values from step 1
bun packages/tokens/scripts/generate.ts scale \
  --neutral-hue 275 --neutral-chroma 0.018 \
  --accent-hue 28 --accent-chroma 0.20 --s9 58
```

It prints `:root, .light` and `.dark` blocks with 12-step scales and a semantic mapping (`--brand`, `--brand-hover`, `--brand-subtle`, `--brand-line`, `--brand-text`, plus neutrals) annotated with contrast. Outside the void repo, run it from the plugin root: `bun ${CLAUDE_PLUGIN_ROOT}/packages/tokens/scripts/generate.ts scale …`.

**Step 3: override only the brand roles** in `src/app/globals.css`, after the direction import:

```css
@import "../styles/void/directions/precision.css";

/* Brand override: Acme red #d83831. Generated with generate.ts scale (hue 28, C .20, s9 58). */
:root, .light, [data-theme="light"] {
  --brand: oklch(58% 0.2 28);          /* keep the user's exact L for the solid */
  --brand-hover: oklch(55.3% 0.19 28);
  --brand-subtle: oklch(96% 0.018 28);
  --brand-line: oklch(81% 0.08 28);
  --brand-text: oklch(51% 0.17 28);   /* ≥ 4.5:1 on --bg */
  --line-focus: oklch(55% 0.19 28);
}
.dark, [data-theme="dark"] {
  --brand: oklch(58% 0.2 28);
  --brand-hover: oklch(62% 0.18 28);
  --brand-subtle: oklch(27% 0.06 28);
  --brand-line: oklch(47% 0.12 28);
  --brand-text: oklch(76% 0.12 28);
  --line-focus: oklch(70% 0.14 28);
}
```

**Step 4: text on the solid.** If the brand solid L ≥ 70% (amber, yellow, lime, cyan), set `--fg-on-brand` to the direction's near-black `--bg` value; otherwise keep white. Check it: white on the solid ≥ 4.5:1 for 13–16px button text.

**Step 5: collisions.** If the new hue is within 30° of a status hue (red brand vs danger, amber vs warning, green vs success), shift the status hue (warning → hue 85–95) or rely on icon + label. Then run `void lint` and `void design <url>`; `design/multiple-accents` must stay clean.

**Brand gradients:** only same-hue, OKLCH-interpolated, ≤ 60° hue travel: `bg-linear-to-b from-brand to-brand-hover` is fine on one element; purple→pink is not.

## 6. Selection, caret, native controls

Base layer: `::selection` = brand at 32%, `accent-color` and `caret-color` = brand. Don't override per component. Terminal direction may use a solid selection (`::selection { background: var(--brand); color: var(--fg-on-brand) }`), which is the basement/hellohello signature.

## 7. Hex mirrors

Canvas, WebGL, `next/og` images and the manifest can't read `var()`/`oklch()`. Use the generated `src/styles/void/tokens.ts` (from `packages/tokens/scripts/sync-template.ts`) or resolve at runtime (`motion/references/webgl-canvas.md`). Never hand-maintain a second palette.


---

<!-- https://void-design.vercel.app/docs/craft/depth-and-detail · updated 2026-09-16 -->

# Depth and detail

## 1. Radius

`--radius` is the only knob (precision/instrument 6px, editorial 2px, swiss/terminal 0, warm 10px).

| Utility | Factor | precision value | Use |
|---|---|---|---|
| `rounded-xs` | ×0.25 | 1.5px | checkbox inner, tiny tags |
| `rounded-sm` | ×0.5 | 3px | badges, kbd |
| `rounded-md` | ×0.75 | 4.5px | small buttons, inputs sm, nav hover pills |
| `rounded-lg` | ×1 | 6px | buttons, inputs |
| `rounded-xl` | ×1.5 | 9px | cards, popovers |
| `rounded-2xl` | ×2 | 12px | dialogs, large panels, product frames |
| `rounded-3xl` | ×3 | 18px | device mockups, hero media only |
| `rounded-full` | pill | — | avatars, dots, toggles; CTAs only if the brand is pill-shaped everywhere |

Measured most frequent non-pill radius: Stripe 4, Vercel 6, Linear 8, Attio 8, Raycast 11. Pill CTAs on Linear, Vercel, Mercury, Granola. Pick pill **or** radius-lg for buttons and use it on every button.

**Concentric corners:** `outer = inner + padding`.

```tsx
// Card with an inset image: card rounded-xl (9px), padding 6px → image 3px
<div className="radius-nested surface-card">          {/* --nest-r = radius-xl, --nest-p = 6px */}
  <img className="w-full" … />                        {/* radius computed automatically */}
</div>
// Tune: className="radius-nested [--nest-p:--spacing(3)]"
```

## 2. Elevation

| Token | Light (precision) | Dark (precision) | Use |
|---|---|---|---|
| `shadow-1` | 1px contact shadow | inset 1px top highlight only | resting card, secondary button |
| `shadow-2` | ring + 2 soft layers | inset highlight + 1 dark drop | popover, menu, sticky toolbar |
| `shadow-3` | ring + 2 layers incl. 32/64 ambient | ring + highlight + deep drop | dialog, hero product frame, terminal focal element |
| `shadow-(--elevation-highlight)` | inset top light + bottom shade | stronger | primary (brand) buttons |

Editorial has no resting shadows (`shadow-1` is empty); swiss has none; terminal uses hard offsets. Don't fight the direction.

Measured recipes worth knowing (already encoded in the direction tokens; use them to judge overrides):
- Linear secondary button: `inset 0 0 0 1px rgb(255 255 255/.03), inset 0 1px 0 rgb(255 255 255/.04), 0 0 0 1px rgb(0 0 0/.6), 0 4px 4px rgb(0 0 0/.1)`.
- Vercel menu: `0 0 0 1px #00000014, 0 1px 1px #00000005, 0 4px 8px -4px #0000000a, 0 16px 24px -8px #0000000f`.
- Stripe card: `0 30px 45px -30px rgba(50,50,93,.25), 0 18px 36px -18px rgba(0,0,0,.1)` (negative spread, navy tint).
- Principles: ≥ 2 layers (tight contact + wide ambient), y offset only, alpha ≤ 0.08 per layer in light, tinted with the neutral hue, a ring layer for edge definition.

**One separation method per element.** Card on `bg-bg`: `border border-line-subtle` (dark) or `shadow-1` + border (the `surface-card` utility, which counts as one designed recipe). Don't add hover shadows *and* border changes *and* background changes.

## 3. Product frame (hero screenshots, demos)

```tsx
<figure className="relative mt-16 lg:mt-24">
  <div className="overflow-clip rounded-2xl border border-line bg-surface shadow-3">
    <Image src={shot} alt="Acme deploy view showing three branch previews"
      fetchPriority="high" loading="eager" sizes="(min-width: 1280px) 1216px, 100vw" className="h-auto w-full" />
  </div>
  <figcaption className="label-mono mt-4 text-fg-subtle">Real product, staging data</figcaption>
</figure>
```

- Real UI at real size (don't shrink a dashboard to 400px of unreadable pixels; crop instead).
- No browser chrome, no traffic lights, no glow halo, no 3D tilt.
- Dark UI on a light page: frame with `border-line-strong`; light UI on a dark page: `border-line` + `shadow-3` is enough.
- Only the LCP image gets `fetchPriority="high" loading="eager"`; `priority` is deprecated in Next 16 (see `speed`).

## 4. Hairlines and rules

- Divider: `<hr className="border-line-subtle" />` or `border-t border-line-subtle`.
- 0.5px ring on retina (Clerk 342 uses, Attio 209): `shadow-[0_0_0_0.5px_var(--line-strong)]` for small chips only.
- Section-head rule (editorial/swiss): `border-t border-line-strong pt-6`.
- Hairline cell grid: `gap-px bg-line` parent, `bg-bg` cells (see `layout.md`).
- Row lists: `divide-y divide-line-subtle` (hellohello capability rows: 1px #2e2e2e between service rows).

## 5. Focus

- Base: `:focus-visible { outline: 2px solid var(--line-focus); outline-offset: 2px }`. Outline follows `border-radius` in all modern engines.
- Inputs: offset 0 (base does). On a brand-filled button the 2px offset shows the page between button and ring, which reads as a double ring (Vercel/Linear `0 0 0 2px bg, 0 0 0 4px focus`).
- Composite controls (search field + button): `focus-within:outline-2 focus-within:outline-line-focus` on the wrapper.
- Never `outline-none`/`outline-hidden` without a replacement. Every hover effect needs a `focus-visible:` twin.

## 6. Texture

- `grain` utility: SVG noise at `--grain-opacity` (direction-set: 0–0.06), behind content (`z-index:-1`, isolated). Use on the hero section or a large flat band. One grain region per viewport.
- On near-black canvases keep grain ≤ 0.02–0.035: measured on the owner's portfolio, 4.5% grain lifted the gutter mean from 5 to 13.9/255 and read as haze.
- Masked grid/dots (only if the concept is technical; an unmasked full-bleed grid is a template tell):

```tsx
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10
  bg-[linear-gradient(to_right,var(--line-subtle)_1px,transparent_1px),linear-gradient(to_bottom,var(--line-subtle)_1px,transparent_1px)]
  [background-size:48px_48px] [mask-image:radial-gradient(70%_60%_at_30%_0%,black,transparent)]" />
```

- One light source: `bg-[radial-gradient(60%_70%_at_20%_0%,color-mix(in_oklch,var(--brand)_12%,transparent),transparent_70%)]` on an absolutely positioned layer at the top edge. Never two, never multi-hue.
- Scanlines (terminal hero only): `bg-[repeating-linear-gradient(to_bottom,transparent_0_2px,color-mix(in_oklch,var(--bg)_60%,transparent)_2px_3px)] opacity-40`.

## 7. Small details checklist

- [ ] `press` on every button, card link and icon button.
- [ ] `hit-area` on every target under 24px.
- [ ] Icon buttons have `aria-label`; icons `aria-hidden="true"`.
- [ ] Links in prose underlined; nav links not.
- [ ] Kbd hints use `<Kbd>` (mono 11px, border, `shadow-1`); platform key rendered after mount.
- [ ] Selection/caret/accent-color come from base (not overridden).
- [ ] Inner scroll areas: `overscroll-contain [scrollbar-width:thin]`.
- [ ] Skeletons match final layout height; no spinner-only pages.
- [ ] Empty, error and loading states designed for every data view.
- [ ] Favicon + `apple-icon` present (template file conventions).
- [ ] `theme-color` equals `--bg` per scheme.


---

<!-- https://void-design.vercel.app/docs/craft/directions · updated 2026-09-16 -->

# The six directions

Pick exactly one. Each direction file sets the same semantic roles (light + dark), so components never change when the direction does. Source of truth: `packages/tokens/css/directions/<name>.css` (template copy: `src/styles/void/directions/<name>.css`). Read its header comment before building: it lists fonts, signature and don'ts.

## Switching or installing a direction

```css
/* src/app/globals.css — order matters */
@import "tailwindcss";
@import "../styles/void/base.css";
@import "../styles/void/directions/editorial.css";   /* exactly one */
```

```tsx
// src/app/layout.tsx — variable names MUST match the var() hooks in the direction file
import { Newsreader, IBM_Plex_Mono } from "next/font/google";
const serif = Newsreader({ subsets: ["latin"], variable: "--font-newsreader", display: "swap", style: ["normal", "italic"] });
const mono = IBM_Plex_Mono({ subsets: ["latin"], weight: ["400", "500"], variable: "--font-plex-mono", display: "swap" });
// <html className={`${serif.variable} ${mono.variable}`}>
```

Then set `direction: "editorial"` in `src/site.config.ts` (theme-color, OG image and manifest read it) and `theme.default` to the direction's scheme.

## Knobs each direction sets

| Knob | precision | editorial | swiss | warm | terminal | instrument |
|---|---|---|---|---|---|---|
| `--radius` | 6px | 2px | 0 | 10px | 0 | 6px |
| `--weight-display` / `--weight-heading` | 500 / 500 | 400 / 450 | 700 / 700 | 600 / 600 | 600 / 600 | 600 / 600 |
| `--tracking-display` (× base tracking) | 1 | 0.35 | 0.9 | 0.6 | 1.1 | 0.5 |
| `--label-tracking` | 0.08em | 0.1em | 0.06em | 0.06em | 0.12em | 0.06em |
| durations fast / base / slow / slower | 100/150/200/450 | 150/250/400/600 | 0/100/150/300 | 150/220/320/500 | 60/100/150/300 | 100/150/200/300 |
| `--grain-opacity` light / dark | 0 / 0.035 | 0.045 / 0.06 | 0 / 0 | 0.03 / 0.05 | 0 / 0.04 | 0 / 0 |
| brand (light) | indigo `oklch(54.5% .19 272)` | oxblood `oklch(46% .15 22)` | signal red `oklch(56.5% .22 29)` | clay `oklch(55% .155 40)` | amber `oklch(80% .165 72)` (dark text on solids) | blue `oklch(53% .17 255)` |
| default scheme | dark | light | light | light | dark | system |
| extra | — | — | `--ease-out: linear` | — | — | `--section-y` 40→72px, `--container-page` 90rem |

Serif and geometric faces need less negative tracking, which is why editorial is 0.35× and terminal 1.1×.

---

## precision — calm, exact, fast

- **Use:** dev tools, AI/infra products, pro SaaS, technical launch pages. **Avoid:** playful consumer, long-form reading.
- **Measured references:** Linear (Inter 510, −0.022em, #08090a, rgba(255,255,255,.08) hairlines, 8px radius), Vercel (Geist 400, −0.06em, ring-as-border, blue on 2 elements), Raycast.
- **Fonts:** Geist (display 500, UI 13px) + Geist Mono (labels, IDs, kbd). ~51 KB.
  ```tsx
  import { Geist, Geist_Mono } from "next/font/google";
  const sans = Geist({ subsets: ["latin"], variable: "--font-geist-sans", display: "swap" });
  const mono = Geist_Mono({ subsets: ["latin"], variable: "--font-geist-mono", display: "swap" });
  ```
- **Signature details:** `label-mono` as the UI voice; 1px white-alpha hairlines; depth from lighter surfaces and inset top highlight; `Kbd` chips; two-tone H2s; a real product UI (or honest DOM mock) as the hero visual; brand on ≤ 2% of pixels.
- **Don't:** gradient buttons, pill buttons everywhere, serif, illustrations, glass cards, a second accent.

```tsx
<section className="relative overflow-clip border-b border-line-subtle">
  <div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-144 bg-[radial-gradient(60%_70%_at_20%_0%,color-mix(in_oklch,var(--brand)_12%,transparent),transparent_70%)]" />
  <Container className="pt-16 pb-20 lg:pt-32 lg:pb-24">
    <Eyebrow>Changelog · v2.4 shipped</Eyebrow>
    <h1 className="mt-6 max-w-[16ch] text-display-lg text-fg">Deploy previews for every branch in 14 seconds.</h1>
    <p className="mt-6 max-w-[44ch] text-lg text-fg-muted">Push a branch, get a URL. Logs, env and rollbacks live in one place.</p>
    <div className="mt-10 flex flex-wrap items-center gap-6">
      <Button href="/signup" size="lg">Start deploying</Button>
      <code className="font-mono text-sm text-fg-muted">$ npx acme deploy</code>
    </div>
    <div className="mt-16 overflow-clip rounded-xl border border-line bg-surface shadow-3 lg:mt-24">{/* real product UI */}</div>
  </Container>
</section>
```

## editorial — literary, considered, slow

- **Use:** publications, essays, research, studios, premium brands, long-form docs. **Avoid:** dense data apps.
- **Measured references:** Anthropic (ivory #faf9f5, serif body 20px, clay accent on 1 element, split hero), Granola (serif display 86px at 0.93 leading, −0.02em, sections 112–160px), Paco (1.75 body leading, serif italic emphasis).
- **Fonts:** Newsreader (opsz axis: display and text from one file; italic for decks) + IBM Plex Mono (kickers, bylines). Snippet at top of this file.
- **Signature details:** body `text-lg` (17–19px) at 1.6–1.65 in `max-w-prose`; structure from rules (`border-t border-line-strong` on section heads) not boxes; mono uppercase kickers; always-underlined links (`TextLink`, `decoration-brand-line underline-offset-4`); italic deck under the H1; drop cap (`first-letter:float-left first-letter:mr-2 first-letter:text-[3.6em] first-letter:leading-[0.8]`); grain on paper.
- **Don't:** cards, pills, icon grids, sans headlines, gradients, scaling or bouncy motion. Danger shares the red family with the brand: pair it with an icon and text.

```tsx
<Container className="grid gap-10 border-b border-line-strong pt-20 pb-16 lg:grid-cols-12 lg:pt-32">
  <div className="lg:col-span-7">
    <p className="label-mono text-fg-muted">Essay · 14 min read</p>
    <h1 className="mt-5 text-display-xl text-fg">The quiet economics of maintenance</h1>
  </div>
  <p className="text-xl italic text-fg-muted lg:col-span-5 lg:self-end">
    Why the cheapest code is the code you already understand, and what it costs to forget that.
  </p>
</Container>
```

## swiss — rational, confident, poster-like

- **Use:** agencies, architecture, type/design tools, events, portfolios, data journalism. **Avoid:** soft consumer brands.
- **Measured references:** basement.studio (one weight 600 at 0.90 leading, −0.04em, one #ff4d00 accent + selection, radius 0), hellohello (one weight, size-only hierarchy, numbered editorial lists with hairlines), PlanetScale (0 radius, logo table).
- **Fonts:** Schibsted Grotesk (700 display, 400 body) + JetBrains Mono (indices "01 —", captions).
  ```tsx
  import { Schibsted_Grotesk, JetBrains_Mono } from "next/font/google";
  const sans = Schibsted_Grotesk({ subsets: ["latin"], variable: "--font-schibsted", display: "swap" });
  const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jetbrains-mono", display: "swap" });
  ```
- **Signature details:** 12-col grid with label col-span-3 / content col-span-9; flush left, ragged right; `border-line` is full-contrast ink here; numbered sections; huge size contrast (text-sm next to text-display-xl); hover = invert (`hover:bg-fg hover:text-bg`); radius 0; linear, near-instant motion.
- **Don't:** shadows, any rounding, centered body, more than 2 weights, decorative icons.

```tsx
<Container className="grid gap-y-8 border-t border-line pt-6 pb-24 lg:grid-cols-12">
  <p className="font-mono text-sm text-fg-muted lg:col-span-3">01 — Studio</p>
  <h1 className="text-display-xl text-fg lg:col-span-9">We design buildings that age in public.</h1>
  <p className="max-w-[48ch] text-md text-fg-muted lg:col-span-5 lg:col-start-4">Forty-one projects across nine cities since 2011. Housing, libraries, one bridge.</p>
</Container>
```

## warm — approachable, tactile, human

- **Use:** consumer SaaS, productivity/notes, education, health, community. **Avoid:** security/infra, luxury, dense data.
- **Measured references:** Cursor (warm #f7f7f4, fg at 60/50/40%), Family (40–72px device radii, 1s out-expo reveals), Granola (olive accent, positive small-size tracking).
- **Fonts:** Bricolage Grotesque (display ≥ 32px only; never request extra axes: 40 → 128 KB) + Figtree (UI/body) + JetBrains Mono (`preload: false`).
  ```tsx
  import { Bricolage_Grotesque, Figtree, JetBrains_Mono } from "next/font/google";
  const display = Bricolage_Grotesque({ subsets: ["latin"], variable: "--font-bricolage", display: "swap" });
  const sans = Figtree({ subsets: ["latin"], variable: "--font-figtree", display: "swap" });
  const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jetbrains-mono", display: "swap", preload: false });
  ```
- **Signature details:** cards `surface-card` on `bg-bg-subtle` with 24px padding; `bg-brand-subtle text-brand-text` callouts; sentence case; `ease-spring` for panels, `ease-spring-bouncy` only for success moments; grain on hero; one illustration style (duotone brand + fg) if any.
- **Don't:** neon, pure grays, sharp corners, 3D blobs, heavy shadows, Poppins/Nunito.

```tsx
<Container className="grid items-center gap-12 pt-16 pb-20 lg:grid-cols-12 lg:pt-28">
  <div className="lg:col-span-6">
    <h1 className="max-w-[14ch] text-display-lg text-fg">Notes that write the follow-up for you.</h1>
    <p className="mt-5 max-w-[42ch] text-lg text-fg-muted">Granular enough for a 1:1, calm enough for a Monday. Works offline.</p>
    <Button href="/download" size="lg" className="mt-8">Download for Mac</Button>
  </div>
  <div className="surface-card p-6 lg:col-span-6">{/* real note UI */}</div>
</Container>
```

## terminal — raw, fast, honest

- **Use:** CLIs, dev infra, hacker tools, hackathons, developer launch pages. **Avoid:** mainstream audiences, long reading.
- **Measured references:** PlanetScale (system mono, 0 fonts, 1.7 MB page), the owner's DevEx landing (boot-log hero, amber at 1–2% coverage, tmux status line).
- **Fonts:** Geist Mono only (~23 KB). Alt: JetBrains Mono under the same variable name.
  ```tsx
  import { Geist_Mono } from "next/font/google";
  const mono = Geist_Mono({ subsets: ["latin"], variable: "--font-geist-mono", display: "swap" });
  ```
- **Signature details:** `$`/`>` prompts in `text-brand-text`; bracketed actions `[ deploy ]`; hard offset shadow (`shadow-3`) on one focal element; solid 1px borders; amber solids take dark text (`text-fg-on-brand` handles it); warning is yellower so "pending" never reads as the CTA; box-drawing dividers; tabular timestamps; a blinking block caret in CSS `steps(1)`.
- **Don't:** rounded corners, soft shadows, gradients, serif, emoji, glass, typewriter headlines (type the log, not the H1).

```tsx
<Container className="pt-16 pb-20 lg:pt-28">
  <p className="label-mono text-fg-subtle">acme/cli · v3.2.0 · MIT</p>
  <h1 className="mt-6 max-w-[18ch] text-display text-fg">Ship infra from a single YAML file.</h1>
  <div className="mt-10 max-w-xl border border-line bg-surface p-4 font-mono text-sm shadow-3">
    <p><span className="text-brand-text">$</span> curl -fsSL acme.sh | sh</p>
    <p className="text-fg-muted">✓ installed acme 3.2.0 in 4.1s</p>
  </div>
</Container>
```

## instrument — dense, data-first

- **Use:** analytics, admin, finance, ops consoles, internal tools, docs for data products. **Avoid:** marketing landing pages with big type.
- **Measured references:** Attio (14px w500 workhorse, 0.95 display leading, navy-tinted shadows, blue-tinted neutrals), Supabase (oklch tokens, 14px w450).
- **Fonts:** IBM Plex Sans + IBM Plex Mono (weights 400/500).
  ```tsx
  import { IBM_Plex_Sans, IBM_Plex_Mono } from "next/font/google";
  const sans = IBM_Plex_Sans({ subsets: ["latin"], variable: "--font-plex-sans", display: "swap" });
  const mono = IBM_Plex_Mono({ subsets: ["latin"], weight: ["400", "500"], variable: "--font-plex-mono", display: "swap" });
  ```
- **Signature details:** 13–14px UI; 36px table rows; sticky table header on `bg-bg-subtle`; right-aligned `tabular-nums slashed-zero`; delta chips `Badge tone="success"`; panels split by `border-line-subtle`; shadows only on floating layers; filters in the URL; `section-y` is compact (40→72px).
- **Don't:** hero-size type, decorative gradients, scroll reveals, a second accent in chrome (charts use a separate palette).

```tsx
<Container className="py-10">
  <div className="flex items-end justify-between border-b border-line-subtle pb-4">
    <h1 className="text-2xl text-fg">Revenue</h1>
    <p className="font-mono text-xs text-fg-muted tabular-nums">Updated 09:41 UTC</p>
  </div>
  <dl className="grid grid-cols-2 gap-px bg-line-subtle lg:grid-cols-4">{/* KPI tiles: bg-bg p-4 */}</dl>
</Container>
```

## Blending rule

Never blend two directions. If the brief pulls two ways, pick the one matching the **audience** and borrow at most one detail from the other as a documented override in `globals.css` (for example editorial's italic deck inside precision). More than one override means you picked the wrong direction.


---

<!-- https://void-design.vercel.app/docs/craft/layout · updated 2026-09-16 -->

# Layout and spacing

## 1. Containers

| Utility | Width | Use | Measured band |
|---|---|---|---|
| `container-page` | max 76rem (1216px) incl. fluid gutter 20→40px | default marketing page | 1180–1232 (Stripe, Resend, Clerk, Framer, Granola) |
| `max-w-wide mx-auto px-gutter` | 90rem (1440px) | product-dense, full-bleed galleries, instrument | 1392–1408 (Vercel, Attio, Railway) |
| `max-w-prose mx-auto px-gutter` | 65ch | articles, docs body | 640–720 (Paco 640, Emil 692) |
| `max-w-narrow mx-auto px-gutter` | 40rem | forms, auth, short CTA | — |

Template: `<Container>` (page), `<Container width="prose">`, `<Container width="narrow">`.
Never nest containers. Full-bleed backgrounds go on the `<section>`, the container sits inside.

## 2. Vertical rhythm

- `section-y` = `clamp(4rem, …, 8rem)` → 64px at 360, 128px at 1440. `py-section` is the same value as spacing.
- Vary deliberately. Measured: Granola 112 / 128 / 160; Attio 152 top / 112 bottom; Resend uniform 96 (fine at that size). Recipe per page:

| Section | Classes |
|---|---|
| Hero | `pt-16 pb-20 sm:pt-24 lg:pt-32 lg:pb-28` |
| Proof strip right after hero | `py-10 lg:py-12` |
| Standard section | `section-y` |
| Showcase / signature section | `py-24 lg:py-40` |
| Final CTA band | `py-24 lg:py-32` |
| Footer | `pt-16 pb-10` |

- Inside a section: eyebrow → heading `mt-4`/`mt-5`; heading → lead `mt-5`/`mt-6`; lead → content `mt-12`/`mt-16`. Gap above a heading ≥ 2× the gap below it.
- Mobile section padding 60–96px: `section-y` already clamps; don't add `py-8` overrides.

## 3. Grids

- **12 columns, asymmetric spans.** `grid gap-10 lg:grid-cols-12` with `lg:col-span-7` + `lg:col-span-5`, or label `lg:col-span-3` + content `lg:col-span-9`. Gap = 24–40px (`gap-6`–`gap-10`).
- **Intrinsic card grid** (unknown count): `grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(min(18rem,100%),1fr))]`.
- **Hairline grid** (cells separated by 1px rules, PlanetScale/hellohello): `grid gap-px overflow-clip rounded-xl border border-line bg-line sm:grid-cols-2 lg:grid-cols-3` with each cell `bg-bg p-6`. Only when rows are fully packed; a spanning grid with holes shows the line color, so compute spans.
- **Divided list** (editorial rows): `divide-y divide-line-subtle border-y border-line-subtle`, each row `grid gap-4 py-6 lg:grid-cols-12`.
- **Bento:** spans follow importance (the biggest tile shows the most important real UI). Row-aligned bentos stretch small tiles; use independent columns (`flex flex-col gap-4` per column) when heights differ.
- Page shell uses media queries (`sm md lg`); components inside cards use container queries (`@container` on the parent, `@md:flex-row` on children).

## 4. Alignment

- Left edges align across eyebrow, heading, paragraph and CTA row. Everything aligns to something.
- Center only: a short hero lockup (≤ 3 lines H1, ≤ 2 lines sub) or a final CTA band. Measured interior headings centered: Linear 1/14, Vercel 1/18, Stripe 1/32 (`design` audit flags > 35%).
- Display text optically overhangs: `-ml-[0.04em]` on very large H1s so the stem lines up with body text below.
- Icon + label rows: `inline-flex items-center gap-2`; icon `size-4 shrink-0`; nudge ±1px if the glyph sits high.

## 5. Header and footer

- Header: `sticky top-0 z-40 h-(--header-h)` (56px), `border-b border-line-subtle bg-bg/92`, optional `supports-[backdrop-filter]:bg-bg/80 supports-[backdrop-filter]:backdrop-blur-md`. Measured 56–72px. Nav links `text-sm text-fg-muted hover:text-fg`; CTA `Button size="sm"`.
- Floating inset header (Clerk/Granola `top: 8px`): `sticky top-2 mx-auto max-w-page rounded-xl border border-line-subtle bg-bg/85 backdrop-blur-md`. One or the other, not both.
- Anchors land below the header: base sets `scroll-padding-top` and `[id] { scroll-margin-top }` from `--header-h`. If you change the header height, change `--header-h`.
- Footer: only links that exist. 2–4 columns of `text-sm`, `label-mono` column heads, legal row `text-xs text-fg-subtle`. No newsletter box unless there is a newsletter.

## 6. Density and control sizes

| Size | Height | Text | Padding-x | Use |
|---|---|---|---|---|
| `Button size="sm"` | 32px (`h-8`) + `hit-area` | `text-sm` | 12px | header, inline, tables |
| `Button size="md"` | 36px (`h-9`) | `text-sm` | 14px | forms, cards |
| `Button size="lg"` | 44px (`h-11`) | `text-base` | 20px | hero CTA, touch primary |

Measured: nav CTAs 32px, hero CTAs 40–48px, padding 12–24px, weight 500, never bold. Inputs on mobile use ≥ 16px text (`text-md`) or iOS zooms. Table rows 36–40px.

## 7. Responsive checklist

- Test 390 (phone), 768, 1280/1440, and 320 for overflow. No horizontal scroll at 390 (`design/horizontal-overflow`, `a11y/reflow-overflow`).
- Grids collapse to one column below `lg` unless cells are tiny; keep reading order (content first, media second) with `order-*` only when necessary.
- Full-height: `min-h-svh`, never `h-screen`. Flex children with media or long words: `min-w-0`.
- Safe areas: `container-page` already includes `env(safe-area-inset-left)`.
- Sticky breaks under `overflow: hidden` ancestors; use `overflow-clip`.
- Absolute decorative layers: `pointer-events-none absolute inset-0 -z-10` and `aria-hidden="true"` on the parent section with `relative isolate` (or `grain`, which sets isolation).


---

<!-- https://void-design.vercel.app/docs/craft/tailwind-v4 · updated 2026-09-16 -->

# Tailwind v4 (4.3.x): silent failures and the correct form

Tailwind v4 never errors on a class it doesn't know. The page just looks slightly wrong. `void lint` compiles the real CSS entry and diffs used classes against emitted CSS; run it after every styling pass.

## 1. Variables in utilities

| Wrong (v3 habit) | Right (v4) | Why |
|---|---|---|
| `duration-[--duration-fast]` | `duration-(--duration-fast)` | brackets are literal: emits `transition-duration: --duration-fast`, invalid → 0s (`lint/tw-v3-arbitrary-var`). The owner's app had 131 of these. |
| `ease-[--ease-out]` | `ease-out` (theme utility) or `ease-(--ease-out)` | same |
| `bg-[--brand]` | `bg-brand` or `bg-(--brand)` | same |
| `h-[var(--header-h)]` | `h-(--header-h)` | shorter, same output |
| `shadow-[var(--elevation-highlight)]` | `shadow-(--elevation-highlight)` | — |

## 2. Classes that emit nothing in a void project

void's `base.css` removes Tailwind defaults so off-system styling can't slip in:

| Removed | Use instead |
|---|---|
| default palette: `bg-zinc-900`, `text-gray-500`, `border-slate-200`, `from-indigo-500` | semantic roles: `bg-surface`, `text-fg-muted`, `border-line`, `bg-brand` |
| `shadow-sm/md/lg/xl/2xl` | `shadow-1`, `shadow-2`, `shadow-3`, `shadow-(--elevation-highlight)` |
| `text-5xl … text-9xl` | `text-display-sm`, `text-display`, `text-display-lg`, `text-display-xl` |
| `text-primary`, `bg-primary`, `bg-muted`, `text-muted-foreground` (shadcn names) | void roles, or import `styles/void/shadcn.css` if using shadcn components |
| anything you invent: `rounded-card`, `ease-smooth`, `bg-backdrop` | define it in `@theme` first, or use an existing token |

Kept from Tailwind: spacing scale, `font-medium`/weights, `tracking-*`, `leading-*`, `max-w-xl` etc., `rounded-full`, `rounded-none`, `ease-in`/`ease-linear`, numeric `duration-150`. Prefer tokens anyway.

**Adding a token correctly:**

```css
/* globals.css, after the direction import */
@theme {
  --color-highlight: oklch(92% 0.12 95);   /* static value → bg-highlight, text-highlight */
}
@theme inline {
  --color-chart-1: var(--brand);           /* runtime var → must be `inline` so .dark subtrees work */
}
```

Never `--x: var(--x)` in `@theme inline` (self-reference cycle). Runtime names differ from theme names (`--elevation-2` → `--shadow-2`, `--brand` → `--color-brand`).

**Defining a token can break old code:** adding `--color-primary` suddenly styles every `bg-primary` pasted from a registry. After token changes, rebuild and look at untouched pages.

## 3. Config and variants

- **JS config is ignored.** `tailwind.config.ts` does nothing without `@config` (`lint/tw-js-config-ignored`). Delete it; theme lives in CSS. Keyframes pasted into a JS config are dead animations.
- **Dark variant.** Default `dark:` = `prefers-color-scheme`. void base declares `@custom-variant dark (&:where(.dark, .dark *, [data-theme="dark"], [data-theme="dark"] *));` (`lint/tw-dark-variant-os`). With semantic tokens you rarely need `dark:` at all.
- **Hover** is already wrapped in `@media (hover: hover)`, so no sticky hover on touch. Pointer-precision effects: `pointer-fine:`.
- **Reduced motion:** `motion-safe:` / `motion-reduce:`.
- **Starting style:** `starting:opacity-0` compiles to `@starting-style`.
- **Container queries:** `@container` on parent, `@md:grid-cols-2` on children; named `@container/card` + `@lg/card:flex-row`.
- **Supports:** `supports-[backdrop-filter]:backdrop-blur-md`, `supports-[animation-timeline:view()]:…`.
- **Arbitrary variants** for descendants: `[&_p+p]:mt-6`, `[&>svg]:size-4`.

## 4. v3 → v4 renames (paste audit, `lint/tw-v3-renamed`)

| v3 | v4 |
|---|---|
| `shadow-sm` / `shadow` | `shadow-xs` / `shadow-sm` (void: `shadow-1`) |
| `rounded-sm` / `rounded` | `rounded-xs` / `rounded-sm` |
| `blur-sm` / `blur` | `blur-xs` / `blur-sm` |
| `drop-shadow-sm` | `drop-shadow-xs` |
| `outline-none` | `outline-hidden` (`outline-none` now sets `outline-style: none`) |
| `ring` (3px) | `ring-3` (bare `ring` = 1px) |
| `bg-gradient-to-r` | `bg-linear-to-r` (also `bg-radial`, `bg-conic`) |
| `flex-shrink-0` / `flex-grow` | `shrink-0` / `grow` |
| `bg-opacity-50`, `text-opacity-*` | `bg-black/50` style modifiers |
| `decoration-slice` | `box-decoration-slice` |
| `overflow-ellipsis` | `text-ellipsis` |
| default border color gray-200 | `currentColor`: always write `border-line*` |
| `space-y-*` on inline children | prefer `flex flex-col gap-*` (selector changed) |
| `@tailwind base; …` | `@import "tailwindcss";` |
| `theme()` in CSS | `var(--color-…)` |

## 5. Cascade traps

- **Unlayered CSS beats utilities** regardless of specificity. Vendor CSS (`xterm.css`, a pasted component stylesheet) must go in `@layer components` or be scoped.
- **No global `* { border-color: … }`.** It overrides every border that relies on `currentColor` (dashed dividers vanished in the owner's app).
- **`!important` soup** (`lint/tw-important-soup`) means a conflicting global rule; fix the source.
- **Direction overrides** are unlayered `:root` rules, so they correctly beat `@layer theme` defaults. Project overrides go after the direction import.
- **Fonts from `next/font`** are CSS variables on `<html>`; the direction's `--font-sans: var(--font-geist-sans, "Geist"), …` picks them up. If the variable name doesn't match, text silently falls back to system-ui. Compare `layout.tsx` variable names to the direction header.

## 6. Arbitrary values

Allowed: `max-w-[16ch]` (headline measure), one-off gradients built from tokens (`bg-[radial-gradient(…var(--brand)…)]`), `[scrollbar-width:thin]`. Not allowed: colors (`text-[#777]`), off-grid spacing (`p-[13px]`), radii (`rounded-[7px]`) (`lint/tw-arbitrary-sprawl`, `design/spacing-off-grid`). Underscores become spaces inside brackets: `bg-[radial-gradient(60%_70%_at_20%_0%,…)]`.

## 7. Verify

```bash
void lint                      # tw-unknown-class, tw-v3-*, hardcoded-colors, arbitrary sprawl
bun run build                  # next build must pass
bun packages/tokens/scripts/validate.ts   # (void repo only) contract utilities emit CSS in every direction
```


---

<!-- https://void-design.vercel.app/docs/craft/typography · updated 2026-09-16 -->

# Typography

## 1. The void type scale (from `base.css`)

| Utility | Size (360→1440px) | Line-height | Tracking | Use |
|---|---|---|---|---|
| `text-2xs` | 11px | 16px | +0.01em | kbd, tiny meta (`label-mono` uses 11px) |
| `text-xs` | 12px | 16px | +0.01em | captions, table meta, legal |
| `text-sm` | 13px | 20px | +0.003em | **UI workhorse**: nav, buttons, labels, cards |
| `text-base` | 15px | 24px | −0.006em | body default (product, dense marketing) |
| `text-md` | 16px | 26px | −0.008em | marketing body paragraphs |
| `text-lg` | 17→19px | 1.55 | −0.011em | lead / hero sub, long-form body |
| `text-xl` | 19→22px | 1.4 | −0.014em | H3, card titles on marketing |
| `text-2xl` | 22→27px | 1.3 | −0.017em | H3 large, pull quote |
| `text-3xl` | 26→34px | 1.2 | −0.02em | H2 in docs/product |
| `text-4xl` | 30→44px | 1.1 | −0.024em | H2 marketing (compact) |
| `text-display-sm` | 34→54px | 1.04 | −0.028em × knob | H2 marketing |
| `text-display` | 38→66px | 1.0 | −0.034em × knob | H1 product/docs |
| `text-display-lg` | 42→80px | 0.96 | −0.04em × knob | H1 marketing |
| `text-display-xl` | 46→96px | 0.92 | −0.045em × knob | poster hero, statements |

`text-display*` also set `font-weight: var(--weight-display)`. `text-5xl`…`text-9xl` **do not exist** (the default scale is removed) and emit nothing.

Mobile check: display sizes land at 0.5–0.75× desktop (measured mobile H1 28–48px). If a word is longer than ~11 characters, test `text-display-xl` at 320px for overflow.

## 2. What the best sites measured (for judging overrides)

| Site | Display face | H1 px / lh / weight / tracking | Body | Workhorse |
|---|---|---|---|---|
| Linear | Inter Variable (cv01, ss03) | 64 / 1.00 / 510 / −0.022em | 16/24 | mono 14/24 |
| Vercel | Geist | 64 / 1.00 / 400 / −0.06em | 16/24 | 14/20 |
| Stripe | Söhne var (ss01) | 48 / 1.15 / 300 / −0.02em | 16 | 16/20 w300 |
| Resend | Domaine serif | 96 / 1.00 / 400 / −0.01em | Inter 16/24 | mono 12/16 |
| Granola | quadrant serif | 86 / 0.93 / 400 / −0.02em | 16/24 | 14 +0.02em |
| Attio | Inter Display | 64 / 0.95 / 600 / −0.02em | 16/22 −0.01em | 14 w500 |
| basement | Geist | 87 / 0.90 / 600 / −0.04em | 16/24 | — |
| hellohello | NHG Display Bold | 104 / 0.91 / 700 / −0.013em | 16/22.6 −0.04em | 16 w700 |

Takeaways: display lh 0.85–1.15; display tracking −0.01 to −0.06em (median −0.023); weights 300–600; body 16px/1.5; workhorse 13–14px; mono labels +0.1 to +0.15em.

**Tracking by size** (grotesk; serifs need about half):

```
≤ 13px   +0.01 … +0.02em        14–16px  0 … −0.01em
18–24px  −0.005 … −0.015em      32–40px  −0.015 … −0.02em
48–64px  −0.02 … −0.03em        ≥ 72px   −0.03 … −0.05em
uppercase / mono labels  +0.05 … +0.15em
```

**Line-height by size:** display 0.9–1.05; H2 1.0–1.15; H3 1.25–1.3; UI 1.33–1.43; body 1.5–1.65; long-form 1.6–1.75.

## 3. Pairings (all SIL OFL 1.1, on `next/font/google` unless noted)

| Pairing | Character | Direction |
|---|---|---|
| Geist + Geist Mono | engineered, neutral-modern | precision (default), terminal (mono only) |
| Inter (display cut via opsz) + JetBrains Mono | Linear-grade product | precision alt; enable `"cv11","ss01"` |
| Newsreader (opsz 6–72) + IBM Plex Mono | literary, trustworthy | editorial |
| Instrument Serif (≥ 40px only) + Instrument Sans | contemporary editorial | editorial alt; whole headline serif, never one word |
| Source Serif 4 + Source Sans 3 | institutional | editorial alt (research, gov) |
| Schibsted Grotesk + JetBrains Mono | sturdy news grotesk | swiss |
| Bricolage Grotesque (≥ 32px) + Figtree | warm, controlled quirk | warm |
| Fraunces (SOFT 50–100, WONK 0) + Commissioner | soft old-style | warm alt |
| IBM Plex Sans + IBM Plex Mono | technical, dense data | instrument |

**Avoid as defaults** (overused in generated sites): Inter for everything with no features, Poppins, Montserrat, Plus Jakarta Sans, Space Grotesk as body, Nunito. Fontshare fonts (Satoshi, General Sans, Cabinet, Clash) use the ITF Free Font License, which forbids subsetting: do not ship them in open-source repos. Berkeley Mono is not free for editors/terminals/OSS.

**Budget:** ≤ 2 families + mono, ≤ 4 files, variable woff2, `subsets: ["latin"]`, `display: "swap"`. Measured latin woff2: Geist 28.7 KB, Geist Mono 22.6, Inter 47.1, JetBrains Mono 39.5, Instrument Serif 20.5. Never request extra axes without measuring (Inter + opsz 47 → 71 KB; Bricolage all axes 40 → 128 KB). Monospace via `next/font/local` needs `adjustFontFallback: false` (see `speed`).

## 4. Rules with snippets

```tsx
// Hero lockup: width in ch, balance from base layer, sub ≤ 2 lines
<h1 className="max-w-[16ch] text-display-lg text-fg">Deploy previews for every branch.</h1>
<p className="mt-6 max-w-[44ch] text-lg text-fg-muted">Push a branch, get a URL in 14 seconds.</p>

// Two-tone heading (Linear/Stripe): one element, second phrase muted
<h2 className="max-w-[24ch] text-display-sm text-fg">
  Built for review. <span className="text-fg-muted">Every change gets a link, a diff and a rollback.</span>
</h2>

// Mono label voice
<p className="label-mono text-fg-muted">01 · Pipelines</p>

// Stat: tabular, unit smaller, label mono
<p className="text-display text-fg tabular-nums">14<span className="ml-1 text-2xl text-fg-muted">s</span></p>
<p className="label-mono mt-2 text-fg-subtle">median preview build</p>

// Long-form article column
<article className="mx-auto max-w-prose text-lg text-fg [&_p+p]:mt-6 [&_h2]:mt-16 [&_h2]:text-3xl">…</article>
```

- **OpenType:** add per project in `globals.css`, only for faces that have them: `body { font-feature-settings: "cv11", "ss01"; }` (Inter), `"ss01"` (Geist alternates). Numbers that change or align: `tabular-nums`; IDs/code: `slashed-zero`.
- **Italic:** load italics only if used (`style: ["normal","italic"]` costs a file).
- **Emphasis:** by color (`text-fg` vs `text-fg-muted`) and weight (`font-medium`), before size. Max 2 weights in UI text.
- **Uppercase:** only via `label-mono` (tracked). Never uppercase headings or buttons.
- **Links in prose:** always underlined (`TextLink`), `underline-offset-4`, decoration color `brand-line`, hover to `currentColor`.
- **Characters:** `…` `“ ”` `’` en dash `–` for ranges, `&nbsp;` between number and unit, `×` for dimensions.
- **Visually hidden semantic H1** (hellohello pattern) is allowed for an art-directed poster hero: `<h1 className="sr-only">Acme — Design studio in Montevideo</h1>` plus the visual lockup with `aria-hidden="true"`. Exactly one H1 per page (`lint/multiple-h1`).

## 5. Fluid overrides

Only for display sizes, always with a rem term (zoom-safe), max ≤ 2.5× min:

```
slope = (maxPx − minPx) / (maxVw − minVw);  intercept = minPx − slope × minVw
font-size: clamp(min/16 rem, intercept/16 rem + slope×100 vw, max/16 rem)
```

A poster hero bigger than `text-display-xl` (hellohello 7.24vw ≈ 104px at 1440): `text-[clamp(3rem,1.2rem+6.2vw,6.75rem)] leading-[0.9] tracking-[-0.03em]`. Test at 320px.


---

<!-- https://void-design.vercel.app/docs/motion · updated 2026-09-16 -->

# motion — alive, fast, cheap

Motion exists to explain cause and effect, keep spatial continuity, or confirm an action. Rarely, it is delight. Most great sites animate far less than generated ones: Emil Kowalski's homepage animates nothing; Vercel had nothing moving 3.5s after load.
Performance rules for animation live in `speed`. Library effects (marquee, spotlight, border beam, number ticker, aurora) and how to source them live in `components`. This skill decides **what moves, how, and how much**.

Principles below are paraphrased after Emil Kowalski ([animations.dev](https://animations.dev), [emilkowal.ski/ui](https://emilkowal.ski/ui)); recipe portions adapted from `emilkowalski/skills` (MIT, see `references/CREDITS.md`).

## The 12 rules

1. **Frequency decides.** 100+ times a day (⌘K, shortcuts, list arrow keys, tab switching by keyboard): **no animation**. Tens a day (hover, menus): near-instant. Occasional (dialog, toast, drawer): standard. Rare (first visit hero, success moment): delight allowed.
2. **Name the purpose** before writing it: feedback, spatial continuity, state change, explanation, or (rare) delight. "Looks cool" is not a purpose.
3. **UI motion ≤ 300ms.** Press 100–160ms, tooltip 125–200, dropdown 150–250, dialog/drawer 200–500. Marketing entrances may run 300–600ms (measured reveals: Supabase 400, Stripe 500, Granola 600, Mercury 670).
4. **Enter with ease-out, exit ~25% faster.** Never `ease-in` on UI (`lint/ease-in-enter`). Built-in `ease-out` is too weak; void's `ease-out` token is `cubic-bezier(0.23, 1, 0.32, 1)`.
5. **Nothing appears from `scale(0)`.** Start at `scale(0.95–0.97)` + `opacity: 0` (`lint/scale-zero-entry`).
6. **Origin-aware.** Popovers, menus and tooltips grow from their trigger (`transform-origin` from the primitive). Dialogs are the exception: centered.
7. **Transitions for state, keyframes for autonomous motion.** Transitions retarget when interrupted; keyframes restart from zero. Toggles, toasts and tabs use transitions.
8. **Compositor-only.** Animate `transform`/`translate`/`scale`/`rotate` and `opacity`. Never `width`, `height`, `top`, `left`, `margin`, `box-shadow` (`lint/animate-layout-prop`, `smooth/animate-layout-property`). Never `transition: all` (`lint/transition-all`).
9. **Stagger 30–60ms per item, ≤ 6 items, ≤ 300ms total.** Never block input while it plays. Only on first reveal, never on re-render or filter.
10. **Reduced motion = a finished static frame**, never hidden content. Movement goes; short opacity may stay (`lint/no-reduced-motion`, `smooth/reduced-motion-ignored`).
11. **Budget:** one signature effect + at most one secondary per page; T0–T1 by default; ≤ 1 WebGL context; 0 decorative effects on app routes. 1–3 scroll reveals per page, not every section.
12. **Never hide the LCP.** The hero H1 and hero image render visible at first paint. Animate secondary elements, or move the H1 without fading it.

## 1. Tokens

Durations are plain CSS variables (set by the direction; reduced motion zeroes `base`, `slow`, `slower`). Use them with the v4 syntax `duration-(--duration-fast)`, never `duration-[--duration-fast]`.

| Token | base.css | precision | editorial | warm | terminal | Use |
|---|---|---|---|---|---|---|
| `--duration-instant` | 0 | 0 | 0 | 0 | 0 | keyboard-driven, subsequent tooltips |
| `--duration-fast` | 100ms | 100 | 150 | 150 | 60 | press, color, hover, exits |
| `--duration-base` | 150ms | 150 | 250 | 220 | 100 | tooltip, dropdown, tabs |
| `--duration-slow` | 250ms | 200 | 400 | 320 | 150 | dialog, drawer, toast, accordion |
| `--duration-slower` | 500ms | 450 | 600 | 500 | 300 | marketing entrance, page transition |

| Easing utility | Curve | Use |
|---|---|---|
| `ease-out` | `cubic-bezier(0.23, 1, 0.32, 1)` (swiss: `linear`) | enter, exit, press, most UI |
| `ease-in-out` | `cubic-bezier(0.77, 0, 0.175, 1)` | something moving while staying on screen (tab indicator, reorder) |
| `ease-out-expo` | `cubic-bezier(0.19, 1, 0.22, 1)` | marketing entrances, reveals (Family, Raycast-style) |
| `ease-drawer` | `cubic-bezier(0.32, 0.72, 0, 1)` | sheets/drawers (iOS curve; Linear, Vercel, Vaul) |
| `ease-spring` / `ease-spring-bouncy` | `linear()` spring samples | warm panels / success moments only |
| `ease-linear` | linear | marquees, progress, hold-to-confirm fills |
| CSS `ease` | — | simple color/hover fades |

Full decision flow, distance scaling and spring configs: `references/timing.md`.

## 2. Should it animate? (answer in order)

```
Is it triggered by keyboard or used 100+ times/day? ── yes → no animation (0ms)
Does it show cause→effect, continuity or state change? ── no → is it a rare, first-visit moment? ── no → don't animate
Can CSS do it (transition, @starting-style, keyframes, view()) ? ── yes → CSS (T1)
Is it gesture-driven, layout/shared-element, or exit orchestration? ── yes → motion library (T2, LazyMotion)
Is it a generative visual (particles, shader)? ── only as the page's one signature → T3/T4 with webgl-canvas rules
```

## 3. Effect tiers and budgets

| Tier | What | JS | Where | Per-page budget |
|---|---|---|---|---|
| **T0** static CSS | gradients, masks, grain, hairlines, shadows | 0 KB | everywhere | one light, one grain region, masked grids |
| **T1** CSS animated | transitions, keyframes, `@starting-style`, scroll-driven `view()`, View Transitions, `@property` | 0 KB | marketing/docs freely; app routes for state feedback only | ≤ 3 infinite animations per viewport, ≤ 1 of them paint-bound |
| **T2** JS / motion | pointer-driven values, springs, layout/exit, one `useScroll` set piece | rAF 0.3–1 KB; `LazyMotion`+`m` +32 KB; full `motion` +46 KB | marketing: 1 signature + 1 secondary; app: functional only (drawer, toast, reorder) | ≤ 2 T2 islands, one runtime per app |
| **T3** canvas 2D | particle/line fields, animated heatmaps | 2–12 KB own code | marketing hero or product-essential visual | 1 viewport-sized canvas |
| **T4** WebGL | shaders, globes, 3D | raw WebGL2 3–6 KB, `ogl` 10–14 KB, `cobe` 5 KB, `three` 140 KB+ | marketing hero only, never in lists, never on authenticated routes unless it is the product | **1 context, hard** |

Default to T0–T1. A page with a T3/T4 signature has no other T2+ effect. Every infinite animation pauses offscreen (`speed` → PauseOffscreen) and has a reduced-motion frame.

## 4. Interaction patterns (summary; code in `references/recipes.md`)

| Pattern | Spec |
|---|---|
| Button / card press | `press` utility: `scale: 0.97` on `:active`, `--duration-fast`, `ease-out`. Large surfaces (> 400px) 0.99 or none |
| Hover | color/background/border step in `--duration-fast`; lift at most `-translate-y-0.5` on cards; **never** `hover:scale-105`. Gate pointer effects with `pointer-fine:`. Animate a child if the hovered box moves (avoids flicker) |
| Tooltip | open delay 400–700ms; subsequent tooltips instant (no delay, no animation); enter 125–150ms scale 0.97 + fade |
| Dropdown / popover | scale 0.96 → 1 + fade from trigger origin, `--duration-base` in, `--duration-fast` out |
| Dialog | centered scale 0.96 + fade `--duration-slow` in, `--duration-fast` out; backdrop fades with it |
| Drawer / sheet | `translateY(100%)` → 0, `ease-drawer`, 300–500ms; drag: set `transform` directly, dismiss on velocity > 0.11 px/ms |
| Toast | from its edge, transitions not keyframes, stack with `translateY` + `scale(1 − 0.05×i)`; use `sonner` |
| Accordion | `<details>` + `interpolate-size`/`::details-content` (Chromium) or `grid-rows-[0fr]→[1fr]`; `--duration-base` |
| Tabs | one indicator moved with `translate`/`scale`, `ease-in-out`, `--duration-base`; instant when switched by keyboard |
| Command palette | no open/close animation |
| Number change | `tabular-nums`; animate once on first view only (`components` → NumberTicker / NumberFlow) |
| Theme switch | no transition at all: freeze transitions for one frame (template `theme-toggle.tsx`) |

## 5. Load and scroll choreography (marketing)

- **Load:** animate hero text only, once, ≤ 600ms total: eyebrow/sub/CTA rise 8–12px with 40–60ms stagger; the H1 either static or `translate` only (no opacity from 0). Measured: Paco 600ms 7-step enter, Attio 500ms. Never a preloader (hellohello's ~8s intro is the anti-pattern).
- **Scroll:** 1–3 set pieces per page. Use the `reveal` utility (CSS scroll-driven `view()`, 12px rise + fade, runs off the main thread in Chromium and Safari 26; Firefox and reduced motion get the static, fully visible element). Not on body copy, not on every section, never on the hero.
- **Scrubbed or pinned scenes:** at most one per page (`position: sticky` track + CSS `view-timeline`, or one `useScroll`), transform-only, never pinning native scroll with JS.
- **Ambient loops** (marquee, pulse, shimmer): 2–6s pulses, 30–180s marquees, paused offscreen, pausable (WCAG 2.2.2 for > 5s movement), static under reduced motion.

## 6. Scroll-driven animations and View Transitions

**Keyframes own the from-state.** A browser without `animation-timeline` drops the entire
`@supports` block, keyframes included — so any hidden value (`opacity: 0`, a collapsed `scale`,
an off-screen `translate`) that lives *outside* that block is never undone, and the content is
gone permanently for that audience. Firefox has no scroll-driven animation support at all, which
is roughly 3% of the web and 100% of a Firefox user's experience. `smooth/scroll-timeline-no-fallback`
and `lint/scroll-timeline-no-fallback` catch it; the rule, the two-render diff behind the check and
the correct patterns are in `references/fallbacks.md`.

| Feature | Chrome/Edge | Safari | Firefox | Use |
|---|---|---|---|---|
| `animation-timeline: view()/scroll()` | 115 | 26 | ✗ (flag) | enhancement only, inside `@supports (animation-timeline: view())` |
| `@starting-style` + `transition-behavior: allow-discrete` | 117 | 17.5 | 129 | entry animations for popover/dialog/`display` |
| Same-document View Transitions | 111 | 18 | 144 | page/state transitions, enhancement |
| Cross-document `@view-transition` | 126 | 18.2 | ✗ | MPA/Astro only |
| `interpolate-size`, `::details-content` | 129 / 131 | ✗ | ✗ | accordions, enhancement |
| `linear()` easing | 113 | 17.2 | 112 | springs in CSS, safe |

View Transitions: page-level ≤ 400ms (`--duration-slower` or less), ≤ 10 named elements, disabled under reduced motion (base's reduced-motion rule + `speed` snippet). React 19.2 `<ViewTransition>` + `next/link` `transitionTypes` in Next 16: `references/recipes.md` §11.

## 7. CSS or the motion library?

Use CSS unless you need one of: gesture with velocity (drag, swipe, throw), layout / shared-element animation (`layout`, `layoutId`), exit animations of React-unmounted trees with orchestration, or springs that must retarget mid-gesture.

```tsx
"use client";
import { LazyMotion, MotionConfig } from "motion/react";
import * as m from "motion/react-m";
const features = () => import("@/lib/motion-features").then((mod) => mod.default); // export { domAnimation as default } from "motion/react";

export function Sheet({ children }: { children: React.ReactNode }) {
  return (
    <LazyMotion features={features} strict>
      <MotionConfig reducedMotion="user">
        <m.div initial={{ transform: "translateY(100%)" }} animate={{ transform: "translateY(0%)" }}
          transition={{ type: "spring", duration: 0.45, bounce: 0 }}>{children}</m.div>
      </MotionConfig>
    </LazyMotion>
  );
}
```

- `LazyMotion` + `m` (+32 KB measured on Next 16.3.5), never `motion.div` (+46 KB) or `framer-motion`. From `motion/react` import only `LazyMotion`, `MotionConfig`, `domAnimation`/`domMax` and hooks; elements come from `motion/react-m` (this is what `speed` means by banning `motion` imports on marketing routes). One animation runtime per app; no GSAP next to motion.
- Prefer a full `transform` string over `x`/`y`/`scale` shorthands for motion that runs while the page is busy.
- Springs: `{ type: "spring", duration: 0.35–0.5, bounce: 0 }` for UI; bounce 0.1–0.25 only for playful moments. Near-critical damping, no multi-second settles.
- Never `.on("change")` or timers in render (`lint/motionvalue-subscribe-in-render`, `lint/timer-in-render`); per-frame values via MotionValues or `element.style`, never React state.
- Scope `layoutId` with `useId()`.

## 8. Smooth scroll, scroll-jacking, parallax

**Default: none.** No Lenis, locomotive-scroll, GSAP ScrollSmoother or pinning. Why: smooth-scroll libraries move scrolling onto the main thread (any long task freezes scroll), add a permanent rAF loop (~10 KB + CPU), override OS scroll physics and accessibility settings, and break in iframes and nested scrollers. Only agency showreels use them (hellohello, Dia, Framer); zero measured SaaS sites do.

If the user explicitly insists (portfolio/agency showreel): one instance mounted in the root layout; `lerp` ≥ 0.08; disabled under `prefers-reduced-motion` and on touch; destroyed in cleanup; route passes TBT ≤ 100ms (`perf/tbt-high`); nested scroll areas marked `data-lenis-prevent`; document the exception in the design statement.

Parallax: ≤ 6% translate via CSS `view()` on one image per viewport; none under reduced motion.

## 9. Canvas and WebGL

Only as the page's single signature, and only if all of `references/webgl-canvas.md` holds: one context, never in `.map()`, DPR ≤ 1.5 (1 for backdrops), ≤ 30fps, cancel rAF offscreen and when hidden, static frame under reduced motion, context-loss handlers, `next/dynamic` with `ssr: false`, a CSS fallback matching the average color. The WebGL2 hook is in `speed/references/rendering-smoothness.md` §8; the 2D canvas hook and design rules are in `references/webgl-canvas.md`. Try the CSS downgrade first (`inspire/references/effect-downgrades.md`). For a 3D object that moves with the scroll, use OGL (~14 KB gzip) with procedural geometry, never three.js/r3f/Spline on marketing routes (131–520 KB+). Follow `references/3d-scenes.md`.

For section **backgrounds** specifically (a hero or section sitting on an alive surface rather than flat `--bg`) — decision table by evoked feeling and measured cost tier, the cheap CSS/SVG ladder to try first, the one-shared-context multi-section pattern, and paste-ready OGL implementations (fluid distortion, particle field, pixel/dither grid, displacement grid): `references/backgrounds.md`.

## 10. Verify

1. `void lint`: `lint/transition-all`, `lint/animate-layout-prop`, `lint/scale-zero-entry`, `lint/ease-in-enter`, `lint/long-ui-duration`, `lint/no-reduced-motion`, `lint/raf-without-cancel`, `lint/webgl-in-map`, `lint/will-change-static`, `lint/tw-v3-arbitrary-var`.
2. `bun run build`, then `void smooth --start "bun run start -p 3100" --port 3100`: `smooth/long-frames-during-scroll`, `smooth/raf-loop-idle`, `smooth/multiple-webgl-contexts`, `smooth/reduced-motion-ignored`.
3. Watch each animation at 0.25× speed (DevTools → Animations) and once with OS reduced motion on. Check it again the next day with fresh eyes.

## References

- `references/recipes.md` — paste-ready CSS/TSX for every pattern above
- `references/timing.md` — durations, easing decision flow, distance, stagger, springs
- `references/webgl-canvas.md` — when a canvas is justified, 2D hook, token → uniform colors, fallbacks
- `references/3d-scenes.md` — scroll-linked 3D: measured library costs, lazy-loaded OGL `SceneCanvas`, render-on-demand, asset pipeline
- `references/backgrounds.md` — section backgrounds: decision table, cheap-first ladder, shared-context pattern, measured CSS + OGL implementations
- `references/fallbacks.md` — what a browser without scroll-driven animations renders; the keyframes-own-the-from-state rule and the checks that enforce it
- `references/CREDITS.md` — attribution and MIT notice for adapted material


---

<!-- https://void-design.vercel.app/docs/motion/3d-scenes · updated 2026-09-16 -->

# 3D scenes: scroll-linked WebGL that stays fast

Tier T4 in the `motion` effect tiers. Use at most one scene per page, as the page's single signature effect. Measured 2026-09-17 on Next 16.3.5 with the void CLI (research: `~/code/sandbox/void/ui/v3/3d-web.md`).

## Hard rules

1. **Library: OGL + procedural geometry only**, for a scroll-linked marketing hero. Never three.js for this use case (131KB+ gzip vs OGL's 14KB, for zero measured smoothness benefit). Never react-three-fiber/drei for a single hero (adds 100–140KB of reconciler glue on top of three itself — same cost/benefit failure the project already flags in React Bits' Silk/Hyperspeed/LiquidEther). Never Spline (~520KB+ gzip floor before any scene loads, proprietary license). Never model-viewer for a lightweight hero (140–282KB for a web component).
2. **Zero assets by default.** Hand-author geometry + shader displacement. A GLB is a last resort, budgeted ≤150KB after `gltf-transform meshopt`+`quantize`+KTX2, sourced CC0 (Poly Haven/Kenney).
3. **Budget math, stated explicitly in the PR:** Next framework ≈135KB gzip + OGL scene chunk ≈15KB gzip ≈ 150–160KB total — fits the ≤170KB marketing gate. Any three.js-class approach (≈267KB+) fails the gate outright; don't ship it without an explicit, owner-approved budget exception.
4. **Loading:** `next/dynamic(..., { ssr: false })`, gated by `requestIdleCallback` and/or IntersectionObserver so the scene chunk never contends with LCP; skip entirely under `prefers-reduced-motion: reduce` and `navigator.hardwareConcurrency <= 4`. Don't rely on `navigator.connection`/`deviceMemory` as a sole gate — both are Chromium-only and silently no-op on all of Safari.
5. **DPR ≤ 1.5, 1 for a full-viewport backdrop.** FPS capped 30–60 (measured with 45). Resize measured via `ResizeObserver`, never per-frame `getBoundingClientRect`.
6. **Scroll-linking:** one passive `{ passive: true }` scroll listener that only writes `scrollY` into a ref; all math (progress, damping/lerp) happens once per rAF tick, never in the scroll handler. Native scroll is never hijacked/pinned by JS.
7. **Uniforms via refs**, never rebuild the GL program on prop/scroll change.
8. **Pause = cancel, never skip.** `cancelAnimationFrame` (not a no-op draw) on `document.hidden`, when the canvas leaves the viewport, and under reduced motion. A `position: fixed; inset: 0` backdrop never leaves the viewport, so also **render on demand**: stop the loop once damped values settle and restart on scroll, pointer or resize. Don't pause on user inactivity while motion is still settling (`webgl-canvas.md` rule 3).
9. **Cleanup:** dispose geometry/program/renderer, `WEBGL_lose_context.loseContext()`, remove all listeners, on unmount. Handle `webglcontextlost`/`restored`.
10. **Reduced motion → exactly one static frame at current scroll progress, then stop.** Never hide content.
11. **A11y:** `aria-hidden="true" pointer-events-none` on the canvas (decorative); a poster/CSS-gradient fallback is always painted underneath so content and LCP never depend on WebGL succeeding.
12. **One WebGL context per page**, never instantiated inside `.map()`.
13. **Skip WebGPU/TSL** for now — measured +84% bundle cost (241KB vs 131KB gzip for an identical scene) for zero visual gain on a simple scroll-linked object. Revisit only for compute-heavy particle work that WebGL genuinely can't do.
14. **Skip `@react-three/offscreen`** — unmaintained (0.0.8, last published 2023), unresolved Safari worker-WebGL rough edges. Not needed: a capped-rAF, DPR-limited OGL scene is already cheap enough for the main thread that OffscreenCanvas buys little.


## Decision

**Use OGL (Unlicense, ~14KB gzip) with hand-authored procedural geometry — never a GLTF fetch, never three.js, never react-three-fiber, never Spline — for a scroll-linked marketing hero.** This is T4 in void's existing tier table, and it is the only WebGL library option that fits inside the site's JS budget at all:

| Approach | Measured gzip (scene code only) | Real first-load total (with Next framework) | Fits ≤170KB gate? |
|---|---|---|---|
| **OGL + procedural geometry** | **~14–15.4 KB** | **~151–159 KB** | **Yes, with room to spare** |
| three.js (tree-shaken, no loaders) | ~131–132 KB | ~266–274 KB | **No — 1.6× over gate** |
| three + GLTFLoader/Draco/meshopt | ~151–162 KB | ~285–300 KB | No |
| react-three-fiber + three (no drei) | ~237–243 KB | ~370–380 KB | No |
| r3f + drei (2 helpers) | ~262–269 KB | ~395–405 KB | No |
| three/webgpu (WebGPURenderer) | ~241 KB (+84% vs WebGL for identical scene) | ~375 KB | No, and no visual gain |
| @splinetool/runtime | **~520 KB+ floor** before any scene asset loads; proprietary license | n/a | No, disqualified outright |
| @google/model-viewer | ~140–282 KB (ESM vs UMD) | ~275–415 KB | No |
| raw WebGL2, no library | ~0.7 KB | ~136 KB | Yes (but you write everything by hand) |

Loading strategy: `next/dynamic(..., { ssr: false })` behind IntersectionObserver + `requestIdleCallback`, skipped entirely under `prefers-reduced-motion: reduce` and on `navigator.hardwareConcurrency <= 4` (the only cross-browser-reliable device-capability signal — `navigator.connection`/`deviceMemory` are Chromium-only and silently no-op on all of Safari). A static CSS-gradient (or server-rendered AVIF/SVG) poster is always in the DOM underneath the canvas so LCP and content are never gated on WebGL.

Asset budget: **zero assets.** Procedural geometry + a custom vertex/fragment shader (displacement + normal shading) beats any GLB fetch on cost, and avoids the whole gltf-transform pipeline for a simple hero shape. If a real model is ever needed, budget ≤150KB GLB after `gltf-transform meshopt` + `quantize` + `uastc`/`etc1s` KTX2 textures (commands below), and prefer Poly Haven/Kenney CC0 sources.

---

## Measured library costs (2026-09-17, bun 1.2.20 + esbuild 0.28.2, minify + gzip -9)

| Approach | Package @ version | Gzip KB | Notes |
|---|---|---|---|
| raw WebGL2 (no lib) | — | 0.67 KB | hand-written shader setup; size floor |
| **OGL minimal** | **ogl@1.0.11** | **13.75–14.0 KB** | Renderer/Camera/Transform/Program/Mesh/Geometry — matches the design system's existing "ogl 10–14KB" figure |
| three minimal | three@0.186.0 | 131.0–131.9 KB | Scene/Camera/Renderer/Mesh/Geometry/StandardMaterial/light, tree-shaken named imports, no loaders |
| three + GLTFLoader | three@0.186.0 | 151.3–152.5 KB | +~20KB over minimal |
| three + DRACOLoader + MeshoptDecoder | three@0.186.0 | 160.8–162.0 KB | +~10KB glue; decoder .wasm (Draco ~300KB+ raw) fetched separately only if a Draco asset loads |
| three/webgpu (WebGPURenderer) | three@0.186.0 | 241.0 KB | +84% vs WebGL path for an identical simple scene |
| r3f + three (no drei) | @react-three/fiber@9.7.0 | 237.5–243.2 KB | reconciler/scheduler shim adds ~106–112KB over bare three (react/react-dom assumed already on page) |
| r3f + drei (2 helpers) | @react-three/drei@10.7.8 | 262.3–268.6 KB | untree-shaken drei alone is ~500KB gzip — the same "import everything" trap the project already flags in React Bits |

Tree-shaking three barely helps once `WebGLRenderer` is pulled in: bundlephobia's full (non-tree-shaken) `three@0.186.0` is 184.9KB gzip, and a *minimal* named-import scene still lands at 131KB — the renderer's internal shader-chunk/material system is the dominant, largely unavoidable cost.

**Spline** (`@splinetool/runtime@2.0.54`, no OSS license): the entry chunk alone is 36.5KB gzip, but it has 55 static (non-lazy) imports pulling ~484KB more gzip before anything renders — **~520KB gzip floor**, confirmed via `npm pack`. Optional feature chunks (hana-ui GUI 1.07MB gzip, physics.wasm 573KB gzip) are lazy, but the floor alone disqualifies it.

**model-viewer** (`@google/model-viewer@4.3.1`, Apache-2.0): 139.68KB gzip (ESM) / 281.78KB gzip (UMD) for the web component alone.

### Licenses

| Package | Version | License |
|---|---|---|
| ogl | 1.0.11 | Unlicense |
| three | 0.186.0 | MIT |
| @react-three/fiber | 9.7.0 | MIT |
| @react-three/drei | 10.7.8 | MIT |
| @react-three/offscreen | 0.0.8 | MIT, but last published 2023-05-11 — 3+ years stale, unmaintained |
| @splinetool/runtime | 2.0.54 | proprietary |
| @google/model-viewer | 4.3.1 | Apache-2.0 |
| meshoptimizer | 1.2.0 | MIT |
| draco3dgltf | 1.5.7 | Apache-2.0 |

---

## Recommended architecture — paste-ready Next 16 code

`SceneCanvas` (client wrapper: poster, lazy import, impl-agnostic):

```tsx
// src/components/scene-canvas.tsx
"use client";

import dynamic from "next/dynamic";

const SceneGL = dynamic(() => import("./scene-gl"), { ssr: false });

/** Fixed full-viewport 3D backdrop. Poster (CSS gradient here; swap for a
 *  server-rendered AVIF/SVG matching the shader's average color in production)
 *  is always painted first so LCP/content never depend on WebGL. */
export function SceneCanvas() {
  return (
    <div aria-hidden="true" className="pointer-events-none fixed inset-0 -z-10 isolate">
      <div
        className="absolute inset-0"
        style={{ background: "radial-gradient(60% 60% at 50% 40%, #23264a 0%, #0c0d14 55%, #08090c 100%)" }}
      />
      <SceneGL />
    </div>
  );
}
// Mount once, e.g. in the marketing route's layout — gate the import itself
// (not just ssr:false) behind requestIdleCallback/IntersectionObserver if the
// hero isn't the very first thing in the viewport.
```

Shared scroll driver — one passive listener, math done once per rAF tick, never in the scroll handler:

```ts
// src/lib/scroll-progress.ts
export type ScrollState = { y: number };
const state: ScrollState = { y: 0 };
let listenerCount = 0;

function onScroll() { state.y = window.scrollY; }

export function subscribeScroll(): ScrollState {
  if (typeof window === "undefined") return state;
  if (listenerCount === 0) {
    state.y = window.scrollY;
    window.addEventListener("scroll", onScroll, { passive: true });
  }
  listenerCount++;
  return state;
}

export function unsubscribeScroll() {
  listenerCount = Math.max(0, listenerCount - 1);
  if (listenerCount === 0 && typeof window !== "undefined") window.removeEventListener("scroll", onScroll);
}

export function getScrollProgress(y: number): number {
  const max = document.documentElement.scrollHeight - window.innerHeight;
  return max <= 0 ? 0 : Math.min(1, Math.max(0, y / max));
}
```

`SceneGL` — the OGL scene (measured ~15.4KB gzip as a lazy chunk). This is the reference implementation: DPR cap, fps cap, IO + visibility + reduced-motion pause, context loss, full dispose, no per-frame allocation, uniforms updated via refs:

```tsx
// src/components/scene-gl.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { Camera, Color, Mesh, Program, Renderer, Torus, Transform } from "ogl";
import { getScrollProgress, subscribeScroll, unsubscribeScroll } from "@/lib/scroll-progress";

const TARGET_FPS = 45;                         // 30-60fps range per webgl-canvas.md
const FRAME_INTERVAL_MS = 1000 / TARGET_FPS;
const MAX_DPR = 1;                              // 1 for full-viewport backdrops; ≤1.5 for smaller hero stages
const DAMPING = 0.08;

const vertex = /* glsl */ `
  attribute vec3 position;
  attribute vec3 normal;
  uniform mat4 modelViewMatrix;
  uniform mat4 projectionMatrix;
  uniform mat3 normalMatrix;
  uniform float uTime;
  uniform float uProgress;
  varying vec3 vNormal;
  varying float vDisp;
  void main() {
    vec3 pos = position;
    float wave = sin(pos.x * 4.0 + uTime * 0.6) * cos(pos.y * 4.0 + uTime * 0.4 + uProgress * 6.28318);
    float disp = wave * (0.05 + uProgress * 0.05);
    pos += normal * disp;
    vDisp = disp;
    vNormal = normalize(normalMatrix * normal);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
  }
`;

const fragment = /* glsl */ `
  precision highp float;
  uniform vec3 uColorA;
  uniform vec3 uColorB;
  uniform float uProgress;
  varying vec3 vNormal;
  varying float vDisp;
  void main() {
    vec3 light = normalize(vec3(0.4, 0.6, 0.8));
    float diff = max(dot(normalize(vNormal), light), 0.0);
    vec3 base = mix(uColorA, uColorB, uProgress);
    gl_FragColor = vec4(base * (0.35 + diff * 0.85) + vDisp * 1.6, 1.0);
  }
`;

export default function SceneGL() {
  const wrapperRef = useRef<HTMLDivElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const [ready, setReady] = useState(false);

  useEffect(() => {
    const wrapper = wrapperRef.current;
    const canvas = canvasRef.current;
    if (!wrapper || !canvas) return;

    const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
    const renderer = new Renderer({ canvas, dpr: Math.min(window.devicePixelRatio || 1, MAX_DPR), alpha: false, antialias: true, powerPreference: "low-power" });
    const gl = renderer.gl;
    gl.clearColor(0.03, 0.035, 0.047, 1);

    const camera = new Camera(gl, { fov: 35 });
    camera.position.set(0, 0, 6);
    const scene = new Transform();
    const geometry = new Torus(gl, { radius: 1.1, tube: 0.42, radialSegments: 48, tubularSegments: 96 });
    const program = new Program(gl, {
      vertex, fragment,
      uniforms: { uTime: { value: 0 }, uProgress: { value: 0 }, uColorA: { value: new Color("#7c9cff") }, uColorB: { value: new Color("#ff8fd6") } },
    });
    const mesh = new Mesh(gl, { geometry, program });
    mesh.setParent(scene);

    const resize = (w: number, h: number) => { renderer.setSize(w, h); camera.perspective({ aspect: w / h }); };
    resize(window.innerWidth, window.innerHeight);
    const resizeObserver = new ResizeObserver(([entry]) => {
      if (!entry) return;
      const { width, height } = entry.contentRect;
      if (width > 0 && height > 0) resize(width, height);
    });
    resizeObserver.observe(wrapper);                       // measured on resize only, never per-frame getBoundingClientRect

    const scrollState = subscribeScroll();
    let dampedProgress = getScrollProgress(scrollState.y);

    let isIntersecting = true;
    const io = new IntersectionObserver(([entry]) => { isIntersecting = entry?.isIntersecting ?? true; syncLoop(); }, { threshold: 0 });
    io.observe(wrapper);
    const onVisibility = () => syncLoop();
    document.addEventListener("visibilitychange", onVisibility);

    let rafId: number | null = null;
    let lastFrameTime = 0;

    function renderFrame(now: number) {
      const targetProgress = getScrollProgress(scrollState.y);
      dampedProgress += (targetProgress - dampedProgress) * DAMPING;   // damped, not scroll-jacked — native scroll untouched
      program.uniforms.uTime.value = now / 1000;                       // uniforms via refs, never rebuild the program
      program.uniforms.uProgress.value = dampedProgress;
      mesh.rotation.y = dampedProgress * Math.PI * 3 + now * 0.00008;
      mesh.rotation.x = dampedProgress * Math.PI * 0.6;
      mesh.position.y = (dampedProgress - 0.5) * 1.4;
      const scale = 0.85 + dampedProgress * 0.5;
      mesh.scale.set(scale, scale, scale);
      renderer.render({ scene, camera });
    }

    function loop(now: number) {
      rafId = requestAnimationFrame(loop);
      if (now - lastFrameTime < FRAME_INTERVAL_MS) return;             // fps cap
      lastFrameTime = now;
      renderFrame(now);
    }
    function startLoop() { if (rafId === null) rafId = requestAnimationFrame(loop); }
    function stopLoop() { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } }  // cancel, never "skip the draw"
    function syncLoop() {
      const shouldRun = isIntersecting && !document.hidden && !reducedMotionQuery.matches;
      shouldRun ? startLoop() : stopLoop();
    }
    const onReducedMotionChange = () => { if (reducedMotionQuery.matches) { stopLoop(); renderFrame(performance.now()); } else syncLoop(); };
    reducedMotionQuery.addEventListener("change", onReducedMotionChange);

    if (reducedMotionQuery.matches) { renderFrame(performance.now()); } else { renderFrame(performance.now()); syncLoop(); }  // one static frame under RM, then stop
    setReady(true);

    const onContextLost = (e: Event) => { e.preventDefault(); stopLoop(); };
    const onContextRestored = () => syncLoop();
    canvas.addEventListener("webglcontextlost", onContextLost, false);
    canvas.addEventListener("webglcontextrestored", onContextRestored, false);

    return () => {
      stopLoop();
      resizeObserver.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
      reducedMotionQuery.removeEventListener("change", onReducedMotionChange);
      canvas.removeEventListener("webglcontextlost", onContextLost);
      canvas.removeEventListener("webglcontextrestored", onContextRestored);
      unsubscribeScroll();
      geometry.remove();
      program.remove();
      (renderer.getExtension("WEBGL_lose_context") as WEBGL_lose_context | null)?.loseContext();
    };
  }, []);

  return (
    <div ref={wrapperRef} className="absolute inset-0">
      <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none h-full w-full transition-opacity duration-500" style={{ opacity: ready ? 1 : 0 }} />
    </div>
  );
}
```

Full prototype (both OGL and three.js variants, plus the 5-section test page): `/home/parth/code/sandbox/void/ui/v3/3d-scratch/`.

---

## Asset pipeline (only if a GLB is ever actually needed)

Prefer procedural geometry (above) — zero network requests, zero decoder wasm. If a real model is required:

```bash
gltf-transform inspect model.glb                                        # geometry- vs texture-heavy?
gltf-transform meshopt model.glb model.opt.glb                          # geometry/morph/animation compression, lighter decoder than Draco
gltf-transform draco model.glb model.opt.glb                            # alternative: Draco geometry compression
gltf-transform quantize model.opt.glb model.opt.glb                     # quantize attribute precision
gltf-transform uastc model.opt.glb model.final.glb                      # textures → KTX2/Basis (uastc = quality, etc1s = smaller)
# or the one-shot bundle:
gltf-transform optimize model.glb model.final.glb --compress meshopt --texture-compress webp
```

Below ~1MB of geometry, WASM decoder overhead can outweigh savings — meshopt's decoder is much lighter than Draco's, prefer it for small hero objects. Target ≤150KB final GLB. CC0 sources: Poly Haven (polyhaven.com/models) and Kenney (kenney.nl/assets?q=3d) — both no-login, CC0.

---


---

<!-- https://void-design.vercel.app/docs/motion/CREDITS · updated 2026-09-16 -->

# Credits and licences

## Emil Kowalski

The motion principles in this skill (frequency-of-use gate, ease-out for UI, no `scale(0)`, origin-aware popovers, transitions vs keyframes, press feedback, instant subsequent tooltips, exit faster than enter, reduced motion as "fewer and gentler") follow Emil Kowalski's work. They are written here in void's own words, with void tokens and measurements.

Further reading (not copied; read the originals):
- Course: [Animations on the Web](https://animations.dev)
- [7 Practical Animation Tips](https://emilkowal.ski/ui/7-practical-animation-tips)
- [Great Animations](https://emilkowal.ski/ui/great-animations)
- [Good vs Great Animations](https://emilkowal.ski/ui/good-vs-great-animations)
- [You Don't Need Animations](https://emilkowal.ski/ui/you-dont-need-animations)
- [Agents with Taste](https://emilkowal.ski/ui/agents-with-taste)
- [Building a Toast Component](https://emilkowal.ski/ui/building-a-toast-component) (Sonner)
- [Building a Drawer Component](https://emilkowal.ski/ui/building-a-drawer-component) (Vaul)
- [The Magic of Clip Path](https://emilkowal.ski/ui/the-magic-of-clip-path)

Portions of `recipes.md` marked † (press, popover, tooltip, modal, drawer, toast mount, stagger, hold-to-confirm, tab clip-path, blur crossfade) and the easing/duration tables in `timing.md` are adapted from [emilkowalski/skills](https://github.com/emilkowalski/skills) (`skills/animate/RECIPES.md`, `skills/review-animations/STANDARDS.md`), modified to use void tokens, Radix/Base UI/native variants and void's reduced-motion rules. That repository is licensed as follows:

```
MIT License

Copyright (c) 2026 Emil Kowalski

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```

No content from the paid courses (animations.dev, aiforui.dev) is used.

## Measurements

Durations, easing frequencies and site behaviour come from void's own teardown of 24 sites (headless Chromium 149, 2026-09-15) and the owner's shipped apps.

## Other

- Radix Colors lightness recipe (via `packages/tokens/scripts/generate.ts`): https://www.radix-ui.com/colors
- Motion bundle measurements (LazyMotion +32 KB, motion +46 KB): void performance research, Next 16.3.5 production build.


---

<!-- https://void-design.vercel.app/docs/motion/backgrounds · updated 2026-09-16 -->

# Section backgrounds: alive surfaces that stay fast

The owner's ask, verbatim: sections should sit on "real, alive backgrounds", not flat `--bg`,
pointing at React Bits' Backgrounds category (Liquid Ether, Ballpit, Pixel Snow, Grid Distortion,
"and so many more") as the quality bar. This doc is the decision-first reference: what to reach
for per section, the cheap tier to try before WebGL, and paste-ready implementations for the T4
tier, all measured with void's own CLI rather than asserted.

**Licence, restated because it is load-bearing here:** React Bits and Animate UI are MIT +
Commons Clause (no redistributing "a ported version"); Aceternity forbids redistributing ports
outright. Every implementation below is written from scratch from the underlying technique
(value-noise domain warping, ordered/Bayer dithering, GPU-driven particle motion, vertex-shader
grid displacement — decades-old, unowned maths) — never a transcription of any library's source.
See `components` skill rule 1 and its licence matrix.

Full WebGL hard rules (context budget, DPR, fps, pause/cancel, cleanup, reduced motion) live in
`references/3d-scenes.md` and `references/webgl-canvas.md` — this doc doesn't restate them, it
applies them to *backgrounds* specifically and links back per recipe.

## 1. Decision table

Cost tiers are **measured**, not guessed — see §7. "Evokes" is the feeling a great-sites teardown
would name it as; use that word, not the library name, when picking one for a section.

| Effect | Evokes | Tier | Measured cost | Use when | Don't use when |
|---|---|---|---|---|---|
| Dot / line grid | structure, precision, blueprint | T0 (static) / T1 (drift) | 0 KB JS, 0% dropped both profiles | dev-tool/infra hero or footer, a section that needs quiet texture under dense text | you already have a grain layer in the same viewport (two textures reads as noise) |
| Gradient mesh (aurora) | warmth, depth, "something is alive here" | T1 | 0 KB JS; **5.3% dropped desktop** when 3 blurred layers share a scroll pass with other effects (§7) | one hero, alone, one instance per page | stacked with grain/dots in the same viewport, or on a route with any other infinite T1 animation (budget is ≤3 infinite/viewport, ≤1 paint-bound — a blurred layer counts as paint-bound) |
| Pointer spotlight | focus, "this responds to you" | T1/T2, ~0.3 KB | rAF-written CSS var, 0 React renders | a card grid or a single feature section, `pointer-fine` only | touch-primary routes (no pointer to follow — ship the static fallback) |
| Grain (`grain` utility, already in `packages/tokens/css/base.css`) | material, film, "not flat" | T0 | 0 KB, existing SVG turbulence data URI | any surface that reads too flat/plasticky | opacity above the direction's `--grain-opacity` ceiling (0.02–0.06) — checked, don't hand-tune per section |
| **Fluid / liquid distortion** (FluidEther) | premium, organic, "something is moving underneath" | T4 | +20.1 KB gzip JS · **did not hold the desktop dropped-frame budget in testing** (§7) | the single hero, alone, nothing else T2+ on the page | any secondary section, any route with text-heavy scroll below it, low-power devices (already gated) |
| **Particle field** (ParticleField) | depth, atmosphere, ballpit/snow energy | T4 | +19.4 KB gzip JS · 0% dropped both profiles, p95 16.7–16.8ms | a hero or one mid-page "moment" section, sparse text over it | list/table routes, anywhere near 100+/day interaction |
| **Pixel / dither grid** (PixelDither) | retro-technical, terminal energy, snow/static | T4 | +19.7 KB gzip JS · 0% dropped both profiles, p95 16.7–16.8ms | `terminal`/`precision` direction hero, a CLI or dev-tool launch section | `editorial`/`warm` directions (fights the material) |
| **Displacement grid** (DisplacementGrid) | interactive structure, "the grid reacts to you" | T4 | +19.6 KB gzip JS · 0% dropped both profiles, p95 16.7–16.8ms | a hero over a grid direction (`swiss`, `instrument`), pointer-fine audiences | touch-only routes (the whole point is the pointer warp) |
| **Shared host, 2 looks, 1 context** (BackgroundHost) | page-level continuity across several sections | T4 (one context) | +17.6 KB gzip JS combined · 0% dropped both profiles | a page that wants 2–3 *different* background looks across sections without a second WebGL context | a page where each section's look should load independently lazily (see §7 caveat — wire this one behind `requestIdleCallback` in production, not eagerly) |

Everything in this table is provided; nothing else needed a WebGL context to reach the React Bits
"Backgrounds" vocabulary — aurora/beams is the gradient mesh, dot/line grids are T0, grain is
already in the base layer.

## 2. Selection rules

1. **At most one expensive (T3/T4) background live at a time**, page-wide — this is `motion`'s
   existing "1 WebGL context, hard" rule applied to backgrounds specifically. Two sections that
   both want WebGL share one context via `BackgroundHost` (§6); they never mount two canvases.
2. **Sections don't repeat the same effect.** Pick by role, not habit: hero gets the one T4 (or a
   T1 gradient mesh if the page has no WebGL budget left), a mid-page "moment" section gets a T0/T1
   texture (dot grid, spotlight), the footer/CTA gets grain or nothing. Three sections in a row
   with the same look reads as a template, the exact tell `craft`'s anti-slop checklist flags for
   card grids (#17–19) — the same logic applies to backgrounds.
3. **Text-heavy sections get T0/T1 only**, never T3/T4. A live WebGL surface under a paragraph of
   body copy fights reading; reserve motion for hero/moment sections where the visual *is* the
   content. `craft` rule 3 (one accent, rationed) extends here: rationing applies to *motion*, not
   just colour.
4. **A hero survives losing its background entirely** (§4's poster/reduced-motion rule) — if the
   page reads worse with the canvas removed than with it, the H1 and CTA aren't carrying enough on
   their own; fix the copy/type before adding more visual.
5. **Pick the cheapest tier that carries the idea.** Run the ladder in §5 before reaching for §6.
   Most sections should land in §5 and never spend a WebGL context at all.

## 3. The non-negotiables

Every implementation below has these wired in, not left as a TODO. Stated once here, linked from
each recipe rather than repeated per file:

- **One shared WebGL context for the whole page** — `BackgroundHost` (§6), never a second canvas.
- **IntersectionObserver + `visibilitychange` pause that cancels the rAF** (not "skip the draw") —
  built into `use-gl-canvas.ts` (§6.1); this is `3d-scenes.md` rule 8/`webgl-canvas.md`'s top rule.
- **DPR ≤ 1.5** (1 for a true full-bleed backdrop) — and for a *full-bleed fragment shader*
  specifically, DPR alone isn't enough (§7's measured finding): also cap **render scale ≤ 0.6**
  (internal resolution below CSS size) for anything that shades every pixel every frame.
- **Render-on-demand where the effect allows** — not implemented below (every effect here is
  continuously alive by design, e.g. a fluid field never "settles"); if you adapt one of these into
  something that damps to a resting state (like `3d-scenes.md`'s scroll-linked object), stop the
  loop once damped values converge, same as that reference does.
- **No per-frame allocation, uniforms via refs** — colours resolve once per direction/theme change
  (`use-token-colors.ts`, §6.2), never per frame; particle/grid positions are closed-form functions
  of `uTime` evaluated in the vertex shader, never CPU-side arrays rewritten per frame.
- **Lazy after idle + intersection** — `use-lazy-webgl.ts` (§6.3): nothing WebGL imports before
  `requestIdleCallback` *and* the section is within 200px of the viewport.
- **Poster/static paint first** — the CSS gradient (or `var(--bg)`) underneath every canvas paints
  immediately; LCP measured 624–672ms across every route in §7, i.e. it never waits on WebGL.
- **Full static fallback** on `prefers-reduced-motion: reduce`, `navigator.hardwareConcurrency <= 4`,
  and `connection.saveData` — the poster simply stays; nothing WebGL ever imports.
- **Zero binary assets** — every effect here is procedural (noise/dither/closed-form motion), no
  texture fetch, no video.
- **Context-loss handling** (`webglcontextlost`/`restored`) and **full dispose on unmount**
  (geometry, program, `WEBGL_lose_context.loseContext()`, every listener) — in `use-gl-canvas.ts`.

## 4. Contrast: text over a live background

Text on top of any effect in this doc must still hit the tiers in `a11y` (§"Color and contrast"):
body ≥ 4.5:1, large/display text ≥ 3:1. **Never demote text to `--fg-faint` to "solve" a busy
background** — that token is 3:1 and reserved for disabled/decorative content, not a contrast
workaround.

Use a scrim between the background and the content instead:

```tsx
// src/components/fx/backgrounds/scrim.tsx
/**
 * Scrim -- guarantees text contrast over ANY live background. Put it between
 * the background and the content. `full` = flat overlay for text-heavy
 * sections; default = bottom-weighted gradient for a hero where the top
 * stays alive.
 */
export function Scrim({ full = false, className = "" }: { full?: boolean; className?: string }) {
  return <div aria-hidden="true" className={`bg-scrim ${full ? "bg-scrim--full" : ""} ${className}`} />;
}
```

```css
/* in backgrounds.css, @layer components */
.bg-scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(to bottom,
    color-mix(in oklch, var(--bg) 55%, transparent) 0%,
    color-mix(in oklch, var(--bg) 82%, transparent) 60%,
    var(--bg) 100%);
  pointer-events: none;
}
.bg-scrim--full { background: color-mix(in oklch, var(--bg) 72%, transparent); }
```

Built from `--bg`, so it re-tints per direction automatically. Measure with `void a11y` after
adding any background — `a11y/color-contrast` catches a scrim that's too thin.

## 5. Cheap-first ladder (try this before §6)

Most sections should stop here. In order of reach:

1. **Grain** — already in `packages/tokens/css/base.css` as the `grain` utility (SVG
   `feTurbulence`, opacity from `--grain-opacity`, 0 KB, procedural). Don't reimplement it.
2. **Dot / line grid** — `background-image: radial-gradient(...)` tiled, masked to fade at the
   edges. Optional 40s `transform` drift, paused under reduced motion.
3. **Gradient mesh (aurora)** — 2–3 blurred radial layers, one hue family
   (`color-mix(in oklch, var(--brand) N%, transparent)`), drifting via `transform` only
   (compositor-only, never `background-position`). This *is* void's `AuroraBackdrop` recipe
   (`components/references/recipes.md`) — same effect, cross-referenced here because it's the
   direct cheap answer to React Bits' Aurora/Silk.
4. **Masked radial spotlight** — pointer-follow glow, position written via `requestAnimationFrame`
   into a CSS custom property (never React state, never a per-pixel-move re-render).
5. **Scroll-driven CSS** — `animation-timeline: view()` for a reveal that only plays once per
   section; not for continuous ambient motion (that's what marks a background as alive, not what
   makes a heading appear).

```tsx
// src/components/fx/backgrounds/dot-grid.tsx -- T0/T1, zero JS (Server Component)
export function DotGrid({
  variant = "dots", cell = 28, opacity = 0.5, drift = false, className = "",
}: { variant?: "dots" | "lines"; cell?: number; opacity?: number; drift?: boolean; className?: string }) {
  return (
    <div
      aria-hidden="true"
      className={`bg-grid ${variant === "lines" ? "bg-grid--lines" : ""} ${drift ? "bg-grid--drift" : ""} ${className}`}
      style={{ ["--bg-grid-cell" as string]: `${cell}px`, ["--bg-grid-opacity" as string]: opacity }}
    />
  );
}
```

```tsx
// src/components/fx/backgrounds/gradient-mesh.tsx -- T1, zero JS (Server Component)
export function GradientMesh({ className = "" }: { className?: string }) {
  return (
    <div aria-hidden="true" className={`bg-mesh ${className}`}>
      <span className="bg-mesh__blob" />
    </div>
  );
}
```

```tsx
// src/components/fx/backgrounds/spotlight-follow.tsx -- T1/T2
"use client";
import { useEffect, useRef } from "react";

/** Writes --spot-x/-y via a ref-driven rAF loop, never React state, so
 *  pointermove never causes a re-render. Fine-pointer only; the CSS default
 *  (top-center spot) is the no-JS fallback. */
export function SpotlightFollow({ className = "" }: { className?: string }) {
  const ref = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    const el = ref.current;
    if (!el || !window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
    let raf = 0;
    let pending: { x: number; y: number } | null = null;
    const flush = () => {
      raf = 0;
      if (!pending) return;
      el.style.setProperty("--spot-x", `${pending.x}px`);
      el.style.setProperty("--spot-y", `${pending.y}px`);
    };
    const onMove = (e: PointerEvent) => {
      const rect = el.getBoundingClientRect();
      pending = { x: e.clientX - rect.left, y: e.clientY - rect.top };
      if (!raf) raf = requestAnimationFrame(flush);
    };
    el.addEventListener("pointermove", onMove, { passive: true });
    return () => { el.removeEventListener("pointermove", onMove); if (raf) cancelAnimationFrame(raf); };
  }, []);
  return <div ref={ref} aria-hidden="true" className={`bg-spotlight ${className}`} />;
}
```

```css
/* src/styles/backgrounds.css -- import after the direction file, before fx.css. Tokens only. */
@layer components {
  .bg-grid {
    position: absolute; inset: 0;
    background-image: radial-gradient(circle at 1px 1px, var(--line-strong) 1px, transparent 0);
    background-size: var(--bg-grid-cell, 28px) var(--bg-grid-cell, 28px);
    mask-image: radial-gradient(120% 100% at 50% 0%, black 30%, transparent 85%);
    opacity: var(--bg-grid-opacity, 0.5);
  }
  .bg-grid--lines {
    background-image:
      linear-gradient(to right, var(--line-subtle) 1px, transparent 1px),
      linear-gradient(to bottom, var(--line-subtle) 1px, transparent 1px);
  }
  .bg-grid--drift { animation: bg-grid-drift 40s linear infinite; }
  @keyframes bg-grid-drift { to { transform: translate(var(--bg-grid-cell, 28px), var(--bg-grid-cell, 28px)); } }
  @media (prefers-reduced-motion: reduce) { .bg-grid--drift { animation: none; } }

  /* 3 soft radial blobs, one hue family, translated on the compositor only. */
  .bg-mesh { position: absolute; inset: -10%; overflow: hidden; isolation: isolate; pointer-events: none; }
  .bg-mesh::before, .bg-mesh::after, .bg-mesh > .bg-mesh__blob {
    content: ""; position: absolute; width: 55%; height: 55%; border-radius: 50%;
    filter: blur(60px); will-change: transform;
  }
  .bg-mesh::before {
    top: 5%; left: 8%; background: color-mix(in oklch, var(--brand) 26%, transparent);
    animation: bg-mesh-drift-a 26s var(--ease-in-out) infinite alternate;
  }
  .bg-mesh::after {
    bottom: 0%; right: 5%; background: color-mix(in oklch, var(--brand) 14%, transparent);
    animation: bg-mesh-drift-b 32s var(--ease-in-out) infinite alternate;
  }
  .bg-mesh > .bg-mesh__blob {
    top: 30%; right: 25%; width: 40%; height: 40%;
    background: color-mix(in oklch, var(--surface-raised) 70%, var(--brand) 10%);
    animation: bg-mesh-drift-a 22s var(--ease-in-out) infinite alternate-reverse;
  }
  @keyframes bg-mesh-drift-a { from { transform: translate(0, 0) scale(1); } to { transform: translate(6%, 8%) scale(1.08); } }
  @keyframes bg-mesh-drift-b { from { transform: translate(0, 0) scale(1); } to { transform: translate(-8%, -5%) scale(1.05); } }
  @media (prefers-reduced-motion: reduce) {
    .bg-mesh::before, .bg-mesh::after, .bg-mesh > .bg-mesh__blob { animation: none; }
  }

  .bg-spotlight {
    position: absolute; inset: 0;
    background: radial-gradient(480px circle at var(--spot-x, 50%) var(--spot-y, 15%),
      color-mix(in oklch, var(--brand) 16%, transparent), transparent 70%);
    transition: background-color var(--duration-fast) var(--ease-out);
  }
}
```

## 6. WebGL tier (T4): shared infrastructure

`OGL` (Unlicense, ~14 KB gzip — already the house choice per `3d-scenes.md`; not a dep of
`templates/next` today, add it the same way that reference's `SceneCanvas` assumes). All four
effects in §7 build on two small hooks so the "one context" and "lazy load" rules live in one
place instead of four.

### 6.1 `use-gl-canvas.ts` — one context, every non-negotiable, per call site

```ts
// src/components/fx/backgrounds/use-gl-canvas.ts
"use client";
import { Renderer } from "ogl";
import type { OGLRenderingContext } from "ogl";
import { useEffect, useRef } from "react";

export type GLFrame = { gl: OGLRenderingContext; renderer: Renderer; time: number; width: number; height: number; dpr: number };
export type GLSetup = {
  onFrame: (frame: GLFrame) => void;
  onResize?: (width: number, height: number, dpr: number) => void;
  onDispose?: () => void;
};

const MAX_DPR = 1.5;
const TARGET_FPS = 30;
const FRAME_INTERVAL_MS = 1000 / TARGET_FPS;

export type UseGLCanvasOptions = {
  /** Internal render resolution as a fraction of CSS size (canvas.style stays
   *  full size; fragment cost drops by scale^2). A full-bleed per-pixel
   *  fragment shader is viewport-AREA-bound, not just DPR-bound -- see the
   *  FluidEther measurement in §7. Geometry-bound effects (points, lines)
   *  don't need this; leave at 1. */
  renderScale?: number;
};

/** One OGL WebGL context per call site: DPR<=1.5, fps cap, cancel (never
 *  skip) rAF offscreen/hidden/reduced-motion, ResizeObserver (never
 *  per-frame layout reads), context-loss handling, full dispose on unmount.
 *  To SHARE one context across several sections, mount this once in a host
 *  component and swap which Program renders inside onFrame -- see
 *  BackgroundHost (§6.4) instead of calling this per section. */
export function useGLCanvas(build: (gl: OGLRenderingContext, renderer: Renderer) => GLSetup | null, options: UseGLCanvasOptions = {}) {
  const renderScale = Math.min(1, Math.max(0.3, options.renderScale ?? 1));
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const wrapperRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    const wrapper = wrapperRef.current;
    if (!canvas || !wrapper) return;

    const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
    const lowPower = (navigator.hardwareConcurrency || 8) <= 4 || (navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData === true;

    let renderer: Renderer | null = null;
    let setup: GLSetup | null = null;
    let rafId: number | null = null;
    let lastFrameTime = 0;
    let isIntersecting = true;

    function init() {
      renderer = new Renderer({ canvas: canvas!, dpr: Math.min(window.devicePixelRatio || 1, MAX_DPR), alpha: true, antialias: false, powerPreference: "low-power" });
      setup = build(renderer.gl, renderer);
    }
    function resize() {
      if (!renderer || !wrapper || !canvas) return;
      const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
      const { width, height } = wrapper.getBoundingClientRect(); // measured on resize only
      if (width <= 0 || height <= 0) return;
      renderer.dpr = dpr * renderScale;
      renderer.setSize(width, height);
      if (renderScale < 1) { canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; }
      setup?.onResize?.(width, height, dpr); // CSS pixels, independent of render scale
    }
    function renderFrame(now: number) {
      if (!renderer || !setup) return;
      setup.onFrame({ gl: renderer.gl, renderer, time: now / 1000, width: renderer.gl.canvas.width, height: renderer.gl.canvas.height, dpr: renderer.dpr ?? 1 });
    }
    function loop(now: number) {
      rafId = requestAnimationFrame(loop);
      if (now - lastFrameTime < FRAME_INTERVAL_MS) return;
      lastFrameTime = now;
      renderFrame(now);
    }
    function startLoop() { if (rafId === null) rafId = requestAnimationFrame(loop); }
    function stopLoop() { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } } // cancel, never "skip the draw"
    function syncLoop() {
      const shouldRun = isIntersecting && !document.hidden && !reducedMotionQuery.matches && !lowPower;
      shouldRun ? startLoop() : stopLoop();
    }

    if (!reducedMotionQuery.matches && !lowPower) { init(); resize(); renderFrame(performance.now()); }

    const resizeObserver = new ResizeObserver(() => {
      if (!renderer && !reducedMotionQuery.matches && !lowPower) { init(); renderFrame(performance.now()); }
      resize();
    });
    resizeObserver.observe(wrapper);

    const io = new IntersectionObserver(([entry]) => { isIntersecting = entry?.isIntersecting ?? true; syncLoop(); }, { threshold: 0 });
    io.observe(wrapper);

    const onVisibility = () => syncLoop();
    document.addEventListener("visibilitychange", onVisibility);

    const onReducedMotionChange = () => {
      if (reducedMotionQuery.matches) stopLoop();
      else if (!renderer) { init(); resize(); renderFrame(performance.now()); syncLoop(); }
      else syncLoop();
    };
    reducedMotionQuery.addEventListener("change", onReducedMotionChange);

    const onContextLost = (e: Event) => { e.preventDefault(); stopLoop(); };
    const onContextRestored = () => { init(); resize(); syncLoop(); };
    canvas.addEventListener("webglcontextlost", onContextLost, false);
    canvas.addEventListener("webglcontextrestored", onContextRestored, false);

    if (!reducedMotionQuery.matches && !lowPower) syncLoop();

    return () => {
      stopLoop();
      resizeObserver.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
      reducedMotionQuery.removeEventListener("change", onReducedMotionChange);
      canvas.removeEventListener("webglcontextlost", onContextLost);
      canvas.removeEventListener("webglcontextrestored", onContextRestored);
      setup?.onDispose?.();
      (renderer?.gl.getExtension("WEBGL_lose_context") as WEBGL_lose_context | null | undefined)?.loseContext();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return { canvasRef, wrapperRef };
}
```

### 6.2 `use-token-colors.ts` — colours re-tint per direction, resolved off the frame loop

```ts
// src/components/fx/backgrounds/use-token-colors.ts
"use client";
import { useEffect, useRef } from "react";

/** Resolves void CSS custom properties (oklch(), color-mix(), var()) to
 *  [r,g,b] (0..1) for shader uniforms, and re-resolves on direction/theme
 *  change so a background re-tints instead of hardcoding colour. Shaders
 *  can't read oklch()/var() themselves -- this is the one legal place to
 *  touch the DOM for colour, resolved once per change, never per frame. */
export function useTokenColors<T extends Record<string, `--${string}`>>(names: T) {
  const values = useRef<Record<keyof T, [number, number, number]>>(
    Object.fromEntries(Object.keys(names).map((k) => [k, [0.5, 0.5, 0.5]])) as Record<keyof T, [number, number, number]>,
  );
  useEffect(() => {
    const probe = document.createElement("canvas").getContext("2d");
    if (!probe) return;
    function readAll() {
      const style = getComputedStyle(document.documentElement);
      for (const key of Object.keys(names) as (keyof T)[]) {
        const raw = style.getPropertyValue(names[key]).trim();
        if (!raw) continue;
        probe!.clearRect(0, 0, 1, 1);
        probe!.fillStyle = raw; // the browser parses oklch()/color-mix() for us
        probe!.fillRect(0, 0, 1, 1);
        const [r, g, b] = probe!.getImageData(0, 0, 1, 1).data;
        values.current[key] = [r! / 255, g! / 255, b! / 255];
      }
    }
    readAll();
    const mo = new MutationObserver(readAll); // theme toggle flips a class on <html>
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });
    const schemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
    schemeQuery.addEventListener("change", readAll);
    return () => { mo.disconnect(); schemeQuery.removeEventListener("change", readAll); };
  }, [names]);
  return values;
}
```

### 6.3 `use-lazy-webgl.ts` — idle + intersection, skip entirely on low power

```ts
// src/components/fx/backgrounds/use-lazy-webgl.ts
"use client";
import { useEffect, useRef, useState } from "react";

/** Nothing WebGL imports before idle AND intersection, and never at all
 *  under prefers-reduced-motion, hardwareConcurrency<=4 or saveData (the
 *  poster stays). Call from a small per-effect "use client" wrapper --
 *  passing the loader as a prop across the server/client boundary isn't
 *  serialisable, so each effect owns its own lazy wrapper (see below). */
export function useLazyWebgl() {
  const wrapperRef = useRef<HTMLDivElement | null>(null);
  const [shouldLoad, setShouldLoad] = useState(false);
  useEffect(() => {
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const lowPower = (navigator.hardwareConcurrency || 8) <= 4 || (navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData === true;
    if (reduced || lowPower) return; // poster stays, nothing else loads
    const wrapper = wrapperRef.current;
    if (!wrapper) return;
    let idleId: number | undefined;
    const io = new IntersectionObserver(([entry]) => {
      if (!entry?.isIntersecting) return;
      io.disconnect();
      const request = "requestIdleCallback" in window ? window.requestIdleCallback : (cb: () => void) => setTimeout(cb, 1) as unknown as number;
      idleId = request(() => setShouldLoad(true)) as number;
    }, { rootMargin: "200px" });
    io.observe(wrapper);
    return () => { io.disconnect(); if (idleId !== undefined && "cancelIdleCallback" in window) window.cancelIdleCallback(idleId); };
  }, []);
  return { wrapperRef, shouldLoad };
}
```

```tsx
// src/components/fx/backgrounds/fluid-ether-lazy.tsx -- the wiring pattern for every effect below
"use client";
import dynamic from "next/dynamic";
import { useLazyWebgl } from "./use-lazy-webgl";

const FluidEther = dynamic(() => import("./fluid-ether").then((m) => m.FluidEther), { ssr: false });

export function FluidEtherLazy() {
  const { wrapperRef, shouldLoad } = useLazyWebgl();
  return (
    <div ref={wrapperRef} aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 isolate overflow-hidden bg-bg">
      <div className="absolute inset-0" style={{ background: "radial-gradient(60% 60% at 50% 40%, var(--brand-subtle), var(--bg) 70%)" }} />
      {shouldLoad && <FluidEther />}
    </div>
  );
}
// Same shape for ParticleFieldLazy, PixelDitherLazy, DisplacementGridLazy --
// swap the import and drop the poster gradient div where a flat --bg reads fine.
```

### 6.4 `background-host.tsx` — the actual "one context, several sections" pattern

```tsx
// src/components/fx/backgrounds/background-host.tsx
"use client";
/**
 * ONE shared WebGL context for an entire page of sections. Build every
 * section's look as its own Program/Mesh once at init (never rebuilt), then
 * each frame render only the Mesh for whichever section is `activeKey`. The
 * parent page owns an IntersectionObserver over its section elements (same
 * shape as the scroll-progress driver in references/3d-scenes.md) and
 * passes the winning section's key down; BackgroundHost never tears down or
 * recreates the GL context when that prop changes.
 *
 * Production note: mount this via next/dynamic(ssr:false) gated by
 * requestIdleCallback in the layout (it's page-level, not per-section, so
 * IntersectionObserver-gating like use-lazy-webgl.ts doesn't apply the same
 * way) -- the measurement in §7 loaded it eagerly for a clean comparison,
 * which is why its jsKb isn't a fair "lazy" number; defer it in production.
 */
import { Color, Mesh, Program, Renderer, Triangle } from "ogl";
import { useEffect, useRef } from "react";
import { useGLCanvas } from "./use-gl-canvas";
import { useTokenColors } from "./use-token-colors";

export type BackgroundKey = "fluid" | "dither" | "none";

// ... build one Program per look (reuse the fragment shaders from §7.1/§7.3,
// trimmed), keep them in a small map, and in onFrame:
//   const key = activeRef.current;
//   if (key === "none") return;          // context stays alive, nothing drawn
//   renderer.render({ scene: meshByKey[key] });
// Full worked file (two looks, fluid + dither): backgrounds-scratch/src/components/fx/backgrounds/background-host.tsx
```

```tsx
// usage: one IntersectionObserver over section refs drives activeKey
"use client";
import { useEffect, useRef, useState } from "react";
import { BackgroundHost, type BackgroundKey } from "@/components/fx/backgrounds/background-host";

export function SharedSections() {
  const [active, setActive] = useState<BackgroundKey>("fluid");
  const fluidRef = useRef<HTMLElement | null>(null);
  const ditherRef = useRef<HTMLElement | null>(null);
  useEffect(() => {
    const targets: [HTMLElement, BackgroundKey][] = [];
    if (fluidRef.current) targets.push([fluidRef.current, "fluid"]);
    if (ditherRef.current) targets.push([ditherRef.current, "dither"]);
    const io = new IntersectionObserver((entries) => {
      const top = entries.filter((e) => e.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
      const match = top && targets.find(([el]) => el === top.target);
      if (match) setActive(match[1]);
    }, { threshold: [0.3, 0.6] });
    for (const [el] of targets) io.observe(el);
    return () => io.disconnect();
  }, []);
  return (
    <>
      <BackgroundHost activeKey={active} />
      <section ref={fluidRef} className="relative isolate flex min-h-svh items-center">{/* … */}</section>
      <section ref={ditherRef} className="relative isolate flex min-h-svh items-center">{/* … */}</section>
    </>
  );
}
```

## 7. WebGL tier (T4): the four effects

Each follows the licence rule at the top of this doc: an original implementation of the
*technique*, never a transcription of any library's shader.

### 7.1 FluidEther — liquid/fluid distortion

Domain-warped value noise (Quilez-style "warp noise by noise", maths not code) advected by time
and pointer velocity. No fluid solver.

```tsx
// src/components/fx/backgrounds/fluid-ether.tsx
"use client";
import { Color, Program, Renderer, Triangle, Mesh } from "ogl";
import { useEffect, useRef } from "react";
import { useGLCanvas } from "./use-gl-canvas";
import { useTokenColors } from "./use-token-colors";

const vertex = /* glsl */ `
  attribute vec2 uv; attribute vec2 position; varying vec2 vUv;
  void main() { vUv = uv; gl_Position = vec4(position, 0.0, 1.0); }
`;
const fragment = /* glsl */ `
  precision highp float;
  varying vec2 vUv;
  uniform float uTime; uniform vec2 uResolution; uniform vec2 uPointer; uniform float uPointerStrength;
  uniform vec3 uColorA; uniform vec3 uColorB; uniform vec3 uColorC;

  float hash(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
  float valueNoise(vec2 p) {
    vec2 i = floor(p), f = fract(p);
    float a = hash(i), b = hash(i + vec2(1.0, 0.0)), c = hash(i + vec2(0.0, 1.0)), d = hash(i + vec2(1.0, 1.0));
    vec2 u = f * f * (3.0 - 2.0 * f);
    return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
  }
  float fbm(vec2 p) {
    // 3 octaves: 4 pushed desktop dropped frames over budget (measured, see table below).
    float sum = 0.0, amp = 0.5;
    for (int i = 0; i < 3; i++) { sum += amp * valueNoise(p); p *= 2.02; amp *= 0.55; }
    return sum;
  }
  void main() {
    float aspect = uResolution.x / uResolution.y;
    vec2 p = (vUv - 0.5) * vec2(aspect, 1.0) * 2.2;
    vec2 pointerOffset = (uPointer - 0.5) * vec2(aspect, 1.0) * 2.2 - p;
    float pointerDist = length(pointerOffset);
    p += pointerOffset * (0.18 * uPointerStrength) / (1.0 + pointerDist * pointerDist * 2.0);

    // Single-level domain warp: 2 fbm() calls for the warp vector + 1 to sample it, 3/pixel.
    float t = uTime * 0.045;
    vec2 warp = vec2(fbm(p + vec2(0.0, t)), fbm(p + vec2(5.2, -t)));
    float field = fbm(p + warp * 1.6);

    vec3 color = mix(uColorA, uColorB, smoothstep(0.25, 0.62, field));
    color = mix(color, uColorC, smoothstep(0.55, 0.92, field) * 0.6);
    color *= 0.55 + 0.45 * smoothstep(1.35, 0.2, length(p));
    gl_FragColor = vec4(color, 1.0);
  }
`;

export function FluidEther({ className = "" }: { className?: string }) {
  const colors = useTokenColors({ a: "--bg", b: "--brand", c: "--surface-raised" });
  const pointerRef = useRef({ x: 0.5, y: 0.5, target: 0 });

  const { canvasRef, wrapperRef } = useGLCanvas((gl) => {
    const geometry = new Triangle(gl);
    const program = new Program(gl, {
      vertex, fragment,
      uniforms: {
        uTime: { value: 0 }, uResolution: { value: [1, 1] }, uPointer: { value: [0.5, 0.5] }, uPointerStrength: { value: 0 },
        uColorA: { value: new Color(...colors.current.a) }, uColorB: { value: new Color(...colors.current.b) }, uColorC: { value: new Color(...colors.current.c) },
      },
    });
    const mesh = new Mesh(gl, { geometry, program });
    return {
      onResize: (w, h) => { program.uniforms.uResolution.value = [w, h]; },
      onFrame: ({ renderer, time }) => {
        program.uniforms.uTime.value = time;
        program.uniforms.uPointer.value = [pointerRef.current.x, pointerRef.current.y];
        pointerRef.current.target *= 0.94; // decays toward 0 when the pointer stops moving
        program.uniforms.uPointerStrength.value = pointerRef.current.target;
        const c = colors.current;
        (program.uniforms.uColorA.value as Color).set(...c.a);
        (program.uniforms.uColorB.value as Color).set(...c.b);
        (program.uniforms.uColorC.value as Color).set(...c.c);
        (renderer as Renderer).render({ scene: mesh });
      },
      onDispose: () => { geometry.remove(); program.remove(); },
    };
  }, { renderScale: 0.6 }); // full-bleed fragment shader: viewport-area-bound, not just DPR-bound

  useEffect(() => {
    const wrapper = wrapperRef.current;
    if (!wrapper) return;
    const onMove = (e: PointerEvent) => {
      const rect = wrapper.getBoundingClientRect();
      pointerRef.current.x = (e.clientX - rect.left) / rect.width;
      pointerRef.current.y = 1 - (e.clientY - rect.top) / rect.height;
      pointerRef.current.target = 1;
    };
    window.addEventListener("pointermove", onMove, { passive: true });
    return () => window.removeEventListener("pointermove", onMove);
  }, [wrapperRef]);

  return (
    <div ref={wrapperRef} aria-hidden="true" className={`pointer-events-none absolute inset-0 -z-10 isolate overflow-hidden bg-bg ${className}`}>
      <div className="absolute inset-0" style={{ background: "radial-gradient(65% 65% at 50% 40%, var(--brand-subtle), var(--bg) 70%)" }} />
      <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
    </div>
  );
}
```

**Measured verdict: hero-only, and re-verify before trusting broadly.** Even after DPR≤1.5, a
0.6 render scale and dropping from a double to a single domain warp (5→3 `fbm()` calls/pixel), this
is the one effect in the catalogue that did **not** hold the dropped-frame budget on the machine
used for this measurement (~15% both profiles, vs. the ≤2%/≤5% target — see §8). The other three
T4 effects held 0% cleanly and repeatably. Don't ship FluidEther as a secondary/ambient effect;
use it only as the page's single hero, and re-profile on a quiet runner (§8 explains the noise)
before relying on the exact percentage. If it still doesn't hold, drop to 2 octaves or lower
`TARGET_FPS` to 24 for this effect specifically before cutting it.

### 7.2 ParticleField — GPU-driven particle field (ballpit/snow energy)

Every particle's position is a closed-form function of time evaluated **in the vertex shader** —
zero per-frame CPU/JS work beyond the `uTime` uniform, no physics solver.

```tsx
// src/components/fx/backgrounds/particle-field.tsx
"use client";
import { Color, Geometry, Program, Renderer, Mesh } from "ogl";
import { useGLCanvas } from "./use-gl-canvas";
import { useTokenColors } from "./use-token-colors";

const COUNT = 220; // budget: cheap enough for 60fps-capable integrated GPUs at DPR<=1.5

const vertex = /* glsl */ `
  attribute vec3 aSeed; // x: horizontal start, y: fall phase, z: depth (0..1)
  uniform float uTime; uniform float uAspect;
  varying float vDepth;
  void main() {
    float depth = aSeed.z;
    float fallSpeed = 0.05 + depth * 0.10;
    float y = fract(aSeed.y - uTime * fallSpeed) * 2.4 - 1.2; // wraps top-to-bottom, never visibly resets
    float drift = sin(uTime * (0.15 + depth * 0.2) + aSeed.x * 20.0) * (0.05 + depth * 0.10);
    float x = (aSeed.x * 2.0 - 1.0) * uAspect + drift;
    vDepth = depth;
    gl_Position = vec4(x, y, 0.0, 1.0);
    gl_PointSize = (1.4 + depth * 3.2) * 2.0;
  }
`;
const fragment = /* glsl */ `
  precision highp float;
  varying float vDepth;
  uniform vec3 uColorNear; uniform vec3 uColorFar;
  void main() {
    vec2 c = gl_PointCoord - 0.5;
    float d = length(c);
    if (d > 0.5) discard;
    float alpha = smoothstep(0.5, 0.15, d) * (0.35 + vDepth * 0.55);
    gl_FragColor = vec4(mix(uColorFar, uColorNear, vDepth), alpha);
  }
`;

export function ParticleField({ className = "" }: { className?: string }) {
  const colors = useTokenColors({ near: "--brand", far: "--fg-subtle" });
  const { canvasRef, wrapperRef } = useGLCanvas((gl) => {
    const aSeed = new Float32Array(COUNT * 3);
    for (let i = 0; i < COUNT; i++) { aSeed[i * 3] = Math.random(); aSeed[i * 3 + 1] = Math.random(); aSeed[i * 3 + 2] = Math.random(); }
    const geometry = new Geometry(gl, { aSeed: { size: 3, data: aSeed } });
    const program = new Program(gl, {
      vertex, fragment,
      uniforms: { uTime: { value: 0 }, uAspect: { value: 1 }, uColorNear: { value: new Color(...colors.current.near) }, uColorFar: { value: new Color(...colors.current.far) } },
      transparent: true, depthTest: false,
    });
    const mesh = new Mesh(gl, { mode: gl.POINTS, geometry, program });
    return {
      onResize: (w, h) => { program.uniforms.uAspect.value = w / h; },
      onFrame: ({ renderer, time }) => {
        program.uniforms.uTime.value = time;
        const c = colors.current;
        (program.uniforms.uColorNear.value as Color).set(...c.near);
        (program.uniforms.uColorFar.value as Color).set(...c.far);
        (renderer as Renderer).render({ scene: mesh });
      },
      onDispose: () => { geometry.remove(); program.remove(); },
    };
  });
  return (
    <div ref={wrapperRef} aria-hidden="true" className={`pointer-events-none absolute inset-0 -z-10 isolate overflow-hidden bg-bg ${className}`}>
      <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
    </div>
  );
}
```

### 7.3 PixelDither — pixel grid + ordered (Bayer) dithering, snow-like falling flecks

A fragment-shader-only effect: no particle geometry, a 4x4 Bayer threshold matrix over a
per-cell fall function (decades-old, unowned ordered-dithering technique, written fresh here).

```tsx
// src/components/fx/backgrounds/pixel-dither.tsx
"use client";
import { Color, Program, Renderer, Triangle, Mesh } from "ogl";
import { useGLCanvas } from "./use-gl-canvas";
import { useTokenColors } from "./use-token-colors";

const vertex = /* glsl */ `
  attribute vec2 uv; attribute vec2 position; varying vec2 vUv;
  void main() { vUv = uv; gl_Position = vec4(position, 0.0, 1.0); }
`;
const fragment = /* glsl */ `
  precision highp float;
  varying vec2 vUv;
  uniform float uTime; uniform vec2 uResolution; uniform vec3 uColorBg; uniform vec3 uColorFg;
  float hash(vec2 p) { p = fract(p * vec2(153.34, 231.11)); p += dot(p, p + 27.1); return fract(p.x * p.y); }
  float bayer(vec2 cell) {
    int x = int(mod(cell.x, 4.0)); int y = int(mod(cell.y, 4.0)); int index = y * 4 + x;
    float m[16];
    m[0]=0.0; m[1]=8.0; m[2]=2.0; m[3]=10.0; m[4]=12.0; m[5]=4.0; m[6]=14.0; m[7]=6.0;
    m[8]=3.0; m[9]=11.0; m[10]=1.0; m[11]=9.0; m[12]=15.0; m[13]=7.0; m[14]=13.0; m[15]=5.0;
    for (int i = 0; i < 16; i++) { if (i == index) return m[i] / 16.0; }
    return 0.0;
  }
  void main() {
    float cellPx = 10.0;
    vec2 gridRes = floor(uResolution / cellPx);
    vec2 cell = floor(vUv * gridRes);
    float speed = 6.0 + hash(vec2(cell.x, 0.0)) * 10.0;
    float phase = hash(vec2(cell.x, 1.0)) * gridRes.y;
    float fallenRow = mod(phase - uTime * speed, gridRes.y);
    float streak = 1.0 - smoothstep(0.0, 6.0, abs(cell.y - fallenRow));
    float flicker = hash(cell + floor(uTime * 2.0));
    float brightness = streak * (0.55 + 0.45 * flicker);
    float dithered = step(bayer(cell), brightness);
    gl_FragColor = vec4(mix(uColorBg, uColorFg, dithered * brightness), 1.0);
  }
`;

export function PixelDither({ className = "" }: { className?: string }) {
  const colors = useTokenColors({ bg: "--bg", fg: "--brand" });
  const { canvasRef, wrapperRef } = useGLCanvas((gl) => {
    const geometry = new Triangle(gl);
    const program = new Program(gl, {
      vertex, fragment,
      uniforms: { uTime: { value: 0 }, uResolution: { value: [1, 1] }, uColorBg: { value: new Color(...colors.current.bg) }, uColorFg: { value: new Color(...colors.current.fg) } },
    });
    const mesh = new Mesh(gl, { geometry, program });
    return {
      onResize: (w, h) => { program.uniforms.uResolution.value = [w, h]; },
      onFrame: ({ renderer, time }) => {
        program.uniforms.uTime.value = time;
        const c = colors.current;
        (program.uniforms.uColorBg.value as Color).set(...c.bg);
        (program.uniforms.uColorFg.value as Color).set(...c.fg);
        (renderer as Renderer).render({ scene: mesh });
      },
      onDispose: () => { geometry.remove(); program.remove(); },
    };
  });
  return (
    <div ref={wrapperRef} aria-hidden="true" className={`pointer-events-none absolute inset-0 -z-10 isolate overflow-hidden bg-bg ${className}`}>
      <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
    </div>
  );
}
```

### 7.4 DisplacementGrid — a line grid that warps around the pointer

Every vertex's displacement is computed in the vertex shader from its base grid position, `uTime`
and `uPointer` — the CPU never touches per-frame geometry.

```tsx
// src/components/fx/backgrounds/displacement-grid.tsx
"use client";
import { Color, Geometry, Program, Renderer, Mesh } from "ogl";
import { useEffect, useRef } from "react";
import { useGLCanvas } from "./use-gl-canvas";
import { useTokenColors } from "./use-token-colors";

const COLS = 28, ROWS = 16;

const vertex = /* glsl */ `
  attribute vec2 aBase; // grid position in -1..1
  uniform float uTime; uniform float uAspect; uniform vec2 uPointer; uniform float uPointerStrength;
  void main() {
    vec2 p = aBase;
    float wave = sin(p.x * 3.1 + uTime * 0.4) * cos(p.y * 2.3 - uTime * 0.3) * 0.03;
    p.y += wave;
    p.x += sin(p.y * 4.0 + uTime * 0.25) * 0.02;
    vec2 pAspect = p * vec2(uAspect, 1.0);
    vec2 pointerAspect = uPointer * vec2(uAspect, 1.0);
    vec2 toPoint = pAspect - pointerAspect;
    float d = length(toPoint);
    float falloff = smoothstep(0.55, 0.0, d) * uPointerStrength;
    p += normalize(toPoint + 0.0001) * falloff * 0.12;
    gl_Position = vec4(p, 0.0, 1.0);
  }
`;
const fragment = /* glsl */ `
  precision highp float; uniform vec3 uColor;
  void main() { gl_FragColor = vec4(uColor, 0.5); }
`;

function buildGridLines(): Float32Array {
  const points: number[] = [];
  const stepX = 2 / (COLS - 1), stepY = 2 / (ROWS - 1);
  for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS - 1; c++) {
    const x0 = -1 + c * stepX, x1 = -1 + (c + 1) * stepX, y = -1 + r * stepY;
    points.push(x0, y, x1, y);
  }
  for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS - 1; r++) {
    const y0 = -1 + r * stepY, y1 = -1 + (r + 1) * stepY, x = -1 + c * stepX;
    points.push(x, y0, x, y1);
  }
  return new Float32Array(points);
}

export function DisplacementGrid({ className = "" }: { className?: string }) {
  const colors = useTokenColors({ line: "--line-strong" });
  const pointerRef = useRef({ x: 0, y: 0, target: 0 });
  const { canvasRef, wrapperRef } = useGLCanvas((gl) => {
    const geometry = new Geometry(gl, { aBase: { size: 2, data: buildGridLines() } });
    const program = new Program(gl, {
      vertex, fragment,
      uniforms: { uTime: { value: 0 }, uAspect: { value: 1 }, uPointer: { value: [0, 0] }, uPointerStrength: { value: 0 }, uColor: { value: new Color(...colors.current.line) } },
      transparent: true, depthTest: false,
    });
    const mesh = new Mesh(gl, { mode: gl.LINES, geometry, program });
    return {
      onResize: (w, h) => { program.uniforms.uAspect.value = w / h; },
      onFrame: ({ renderer, time }) => {
        program.uniforms.uTime.value = time;
        program.uniforms.uPointer.value = [pointerRef.current.x, pointerRef.current.y];
        pointerRef.current.target *= 0.95;
        program.uniforms.uPointerStrength.value = pointerRef.current.target;
        (program.uniforms.uColor.value as Color).set(...colors.current.line);
        (renderer as Renderer).render({ scene: mesh });
      },
      onDispose: () => { geometry.remove(); program.remove(); },
    };
  });
  useEffect(() => {
    const wrapper = wrapperRef.current;
    if (!wrapper) return;
    const onMove = (e: PointerEvent) => {
      const rect = wrapper.getBoundingClientRect();
      pointerRef.current.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
      pointerRef.current.y = (1 - (e.clientY - rect.top) / rect.height) * 2 - 1;
      pointerRef.current.target = 1;
    };
    window.addEventListener("pointermove", onMove, { passive: true });
    return () => window.removeEventListener("pointermove", onMove);
  }, [wrapperRef]);
  return (
    <div ref={wrapperRef} aria-hidden="true" className={`pointer-events-none absolute inset-0 -z-10 isolate overflow-hidden bg-bg ${className}`}>
      <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
    </div>
  );
}
```

## 8. Measured numbers

Measured 2026-09-18 with void's own CLI (`bun packages/cli/src/index.ts audit <url> --only
perf,smooth --profile both`) against a scratch Next 16.3.5 app built from `templates/next` (real
tokens, real header/footer, one section per effect), on real Chromium via Playwright.
`templates/next`'s baseline (no background) measured jsKb 140.0 — "added JS" below is the delta.

| Effect | Added JS (gzip) | Dropped frames mobile | Dropped frames desktop | Frame p95 | LCP | Holds budget (≤2%/≤5%, p95≤20ms)? |
|---|---|---|---|---|---|---|
| FluidEther | +20.1 KB | 0–21% (noisy, see below) | 15–29% (worst run 52.6%) | 16.7–33.4ms | 624ms | **No** — hero-only, re-verify |
| ParticleField | +19.4 KB | 0% | 0% | 16.7–16.8ms | 624ms | Yes, cleanly, every rerun |
| PixelDither | +19.7 KB | 0% | 0–2.3% | 16.7–16.8ms | 636ms | Yes |
| DisplacementGrid | +19.6 KB | 0% | 0% | 16.7–16.8ms | 636/72ms | Yes, cleanly, every rerun |
| BackgroundHost (2 looks, 1 ctx) | +17.6 KB combined | 0% | 0% | 16.7–16.8ms | 624/80ms | Yes (measured eager-loaded, see §6.4 note) |
| CSS tier, 3 sections stacked (mesh + dot-grid + grain) | +1.4 KB | 0% | 5.3% | 16.7–33.3ms | 636/116ms | Desktop borderline — see note |

`htmlKb` stayed 6.2–6.6 KB across every route (≤30KB gate, plenty of room).

**On the noise:** these were measured on a shared development workstation with other GPU-heavy
processes running (browsers, compositor), not a quiet CI runner — load average was consistently
above 2 during testing. Re-running FluidEther alone produced 6.7%, 29.2%, 52.6% and finally
15.4%/15.4% desktop dropped-frame readings across four separate runs at the same code, which is
too much variance to trust as an exact number. What *is* reproducible across every rerun: the
other three T4 effects and the shared-context host held 0% dropped frames on both profiles every
single time, while FluidEther never once measured 0% on desktop. That relative signal — one
full-bleed domain-warp shader costs meaningfully more than GPU-driven points or a flat
fragment-shader dither grid, at the same DPR cap and fps cap — is the real, reportable finding,
even though the exact percentage should be re-measured on a quiet runner (e.g. CI) before writing
it into a hard gate. This is also *why* §3 calls out render scale as a non-negotiable specifically
for full-bleed fragment shaders: DPR alone visibly wasn't enough for this one.

**On the CSS tier's desktop number:** 5.3% dropped frames there comes from three `filter: blur(60px)`
gradient-mesh layers animating `transform` while scrolling past two other sections in the same
pass — a real, useful finding (blurred compositor layers aren't free even though they're 0 KB JS)
but not representative of §2 rule 1 ("at most one live expensive background"): this demo
deliberately stacks three effects to show variety on one scroll pass. A single `GradientMesh`
instance, alone, is the intended real-world case and is expected to cost less; if you need to
verify, audit a page with just one instance.

### 8.1 The CSS tier is not free either — four findings from shipping it

Measured 2026-09-18 on void's own home page (`web/`), which already spends its one WebGL context
on a scroll-linked object. Every number is desktop dropped frames from `void smooth <url>
--profile desktop`, same machine, back to back. Baseline — the object, no section surfaces at
all — was **9.2%**.

| Configuration | Dropped frames | Delta |
|---|---|---|
| Baseline: object only, no surfaces | 9.2% | — |
| One surface (SVG-tile dot grid, masked, hero only) | 14.6% | +5.4 |
| One surface + its pointer light left at `opacity: 0` | 22.6% | +8.0 |
| Four surfaces, patterns as CSS gradients, masks on the animated layer | 51.8% | +42.6 |
| Four surfaces, patterns as CSS gradients, masks hoisted to the static parent | 45.0% | −6.8 |
| Four surfaces, patterns as CSS gradients, **animation removed entirely** | 44.1% | −0.9 |
| Four surfaces, patterns as **SVG `data:` tiles**, animated | 36.7% | −15.1 |

1. **A CSS gradient pattern is not a cheap texture.** `repeating-linear-gradient` with 1px stripes
   and `radial-gradient(circle at 1.4px 1.4px, …) / 34px 34px` are evaluated per pixel per raster
   tile, and a scrolling page rasterises new tiles constantly. The identical pattern written as a
   34×34 SVG `data:` URI — which the browser decodes once and blits — recovered **15 points**.
   Write section patterns as image tiles; keep gradients for one-off soft washes, not repeats.
2. **Moving the layer is nearly free; painting it is not.** Removing the tile animation entirely
   bought 0.9 points. The translate really does run on the compositor, exactly as intended — the
   cost was never the motion, and "make it static to make it fast" would have been the wrong fix
   applied to the wrong thing. Measure before you delete the part that looks expensive.
3. **`mask-image` belongs on the static parent, never on the layer that moves.** A mask on an
   animating element is re-evaluated as it moves: 6.8 points across four sections. Put the pattern
   and the animation on the child, the mask on the parent.
4. **`opacity: 0` does not mean "not painted".** A 544px soft radial gradient sitting invisible in
   the tree, waiting for a hover that had not happened, cost **8 points** on its own. Use
   `display: none` with `transition-behavior: allow-discrete` and `@starting-style` — the fade
   survives, the raster does not. This applies to every hidden decorative layer, not just this one.

**Budget, restated with numbers:** on a page that already has a WebGL object, **one** full-section
CSS surface is affordable (+5 points) and four are not (+27 even as image tiles). §2 rule 1 says
at most one expensive background page-wide; this is the same rule at the cheap tier, and the
reason void's own home page ships exactly one — the hero — while the inner routes, which run no
WebGL at all, each carry one in their page head.

## 9. What was cut, and why

- **A literal fluid/Navier–Stokes solver** — a real velocity-field advection simulation (what
  React Bits' Liquid Ether almost certainly runs) is expensive at any resolution and hard to keep
  inside a section's JS/frame budget from scratch; the domain-warped noise field in §7.1 evokes
  the same "liquid" feeling for a fraction of the cost, and even that needed a render-scale
  mitigation to get close to budget. A real solver would need to be WASM-compute-bound and is out
  of scope for a section background.
- **A second BackgroundHost look per extra section** — the pattern in §6.4 generalises to N looks
  (add a Program, add a case), but only 2 are worked through here to keep the reference reviewable;
  extending it is mechanical, not a new technique.
- **CPU-side particle physics** (collision, gravity accumulation) for ParticleField — a real
  ballpit has inter-particle collisions; that requires either a spatial hash updated per frame
  (CPU cost, violates "no per-frame allocation" at any interesting particle count) or a compute
  shader (WebGL2 transform feedback, meaningfully more code and a stricter browser floor). The
  closed-form GPU motion in §7.2 gets the "field of drifting particles" feeling without either.
- **Nothing was cut for licence reasons** — every effect the owner named (liquid/fluid, ballpit,
  pixel/snow, grid distortion) has a from-scratch equivalent above; none needed to be dropped.

## Verify

1. `void lint` on the project using these: no `lint/hardcoded-colors` (colours must come from
   `useTokenColors`, never a literal hex in a shader default), no `lint/raf-without-cancel`,
   no `lint/webgl-in-map`.
2. `bun run build`, then `void smooth --start "bun run start -p 3100" --port 3100`:
   `smooth/multiple-webgl-contexts` must be 0 even with several background sections on the page;
   `smooth/raf-loop-idle`, `smooth/reduced-motion-ignored`.
3. `void audit <url> --only perf,smooth --profile both --format md` per route with a background —
   compare `droppedFramePct`/`frameP95` against this doc's table, and re-measure on a quiet
   machine if the numbers look noisy (see §8).
4. Screenshot at 390/768/1440, light + dark, and `--force-prefers-reduced-motion`: every background
   must show a finished, on-token static frame, and body text over it must still read at 4.5:1
   (`void a11y`).

## References

- `references/3d-scenes.md` — the scroll-linked-object sibling of this doc: measured library
  costs (why OGL, not three.js), the `SceneCanvas`/render-on-demand pattern this doc's hooks share.
- `references/webgl-canvas.md` — the full WebGL/canvas hard-rule table and gate this doc applies.
- `components/references/recipes.md` — `PatternBackdrop`, `AuroraBackdrop`, `HeroSpotlight`: the
  T0/T1 recipes this doc's §5 cross-references rather than duplicates.
- `inspire/references/effect-downgrades.md` — the general "heavy effect → cheap equivalent"
  mapping table this doc specialises for backgrounds.
- Full working prototype (all files above, typechecked, built, measured):
  `/home/parth/code/sandbox/void/ui/v3/backgrounds-scratch/`.


---

<!-- https://void-design.vercel.app/docs/motion/fallbacks · updated 2026-09-16 -->

# Fallbacks: what a browser without scroll-driven animations renders

void's home page shipped effectively blank in Firefox: header, one chip and a decorative shape
rendered; the headline, sub-copy and live terminal did not, because their base CSS sat at `opacity: 0`
and only the scroll-driven animation ever undid it. `void audit` scored the page `smooth` 99–100
because every check ran in Chromium, which supports the feature — the bug was invisible to the tool
that exists to catch exactly this. This reference is the rule that closes it, and the check
(`smooth/scroll-timeline-no-fallback`, `lint/scroll-timeline-no-fallback`) that enforces it.

## 1. The hard rule

**Keyframes own the from-state. A base rule — anything outside `@keyframes` — is always the finished,
visible state.**

`@supports (animation-timeline: view())` (or `scroll()`, or a bare `view-timeline`/`animation-range`)
has no fallback path. A browser that doesn't implement the feature evaluates the condition as false
and drops the *entire block*, keyframes included — not "degrades to no animation", **drops the CSS**.
If the hidden value (`opacity: 0`, a collapsed `scale`, an off-screen `translate`, `visibility: hidden`)
lives in a rule outside that block, nothing in that browser ever undoes it. The content isn't
degraded. It's gone, permanently, no matter how long the user waits or how far they scroll.

This is the same discipline `reduced-motion-ignored`/`no-reduced-motion` already enforce for
`prefers-reduced-motion` (motion rule 10: "reduced motion = a finished static frame, never hidden
content") — `@supports` just adds a second, more brutal way to lose an entire audience, because unlike
`prefers-reduced-motion` there is no user setting to turn it off with, and unlike a slow network there
is no amount of waiting that fixes it.

### Incorrect — base rule hides it, only `@supports` undoes it

```css
/* BUG: .hero-sub is opacity: 0 in every browser. Only one that supports
   animation-timeline ever runs the animation that sets it back to 1. */
.hero-sub {
  opacity: 0;
}
@supports (animation-timeline: view()) {
  .hero-sub {
    animation: reveal linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 60%;
  }
}
@keyframes reveal {
  to { opacity: 1; }
}
```

### Correct — the resting rule is already the finished state

```css
/* .hero-sub is opacity: 1 (the default) everywhere. A supporting browser plays
   an entrance from 0; everyone else just has it, at rest, from first paint. */
@supports (animation-timeline: view()) {
  .hero-sub {
    animation: reveal linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 60%;
  }
}
@keyframes reveal {
  from { opacity: 0; translate: 0 0.75rem; }
}
```

This is exactly `@utility reveal` in `packages/tokens/css/base.css`: `opacity: 0` and the 12px rise
live only inside `@keyframes void-reveal`, never in `.reveal` itself. Deleting the whole `@supports`
block changes nothing about what's visible at rest — which is the property this rule tests for.

The same rule applies to transforms, masks and `visibility`:

```css
/* wrong */
.card { transform: translateX(140%); }          /* off-canvas at rest, in every browser */
@supports (animation-timeline: view()) {
  .card { animation: slide-in linear both; animation-timeline: view(); }
}
@keyframes slide-in { to { transform: translateX(0); } }

/* right */
@supports (animation-timeline: view()) {
  .card { animation: slide-in linear both; animation-timeline: view(); }
}
@keyframes slide-in { from { transform: translateX(140%); } }
```

```css
/* wrong: visibility:hidden outside @supports never gets undone without it */
.panel { visibility: hidden; }
@supports (animation-timeline: scroll()) {
  .panel { animation: unmask both; animation-timeline: scroll(root); }
}

/* right */
@supports (animation-timeline: scroll()) {
  .panel { animation: unmask both; animation-timeline: scroll(root); }
}
@keyframes unmask { from { visibility: hidden; } to { visibility: visible; } }
```

One more shape worth naming: `animation: reveal both; animation-timeline: view();` declared as a
plain, unconditional rule (not inside `@supports` at all) is just as broken — `animation-timeline`
itself silently no-ops in a non-supporting browser (an unknown property value is dropped, not an
error), the animation never gets a timeline, and depending on fill-mode the element can sit on its
keyframe's `from` state forever. Always gate `animation-timeline`/`view-timeline`/`animation-range`
behind `@supports`, and always let the block's own keyframe own the hidden value.

## 2. Browser support, verified (not from memory)

Checked directly in a real Firefox 151 (`CSS.supports('animation-timeline', 'view()')` →
`false`; likewise `scroll()`, `view-timeline-name`, `animation-range`) and in Chromium 149
(`CSS.supports` → `true` for all four). Don't trust a remembered support table for a fast-moving CSS
feature — re-check with `CSS.supports()` in the actual engine before relying on it.

| Feature | Chrome/Edge | Safari | Firefox |
|---|---|---|---|
| `animation-timeline: view()` / `scroll()` | 115 | 26 | **no support, through v151** (behind a flag only) |
| `view-timeline`, `animation-range` | 115 | 26 | **no support, through v151** |

There is no Chromium launch flag that flips `CSS.supports` back to false for this — it shipped
unconditionally in M115, not behind a `--disable-blink-features=…`-style runtime flag (every
plausible flag name was tried and verified against `CSS.supports` directly; none changed the result).
That's why `smooth/scroll-timeline-no-fallback` reproduces the *content* of a non-supporting engine's
cascade — stripping the matching `@supports` blocks from the CSS before the browser parses it — rather
than trying to make Chromium stop supporting the feature.

Firefox is not a rounding error here: it's every Firefox version currently in use, ~2–3% of global
traffic depending on the audience, and — same rendering path — every in-app WebView and embedded
browser built on an older engine snapshot. Design as if this browser is real, because it is.

## 3. Designing the fallback on purpose

A scroll-driven reveal is an enhancement. The resting state — what a non-supporting browser (or a
supporting one, before the animation has run) shows — is not "the page minus the motion", it's a
design you're responsible for, same as a loading state or an empty state.

- **Everything is there, in its final position, from first paint.** No staged reveal, no partial
  layout. If the page would look "finished" a beat after the animation completes, that's what
  everyone without timeline support gets immediately — which reads as a *faster* site, not a broken
  one.
- **Don't rely on scroll to ever restore visibility.** A non-supporting browser never runs the
  `@supports` block, at any scroll position. If the resting state is wrong, it is wrong forever, not
  "wrong until you scroll".
- **Keep any color/border/shadow embellishment that isn't the motion itself in the base rule.** Only
  the literal animated property (opacity, transform, mask position) belongs in the keyframe.
- **Sanity-check the resting frame by itself:** temporarily comment out the `@supports` block (or run
  the audit below) and look at the page. It should read as a deliberate, calmer version of the site —
  headline, copy and CTA all present — not a blank section with a chip floating in it.
- **This composes with `prefers-reduced-motion`.** Both guards remove the animation and fall back to
  the same rule: the base state is the finished state. Get the base state right once and both paths
  are correct for free.

## 4. What the rule reports

Two checks, same shape, different cost:

- **`lint/scroll-timeline-no-fallback`** (static, `void lint`) — parses the CSS for a selector that
  carries a hiding declaration (`opacity: 0`, `visibility: hidden`, a zero scale, a translate large
  enough to move content out of its box) as a **base** rule, where the only thing that ever undoes it
  (an `animation`/`animation-name` declaration, or a direct override back to a visible value) is inside
  an `@supports (animation-timeline: …)` block. No browser required — this is the fastest feedback for
  an agent, and it runs on every `void lint` pass.
- **`smooth/scroll-timeline-no-fallback`** (runtime, `void smooth` / `void audit`) — loads the page
  twice: once normally, once with every matching `@supports` block stripped from the CSS before the
  browser parses it (and the `ViewTimeline`/`ScrollTimeline` globals removed, for the rare case of a
  JS-driven timeline) — two independent renders of the same URL, the second reproducing what a
  non-supporting engine computes. Text- and media-bearing elements visible in the first render and
  invisible in the second are reported with their selector, the text they hide, and which property is
  responsible (`opacity`, `visibility`, `clip/transform`, or a collapsed box). An element that's hidden
  in *both* renders (a closed menu, `hidden`, `aria-hidden` decoration) never flips, so it's never
  reported — only content that a scroll-timeline browser sees and a non-supporting one doesn't.

`void audit` also prints a one-line engine-coverage note whenever `smooth` runs: every browser-driven
check executes in one engine (Chromium), and the scroll-timeline pass simulates a non-supporting
engine within that same Chromium rather than actually running Firefox/WebKit. That's a meaningful
difference from "we tested Firefox" worth saying out loud, not implying.

Fix in both cases is the same: move the hiding declaration out of the base rule and into the
`@keyframes` referenced from inside the `@supports` block.


---

<!-- https://void-design.vercel.app/docs/motion/recipes · updated 2026-09-16 -->

# Motion recipes (paste-ready)

All recipes use void tokens and Tailwind 4.3 syntax. Put plain CSS in `src/app/globals.css` after the direction import, inside `@layer components { … }` so utilities can still override it. Base already zeroes `--duration-base/slow/slower` and caps animations under `prefers-reduced-motion: reduce`; recipes add a motion-specific static frame where needed.

Library-shaped effects are **not** here: marquee / logo loop, spotlight card, border beam / gradient border with `@property`, number ticker, magnetic, shiny text, aurora and hero spotlight live in the `components` skill (lean void translations with tiers). Recipes marked † adapt `emilkowalski/skills` (MIT, see `CREDITS.md`).

---

## 1. Button press †

```tsx
<button className="press …">Save</button>           // void utility: scale .97 on :active, --duration-fast, ease-out
```

Hand-rolled equivalent (e.g. a card link):

```tsx
<a className="block rounded-xl border border-line-subtle p-6 transition-[scale,border-color] duration-(--duration-fast) ease-out hover:border-line-strong active:scale-99">…</a>
```

- `scale` scales children, which is what makes it read as a physical press.
- 0.97 for buttons, 0.99 or none for surfaces wider than 400px. No press on text links or inputs.

## 2. Hover lift that isn't `hover:scale-105`

```tsx
<a href="/changelog/42" className="group block rounded-xl focus-visible:outline-offset-4">
  <div className="rounded-xl border border-line-subtle bg-surface p-6 transition-[translate,border-color] duration-(--duration-base) ease-out
                  pointer-fine:group-hover:-translate-y-0.5 group-hover:border-line-strong">
    <h3 className="text-xl text-fg">Branch previews</h3>
    <p className="mt-2 text-md text-fg-muted">…</p>
  </div>
</a>
```

- The **inner** element moves; the link box stays put, so the pointer never leaves the hit area mid-animation (no flicker).
- Max 2px of lift. Pair with one other change (border **or** shadow step), not both.
- Arrow nudge on CTAs: `<svg className="transition-[translate] duration-(--duration-fast) ease-out group-hover:translate-x-0.5" />`.

## 3. Dropdown / popover from the trigger †

**Radix** (uses keyframes because Radix waits for `animationend` before unmounting):

```css
@layer components {
  .void-popover { transform-origin: var(--radix-popover-content-transform-origin, var(--radix-dropdown-menu-content-transform-origin, top)); }
  .void-popover[data-state="open"]   { animation: void-pop-in var(--duration-base) var(--ease-out); }
  .void-popover[data-state="closed"] { animation: void-pop-out var(--duration-fast) var(--ease-out); }
}
@keyframes void-pop-in  { from { opacity: 0; transform: scale(0.96); } }
@keyframes void-pop-out { to   { opacity: 0; transform: scale(0.96); } }
```

**Base UI** (transitions, interruptible):

```css
@layer components {
  .void-popup {
    transform-origin: var(--transform-origin);
    transition: opacity var(--duration-base) var(--ease-out), transform var(--duration-base) var(--ease-out);
  }
  .void-popup[data-starting-style], .void-popup[data-ending-style] { opacity: 0; transform: scale(0.96); }
  .void-popup[data-ending-style] { transition-duration: var(--duration-fast); }
}
```

**Native `popover`** (no library):

```css
@layer components {
  .void-native-popover {
    opacity: 1; transform: none;
    transition: opacity var(--duration-base) var(--ease-out), transform var(--duration-base) var(--ease-out),
                overlay var(--duration-base) allow-discrete, display var(--duration-base) allow-discrete;
  }
  .void-native-popover:not(:popover-open) { opacity: 0; transform: scale(0.96); transition-duration: var(--duration-fast); }
  @starting-style { .void-native-popover:popover-open { opacity: 0; transform: scale(0.96); } }
}
```

Surface: `rounded-xl border border-line bg-surface-raised p-1 shadow-2`. Menu items: `rounded-md px-2 py-1.5 text-sm data-highlighted:bg-surface-hover` with **no** transition (keyboard-driven highlight is instant).

## 4. Tooltip †

```css
@layer components {
  .void-tooltip {
    transform-origin: var(--transform-origin, var(--radix-tooltip-content-transform-origin));
    transition: opacity 125ms var(--ease-out), transform 125ms var(--ease-out);
  }
  .void-tooltip[data-starting-style], .void-tooltip[data-ending-style] { opacity: 0; transform: scale(0.97); }
  .void-tooltip[data-instant] { transition-duration: 0ms; }   /* Base UI: neighbours after the first open instantly */
}
```

Radix: `delayDuration={500}` on the provider, `skipDelayDuration={300}`. Tooltips are never the only place information lives.

## 5. Dialog (native `<dialog>`) †

```css
@layer components {
  dialog.void-dialog {
    opacity: 0; transform: scale(0.96);
    transition: opacity var(--duration-fast) var(--ease-out), transform var(--duration-fast) var(--ease-out),
                overlay var(--duration-fast) allow-discrete, display var(--duration-fast) allow-discrete;
  }
  dialog.void-dialog[open] { opacity: 1; transform: none; transition-duration: var(--duration-slow); }
  @starting-style { dialog.void-dialog[open] { opacity: 0; transform: scale(0.96); } }

  dialog.void-dialog::backdrop {
    background: color-mix(in oklch, var(--bg) 70%, transparent);
    opacity: 0;
    transition: opacity var(--duration-fast) var(--ease-out), overlay var(--duration-fast) allow-discrete, display var(--duration-fast) allow-discrete;
  }
  dialog.void-dialog[open]::backdrop { opacity: 1; transition-duration: var(--duration-slow); }
  @starting-style { dialog.void-dialog[open]::backdrop { opacity: 0; } }
}
```

```tsx
<dialog ref={ref} className="void-dialog m-auto w-full max-w-md rounded-2xl border border-line bg-surface-raised p-6 text-fg shadow-3">…</dialog>
// open: ref.current?.showModal()   close: ref.current?.close()
```

Exit uses the closed-state duration (`--duration-fast`), enter the open-state one (`--duration-slow`): exits are faster automatically. Use Radix/Base UI Dialog when you need focus return and scroll lock in older Safari (`a11y`).

## 6. Drawer / sheet †

```css
@layer components {
  .void-drawer { transform: translateY(0); transition: transform 450ms var(--ease-drawer); }
  .void-drawer[data-closed], .void-drawer[data-ending-style], .void-drawer[data-starting-style] { transform: translateY(100%); }
}
```

Dragging (if needed):
- Set `element.style.transform = \`translateY(${dy}px)\`` directly in `pointermove`; never a CSS variable on the parent (recalculates every child).
- `setPointerCapture` on drag start; ignore extra touch points; damp over-drag (`dy < 0 ? dy * 0.2 : dy`).
- Dismiss when `dy > height * 0.25` **or** `Math.abs(dy) / elapsedMs > 0.11`.
- In a scrollable drawer, only start dragging when `scrollTop === 0`, then ignore drags for 100ms after reaching the top.
- Prefer Base UI Drawer (maintained) over re-implementing.

## 7. Toast

Use `sonner` and wire it to tokens (shadcn's wrapper references `var(--popover)`, which doesn't exist in void):

```tsx
"use client";
import { Toaster as Sonner } from "sonner";
import type { CSSProperties } from "react";

export function Toaster() {
  return (
    <Sonner
      position="bottom-right"
      style={{ "--normal-bg": "var(--surface-raised)", "--normal-text": "var(--fg)", "--normal-border": "var(--line)", "--border-radius": "var(--radius-xl)" } as CSSProperties}
      toastOptions={{ classNames: { toast: "font-sans text-sm shadow-2", description: "text-fg-muted" } }}
    />
  );
}
```

Hand-rolled (no dependency), transitions not keyframes so rapid additions don't jump:

```css
@layer components {
  .void-toast {
    transform: translateY(calc(var(--i, 0) * -10px)) scale(calc(1 - var(--i, 0) * 0.05));
    opacity: 1;
    transition: transform 350ms var(--ease-out), opacity 350ms var(--ease-out);
  }
  @starting-style { .void-toast { transform: translateY(100%); opacity: 0; } }
  .void-toast[data-leaving] { transform: translateY(100%); opacity: 0; transition-duration: var(--duration-slow); }
}
```

Toasts only for results of user actions; pause timers while `document.hidden`; `role="status"` region (`a11y`).

## 8. Accordion

**Zero JS, `<details>`** (animated in Chromium 131+, instant elsewhere):

```css
@layer components {
  @media (prefers-reduced-motion: no-preference) {
    .void-accordion { interpolate-size: allow-keywords; }
    .void-accordion::details-content {
      block-size: 0; overflow: clip;
      transition: block-size var(--duration-base) var(--ease-out), content-visibility var(--duration-base) allow-discrete;
    }
    .void-accordion[open]::details-content { block-size: auto; }
  }
  .void-accordion > summary { list-style: none; cursor: pointer; }
  .void-accordion > summary::-webkit-details-marker { display: none; }
  .void-accordion .void-chevron { transition: rotate var(--duration-base) var(--ease-out); }
  .void-accordion[open] .void-chevron { rotate: 180deg; }
}
```

```tsx
<details className="void-accordion border-b border-line-subtle py-4" name="faq">
  <summary className="flex items-center justify-between gap-4 text-md text-fg">
    How is usage billed?
    <svg aria-hidden="true" className="void-chevron size-4 text-fg-muted" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="m4 6 4 4 4-4" /></svg>
  </summary>
  <p className="pt-3 text-md text-fg-muted">Per build minute, rounded up, invoiced monthly.</p>
</details>
```

`name="faq"` makes it exclusive (one open at a time). Controlled React fallback for all engines:

```tsx
<div className={cn("grid transition-[grid-template-rows] duration-(--duration-base) ease-out", open ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}>
  <div className="overflow-hidden">{children}</div>
</div>
```

`grid-template-rows` is a layout animation, accepted only for user-initiated accordions; keep it ≤ 250ms.

## 9. Tab indicator

One indicator, moved with `translate` + `scale` (compositor), positioned from the active tab:

```tsx
"use client";
import { useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/cn";

export function Tabs({ tabs }: { tabs: { id: string; label: string }[] }) {
  const [active, setActive] = useState(0);
  const [instant, setInstant] = useState(false);
  const list = useRef<HTMLDivElement>(null);
  const bar = useRef<HTMLSpanElement>(null);

  useLayoutEffect(() => {
    const place = () => {
      const tab = list.current?.querySelectorAll<HTMLElement>('[role="tab"]')[active];
      if (!tab || !bar.current) return;
      bar.current.style.translate = `${tab.offsetLeft}px 0`;
      bar.current.style.scale = `${tab.offsetWidth / 100} 1`;   // bar is 100px wide, origin left
    };
    place();
    const ro = new ResizeObserver(place);
    if (list.current) ro.observe(list.current);
    return () => ro.disconnect();
  }, [active]);

  return (
    <div ref={list} role="tablist" className="relative flex gap-1 border-b border-line-subtle"
      onKeyDown={(e) => {
        if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
        setInstant(true);                                            // keyboard: no animation
        setActive((i) => (i + (e.key === "ArrowRight" ? 1 : tabs.length - 1)) % tabs.length);
      }}>
      {tabs.map((t, i) => (
        <button key={t.id} role="tab" aria-selected={i === active} tabIndex={i === active ? 0 : -1}
          onClick={() => { setInstant(false); setActive(i); }}
          className={cn("px-3 py-2 text-sm transition-colors duration-(--duration-fast)", i === active ? "text-fg" : "text-fg-muted hover:text-fg")}>
          {t.label}
        </button>
      ))}
      <span ref={bar} aria-hidden="true"
        className={cn("absolute bottom-0 left-0 h-0.5 w-[100px] origin-left bg-fg", !instant && "transition-[translate,scale] duration-(--duration-base) ease-in-out")} />
    </div>
  );
}
```

Pill-shaped active background with a perfect text-color change (after Emil Kowalski): render a second copy of the tab list styled as active (`bg-fg text-bg`), stack it on top with `pointer-events-none aria-hidden`, and animate its `clip-path: inset(0 R 0 L round var(--radius-md))` to the active tab's box with `--duration-base` `ease-in-out`. Text and background change as one element, so they never drift out of sync.

## 10. Text reveals

**Hero lockup on load (LCP-safe):** H1 moves but never fades; supporting lines fade + rise with a stagger.

```css
@layer components {
  @media (prefers-reduced-motion: no-preference) {
    .void-rise   { animation: void-rise var(--duration-slower) var(--ease-out-expo) both; animation-delay: calc(var(--i, 0) * 60ms); }
    .void-settle { animation: void-settle var(--duration-slower) var(--ease-out-expo) both; }
  }
}
@keyframes void-rise   { from { opacity: 0; translate: 0 0.75rem; } }
@keyframes void-settle { from { translate: 0 0.3em; } }   /* no opacity: the H1 is visible at first paint */
```

```tsx
<h1 className="void-settle text-display-lg">…</h1>
<p className="void-rise [--i:1] mt-6 text-lg text-fg-muted">…</p>
<div className="void-rise [--i:2] mt-10 flex gap-6">…</div>
```

**Masked line rise on scroll** (hellohello-style section statements, not the hero). Author the line breaks (lines can't be measured on the server):

```tsx
const lines = ["We design memorable", "experiences for brands", "that refuse to blend in."];
<p className="void-lines text-display-sm text-fg">
  {lines.map((line, i) => (
    <span key={i} className="block overflow-clip pb-[0.08em]">
      <span className="void-line block" style={{ "--i": i } as React.CSSProperties}>{line}</span>
    </span>
  ))}
</p>
```

```css
@layer components {
  @media (prefers-reduced-motion: no-preference) {
    @supports (animation-timeline: view()) {
      .void-lines { view-timeline-name: --void-lines; }
      .void-line {
        animation: void-line-rise linear both;
        animation-timeline: --void-lines;
        animation-range: entry calc(10% + var(--i) * 8%) cover calc(30% + var(--i) * 8%);
      }
    }
  }
}
@keyframes void-line-rise { from { translate: 0 105%; } }
```

Unsupported browsers (Firefox) and reduced motion render the finished statement. Per-word reveals with `@starting-style`: `components` (RevealText). Don't split per character (one node per glyph).

**Stagger a group entrance** (cards appearing once) †: `.void-rise` with `[--i:0]…[--i:5]`; items beyond 6 share `--i:5`.

## 11. Page transitions (View Transitions)

Next 16 App Router with React 19.2's `<ViewTransition>`:

```tsx
// src/app/template.tsx — crossfade content between routes; header/footer stay outside
import { ViewTransition } from "react";   // if your React types lack it: /// <reference types="react/canary" />
export default function Template({ children }: { children: React.ReactNode }) {
  return <ViewTransition default="void-page">{children}</ViewTransition>;
}
```

```css
::view-transition-old(.void-page) { animation: void-vt-out var(--duration-fast) var(--ease-out) both; }
::view-transition-new(.void-page) { animation: void-vt-in var(--duration-slow) var(--ease-out) both; }
@keyframes void-vt-out { to { opacity: 0; } }
@keyframes void-vt-in  { from { opacity: 0; translate: 0 0.5rem; } }
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
}
```

- Shared element (thumbnail → detail): wrap both in `<ViewTransition name={\`post-${slug}\`}>`. ≤ 10 named elements per transition; names unique on the page.
- Directional transitions: `<Link href="/next" transitionTypes={["forward"]}>` (Next 16.2+) and style by type.
- Page-level ≤ 400ms. It is enhancement: navigation must work identically without it. Without React integration, use `document.startViewTransition` (`speed/references/rendering-smoothness.md` §4).

## 12. Theme switch without a transition flash

The template already does this (`src/lib/theme.ts` head script + `src/components/theme-toggle.tsx`): set the theme class in a blocking `<head>` script before paint; on toggle, inject `*{transition:none!important}`, flip the class, force a reflow, remove the style next frame. Icons swap with `dark:hidden`/`dark:block`, not React state. Optional circular reveal: `document.startViewTransition(flip)` + `clip-path` on `::view-transition-new(root)`, skipped under reduced motion.

## 13. Hold to confirm †

```css
@layer components {
  .void-hold { position: relative; overflow: clip; }
  .void-hold .void-hold-fill { position: absolute; inset: 0; background: var(--danger); clip-path: inset(0 100% 0 0); transition: clip-path 200ms var(--ease-out); }
  .void-hold:active .void-hold-fill { clip-path: inset(0 0 0 0); transition: clip-path 1500ms linear; }
}
```

Fire the action on `animationend`/a 1500ms timer started on `pointerdown` and cleared on `pointerup`/`pointerleave`. Slow where the user decides, fast where the system responds. Provide a keyboard path (confirm dialog).

## 14. Masking an imperfect crossfade †

When two states visibly overlap (icon swap, label change), blur the seam: `transition: filter 150ms ease, opacity 150ms ease` with `filter: blur(2px); opacity: 0.7` at the midpoint state. Keep blur ≤ 4px on small elements; never on large areas (Safari cost).

## 15. Skeleton shimmer

```tsx
<div className="h-4 w-40 rounded-sm bg-surface-hover motion-safe:animate-pulse" />
```

Static fill under reduced motion (`motion-safe:`); skeleton height equals the final content height; show only after 150–300ms of loading.


---

<!-- https://void-design.vercel.app/docs/motion/timing · updated 2026-09-16 -->

# Timing: durations, easing, stagger, springs

## 1. Duration by use

| Interaction | Duration | Token | Measured / source |
|---|---|---|---|
| Keyboard-driven UI (⌘K, list highlight, key-switched tabs) | 0ms | `--duration-instant` | Raycast has no palette animation; Emil Kowalski |
| Link color, hover color/bg | 100–150ms | `--duration-fast` | Linear links 100ms, Vercel 150ms |
| Button press (`:active`) | 100–160ms | `--duration-fast` | Linear 160ms scale .97 |
| Tooltip | 125–150ms | `--duration-base` or literal `125ms` | Emil 125–200 |
| Dropdown, popover, select, tabs | 150–250ms | `--duration-base` | Stripe nav 240ms, Anthropic dropdown 200ms |
| Accordion | 200–360ms | `--duration-base`–`--duration-slow` | Stripe accordion 360ms |
| Dialog, toast | 200–350ms | `--duration-slow` | Emil modal 200–300 |
| Drawer / sheet | 300–500ms | literal `450ms` + `ease-drawer` | Vaul 500ms |
| Page transition | 250–400ms | `--duration-slow`–`--duration-slower` | perf rule PERF-56 ≤ 400ms |
| Marketing entrance / reveal | 400–700ms | `--duration-slower` | Supabase 400, Stripe 500, Granola/Attio 600, Mercury 670 |
| Hero choreography total | ≤ 1000ms | — | Linear H1 1000–1500 (upper bound, don't exceed) |
| Ambient pulse / shimmer | 1.5–6s loop | literal | pulses 2–6s |
| Marquee | 30–180s loop | literal | Resend 180s, Supabase 30–35s |
| Spinner rotation | 600–800ms/turn | literal | faster spin feels faster |

**Reconciling "UI < 300ms" with longer marketing entrances:** they are different token groups. Components the user operates repeatedly stay ≤ 300ms (`fast`/`base`/`slow`). One-time, explanatory marketing motion may use `--duration-slower` (300–600ms depending on direction). `lint/long-ui-duration` flags interaction feedback over 500ms.

**Exit = 70–80% of enter**, and half the travel. Closed-state rules carry `--duration-fast`, open-state rules `--duration-slow`, so exits are faster automatically.

**Distance:** add ~50ms per extra 100px of travel beyond 100px. Mobile full-screen sheets 300–400ms; desktop equivalents 20–30% shorter.

**Direction personality** (already in the token values): precision quick and exact; editorial slow fades, no scale; swiss linear and near-instant (`--ease-out: linear`, fast = 0); warm springy panels; terminal `steps()` and 60–100ms; instrument minimal.

## 2. Easing decision flow

```
Entering or exiting the screen?          → ease-out            (never ease-in)
Moving/morphing while staying on screen? → ease-in-out         (tab indicator, reorder, carousel slide)
Simple color / opacity hover?            → ease (CSS keyword) or ease-out
Constant motion or progress?             → ease-linear         (marquee, hold-to-confirm fill, spinner)
Sheet / drawer?                          → ease-drawer
Marketing entrance or reveal?            → ease-out-expo
Gesture release with velocity?           → spring (motion)     { type: "spring", duration: 0.4, bounce: 0 }
Success / playful moment (warm only)?    → ease-spring-bouncy  (or bounce 0.15–0.25)
Otherwise                                → ease-out
```

Don't invent cubic-beziers. The measured top-site curves are already the tokens: `(.23,1,.32,1)` out-quint (Linear, Vercel, Raycast), `(.19,1,.22,1)` / `(.16,1,.3,1)` out-expo (8 sites), `(.32,.72,0,1)` drawer (7 sites), `(.77,0,.175,1)` in-out-quart (6 sites). Tailwind's default `ease-out` `(0,0,.2,1)` is too weak and is overridden by void.

## 3. Travel and scale

| Motion | Start state |
|---|---|
| Popover / menu | `opacity 0`, `scale(.96)` from trigger origin |
| Tooltip | `opacity 0`, `scale(.97)` |
| Dialog | `opacity 0`, `scale(.96)`, centered origin |
| Toast | `translateY(100%)` from its edge |
| Drawer | `translateY(100%)` |
| Reveal | `opacity 0`, `translateY(8–16px)` (`reveal` uses 12px) or masked `translateY(105%)` |
| Hero H1 | `translate 0.3em`, **no opacity** |
| Press | `scale(.97)` (surfaces .99) |
| Hover lift | `translateY(-2px)` max |

Never `scale(0)`, never travel > 40px for reveals (measured 8–24px), blur ≤ 4px on small elements only.

## 4. Stagger

| Case | Step | Cap |
|---|---|---|
| Hero lockup (eyebrow, H1, sub, CTAs) | 60ms | 4–5 items |
| Card/list group on first reveal | 40–60ms | 6 items / 300ms total; the rest share the last delay |
| Per word | 20–40ms | one sentence |
| Per character | avoid (15–30ms if unavoidable; one DOM node per glyph) | a single short word |

Stagger never blocks input, never replays on filter/sort/re-render, and collapses to 0 under reduced motion (base zeroes durations and delays).

## 5. Springs (motion library)

| Feel | Config |
|---|---|
| UI (sheets, layout) | `{ type: "spring", duration: 0.35–0.45, bounce: 0 }` |
| Gesture release | `{ type: "spring", duration: 0.5, bounce: 0.1 }` keeps velocity when interrupted |
| Playful (warm success) | `{ type: "spring", duration: 0.5, bounce: 0.2 }` |
| Pointer-follow (decorative) | `useSpring(value, { stiffness: 300, damping: 30 })` |

Near-critical damping. An overdamped spring (damping 60) took 5s to settle in the owner's app and was the top idle CPU cost. No springs on opacity or color. CSS springs: `ease-spring` (300/30, no overshoot, pair with ~300ms) and `ease-spring-bouncy` (400/24, ~9% overshoot, ~450ms).

## 6. Reduced motion

| Motion type | Under `prefers-reduced-motion: reduce` |
|---|---|
| Press, hover color | keep (≤ 100ms, not spatial) |
| Popover / dialog / toast | opacity only or instant (base zeroes base/slow) |
| Reveals, parallax, scrubbed scenes, marquees | removed, final frame shown |
| Page transitions | none |
| Canvas / WebGL | one static frame |
| Progress, spinners | keep (they convey state), slower or static bar |

CSS: base.css handles durations; recipes wrap spatial animation in `@media (prefers-reduced-motion: no-preference)`. JS: `<MotionConfig reducedMotion="user">` or `matchMedia("(prefers-reduced-motion: reduce)")` read in an effect (server snapshot `false`).


---

<!-- https://void-design.vercel.app/docs/motion/webgl-canvas · updated 2026-09-16 -->

# Canvas and WebGL

A canvas is the most expensive way to make a page feel alive. Use one only when it is the page's single signature and a CSS version (`inspire/references/effect-downgrades.md`, `components`) can't carry the idea.

## 1. Gate (all must be yes)

1. It's a marketing, portfolio or launch page (or the canvas **is** the product: a chart, editor, game).
2. It's the only T3/T4 effect on the page, and there is no other T2 signature.
3. It has a job: explains the product (data flowing, a network, a terminal), or is the brand's visual identity. Not "background vibe".
4. The page reads completely without it (text, CTA and LCP are HTML).
5. You can name its static fallback: a CSS gradient or a pre-rendered AVIF frame matching its average color.

If any answer is no, use T0/T1: grain, one light, masked grid, CSS scanlines, a looped muted `<video>` with a poster, or an AVIF still.

## 2. Hard rules (from shipped bugs)

| Rule | Why |
|---|---|
| **1 WebGL context per page**, never inside `.map()`, cards or avatars (`lint/webgl-in-map`, `smooth/multiple-webgl-contexts`) | browsers cap at ~8–16 contexts and silently kill the oldest; the owner's orb avatars died at the 9th |
| **Cancel** rAF offscreen (IntersectionObserver) and when `document.hidden`; never "skip the draw" inside a running loop (`lint/raf-without-cancel`, `smooth/raf-loop-idle`) | a skipped frame still wakes the main thread 60×/s |
| Never pause on user inactivity | the owner's backdrop froze while they were reading |
| **DPR ≤ 1.5** (1 for full-viewport backdrops), **≤ 30fps** ambient, render scale ≤ 0.6 for full-bleed shaders | shader cost = pixels × ops × fps: DPR 2 @ 60fps ≈ 3.5× DPR 1.5 @ 30fps |
| Backdrops sized to the **viewport** (`fixed inset-0`), never the page | a page-sized canvas cost ~30 MB of backing store |
| No allocation in the frame loop (typed arrays, hoisted formatters, gradients) | GC stutter |
| Measure in `ResizeObserver`, never `getBoundingClientRect` per frame | forced layout per frame |
| 2D: `ctx.setTransform(dpr,0,0,dpr,0,0)` on resize, never cumulative `ctx.scale` | drawing drifts after resizes |
| Handle `webglcontextlost`/`restored`; `WEBGL_lose_context.loseContext()` on unmount | frees the context slot |
| Props update uniforms via refs; never rebuild the program on prop change; never put ref-backed values in effect deps | rebuilt contexts flash and leak |
| `next/dynamic(() => import(...), { ssr: false })` from a client component; reserve the box | text paints first, no CLS |
| Mount persistent backdrops once in the **root layout** | re-seeding on every navigation |
| Reduced motion → draw one frame and stop | vestibular safety |
| Decorative: `aria-hidden="true"` + `pointer-events-none`; meaningful: `role="img"` + `aria-label` | a11y |
| Colors from tokens resolved once to RGB (shaders can't read `oklch()`/`var()`) | tokens stay the single source |
| `ogl` (10–14 KB) or raw WebGL2 for full-screen quads; `cobe` (5 KB) for globes; **no `three` for 2D quads** (140 KB+) | budget |

## 3. WebGL2

Use the `useWebGLLoop` hook in `speed/references/rendering-smoothness.md` §8 (context loss, DPR/fps caps, IO + visibility pause, reduced-motion frame, cleanup). Pass a module-scope `setup` so the effect doesn't re-run.

## 4. Canvas 2D hook

```tsx
// src/components/fx/use-canvas-2d.ts
"use client";
import { useEffect, useRef } from "react";

export type Draw2D = (ctx: CanvasRenderingContext2D, timeMs: number, size: { w: number; h: number }) => void;

/** Viewport- or element-sized 2D canvas loop: DPR + fps capped, paused offscreen/hidden, static under reduced motion. */
export function useCanvas2D(draw: Draw2D, { fps = 30, maxDpr = 1.5 } = {}) {
  const ref = useRef<HTMLCanvasElement>(null);
  const drawRef = useRef(draw);
  useEffect(() => { drawRef.current = draw; });                  // latest draw without restarting the loop

  useEffect(() => {
    const canvas = ref.current;
    const ctx = canvas?.getContext("2d");
    if (!canvas || !ctx) return;
    const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
    const size = { w: 0, h: 0 };
    const interval = 1000 / fps;
    let raf = 0, last = 0, visible = true;

    const paint = (t: number) => drawRef.current(ctx, t, size);
    const resize = () => {
      const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);
      const rect = canvas.getBoundingClientRect();              // measured on resize only
      size.w = rect.width; size.h = rect.height;
      canvas.width = Math.round(rect.width * dpr);
      canvas.height = Math.round(rect.height * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      paint(last);
    };
    const loop = (t: number) => {
      raf = requestAnimationFrame(loop);
      if (t - last < interval) return;
      last = t;
      paint(t);
    };
    const start = () => { if (!raf && visible && !document.hidden && !reduced) raf = requestAnimationFrame(loop); };
    const stop = () => { cancelAnimationFrame(raf); raf = 0; };
    const onVisibility = () => (document.hidden ? stop() : start());

    const ro = new ResizeObserver(resize);
    const io = new IntersectionObserver(([entry]) => { visible = !!entry?.isIntersecting; visible ? start() : stop(); });
    ro.observe(canvas);
    io.observe(canvas);
    document.addEventListener("visibilitychange", onVisibility);
    resize();
    start();

    return () => {
      stop();
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, [fps, maxDpr]);

  return ref;
}
```

```tsx
// src/components/fx/signal-field.tsx — usage
"use client";
import { useCanvas2D } from "./use-canvas-2d";
import { useTokenColor } from "./use-token-color";

const COUNT = 120;
const xs = new Float32Array(COUNT), ys = new Float32Array(COUNT);   // allocated once
for (let i = 0; i < COUNT; i++) { xs[i] = Math.random(); ys[i] = Math.random(); }

export function SignalField() {
  const brand = useTokenColor("--brand");
  const ref = useCanvas2D((ctx, t, { w, h }) => {
    ctx.clearRect(0, 0, w, h);
    ctx.fillStyle = brand.current;
    for (let i = 0; i < COUNT; i++) {
      const y = (ys[i]! * h + t * 0.01 * (1 + (i % 3))) % h;
      ctx.globalAlpha = 0.15 + (i % 5) * 0.08;
      ctx.fillRect(xs[i]! * w, y, 1.5, 1.5);
    }
  });
  return <canvas ref={ref} aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 size-full" />;
}
// Parent: const SignalField = dynamic(() => import("./signal-field").then(m => m.SignalField), { ssr: false });
// inside a `relative isolate` section with a bg fallback, e.g. bg-bg-subtle.
```

Random seeds at module scope are fine because the component is `ssr: false` (no hydration mismatch).

## 5. Token colors for canvas and shaders

```tsx
// src/components/fx/use-token-color.ts
"use client";
import { useEffect, useRef } from "react";

/** Resolves a CSS custom property (oklch, color-mix, var) to an rgb() string; updates on theme change. */
export function useTokenColor(name: `--${string}`) {
  const value = useRef("rgb(128 128 128)");
  useEffect(() => {
    const probe = document.createElement("canvas").getContext("2d")!;
    const read = () => {
      const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
      probe.clearRect(0, 0, 1, 1);
      probe.fillStyle = raw;                                    // the browser parses oklch()
      probe.fillRect(0, 0, 1, 1);
      const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;
      value.current = `rgb(${r} ${g} ${b})`;
    };
    read();
    const mo = new MutationObserver(read);                        // .dark / data-theme toggles
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme"] });
    return () => mo.disconnect();
  }, [name]);
  return value;                                                   // read .current inside the draw loop
}
```

For WebGL uniforms, divide the channels by 255. For `next/og` images and manifests, use the generated `src/styles/void/tokens.ts` hex mirror instead.

## 6. Fallback layering

```tsx
<section className="relative isolate overflow-clip">
  {/* 1. CSS fallback: always present, matches the effect's average color */}
  <div aria-hidden="true" className="absolute inset-0 -z-20 bg-[radial-gradient(80%_60%_at_50%_0%,color-mix(in_oklch,var(--brand)_10%,var(--bg)),var(--bg))]" />
  {/* 2. The effect, loaded after paint, hidden under reduced data */}
  <SignalField />
  {/* 3. Content, fully readable over both */}
  <Container className="relative">…</Container>
</section>
```

Contrast: text over any effect must pass with the effect at its brightest frame. Keep effects out from behind body copy; put them behind the hero headline area or beside it.

## 7. CSS-first alternatives

| Wanted | T0/T1 version |
|---|---|
| Shader gradient / aurora | 2–3 blurred radial layers drifting with `translate` keyframes 20–40s, paused offscreen (`components` → AuroraBackdrop) |
| CRT / scanlines | `repeating-linear-gradient` scanlines + `grain` + inset vignette `box-shadow: inset 0 0 120px var(--bg)` |
| Particle field | static SVG dots + one slow `translate` loop on the layer |
| Globe | a static SVG/AVIF render; `cobe` only if it must rotate |
| Dithered video | AVIF still with `grain`; or muted `<video poster preload="none" playsInline loop>` played in view |
| 3D product | pre-rendered AVIF sequence or a short video |


---

<!-- https://void-design.vercel.app/docs/components · updated 2026-09-16 -->

# components — take the idea, not the code

Component libraries are **catalogues of techniques**, not dependencies. The output is always void-native: tokens only, a Server Component where possible, the lowest effect tier that carries the look, audited, and attributed.

Owner of: library sourcing, licences, registry audits, and signature-effect translations. For UI micro-interactions (press, dropdown, dialog, toast, accordion, tabs), timing and effect tiers, see `motion`. For WebGL and canvas hooks, see `speed` → `references/rendering-smoothness.md` §8 and `motion` → `references/webgl-canvas.md`. For rebuilding a site you admire, see `inspire`. For the visual system and anti-slop checks, see `craft`.

## The 10 rules

1. **Check the licence before you read the code.** MIT/Apache/Unlicense can be vendored with the notice kept. React Bits, Animate UI, Aceternity, Hover.dev and skiper-ui (free tier) are **inspiration only** in anything open-source: write a clean-room rewrite. React Bits' Commons Clause explicitly bans redistributing "a ported version".
2. **Effect budget: 1 signature + 1 secondary per page.** A signature is a hero backdrop, a hero product animation, or one scroll set piece. A secondary is one marquee, one spotlight grid or one beam card. Masked patterns, one glow and grain are free. Zero decorative effects on authenticated app routes, and none on anything a user triggers 100+ times a day or from the keyboard.
3. **Behaviour comes from a headless library, visuals from tokens.** Dialogs, drawers, menus, comboboxes, tabs and toasts come from Base UI / Radix / React Aria (+ sonner, cmdk). Never ship a registry's hand-rolled `div` modal.
4. **Use a recipe before a registry.** Check `references/recipes.md` first. Adapt registry source only when no recipe or library covers the effect, and only after the audit.
5. **Never `npx shadcn add` straight into the app while evaluating.** Fetch the JSON, read `files[].content`, `dependencies` and `css`/`cssVars`, and run the audit. Unaudited registry code does not ship.
6. **Assume every registry component is broken** until proven otherwise: no reduced motion (Magic UI 5/78, Aceternity 3/118, React Bits 3/56 backgrounds), no offscreen pause, hardcoded colours, v3-only Tailwind. Catalogue of failures: `references/upstream-bugs.md`.
7. **The server HTML carries the real content.** Headlines, numbers and links are in the HTML and visible without JS. Never use `opacity:0` on LCP text, and never let a counter SSR as `0`.
8. **Compositor-first.** Continuous motion uses only `transform`/`translate`/`opacity`. Zero React renders per frame. Every loop pauses offscreen and has a reduced-motion finished frame.
9. **One of each:** one animation runtime (`motion`, never also `framer-motion` or GSAP), one WebGL context per page, one primitive library per app.
10. **Attribute.** Vendored MIT code keeps its copyright header plus a `THIRD_PARTY_NOTICES.md` entry. A clean-room rewrite gets an "inspired by" comment. Never claim affiliation.

## Decision tree

```
Need a component or effect?
├─ Behaviour (focus trap, menu, combobox, drawer, toast, ⌘K, date picker)?
│    → headless library + token styling:
│      Base UI (@base-ui/react 1.8: Dialog, Drawer, Toast, Combobox, OTPField) · Radix (radix-ui) ·
│      React Aria (i18n, dates, drag a11y) · sonner (toasts) · cmdk (⌘K, no open animation).
│      vaul is unmaintained → Base UI Drawer. Motion for these: `motion` recipes.
├─ Static structure (pricing table, bento, footer, auth form)?
│    → build from template primitives (Card, Section, Button). For layout ideas, read MIT sources
│      (shadcn blocks, Tailark GitHub, coss ui, opensourceui). No animation needed.
├─ Signature/secondary visual (marquee, spotlight, reveal, beam, aurora, ticker…)?
│    1. GATE: frequency (first visit only), a purpose in one word, budget left on this page? No → T0 static.
│    2. RECIPE: is it in references/recipes.md? → paste it. Done.
│    3. LIBRARY: a real package that solves it? numbers → @number-flow/react · globe → cobe (T4) ·
│       text morph → torph · springs/layout/exit → motion (LazyMotion + m).
│    4. REGISTRY (last resort): shortlist 2–4 by licence + tier → fetch JSON → 16-point audit →
│       MIT-class: vendor + fix every fail · anything else: write a spec, close the source, clean-room.
│       Tier too high for the budget (WebGL for a mood)? → down-tier (inspire → effect-downgrades).
└─ User pasted code from a library? → identify licence + component → run the audit → rewrite.
```

## Effect tiers (summary; the full rules are in `motion` §3)

| Tier | What | JS | Page budget |
|---|---|---|---|
| T0 static CSS | gradients, masks, grid/dots, grain, glow | 0 KB | free within taste: one glow, one grain, masked patterns |
| T1 CSS animated | keyframes, transitions, `@starting-style`, `view()` timelines | 0 KB | ≤3 infinite per viewport, ≤1 paint-bound |
| T2 JS / motion | pointer-driven, springs, counters | rAF 0.3–1 KB · LazyMotion +32 KB · full motion +46 KB | 1 hero signature + 1 secondary; ≤2 islands |
| T3 canvas 2D | particle/line fields | 2–12 KB | 1 viewport canvas; excludes T4 |
| T4 WebGL | shaders, globes, 3D | ogl 10–14 · cobe 5 · three 140+ KB | **1 context**, marketing hero only |

Assign a tier from a registry item's dependencies: nothing or CSS → T1 · `motion`/`framer-motion`/`gsap`/rAF → T2 · `getContext('2d')` → T3 · `ogl`/`three`/`@react-three/*`/`cobe`/`webgl` → T4.

## Licence matrix (verified 2026-09-16; not legal advice)

| Library | Licence | Install in an app | Copy into an OSS repo / template | Attribution |
|---|---|---|---|---|
| shadcn/ui, Magic UI, motion-primitives, Cult UI, kibo-ui, smoothui, Kokonut, Eldora, Luxe, RareUI, opensourceui, uiverse (galaxy) | MIT | yes | yes, keep notice | licence text in notices |
| coss ui / Origin UI (`coss.com/ui/r`, `coss.com/origin/r`) | MIT (repo default AGPL; `apps/ui` + `apps/origin` MIT) | yes | registry components yes; never the AGPL app code | notice |
| Tailark | MIT on GitHub; hosted `/r/` blocks paywalled (401) | from GitHub | from GitHub | notice |
| Base UI, Radix, Ark UI, sonner, cmdk, vaul | MIT | depend | depend | — |
| React Aria | Apache-2.0 | depend | keep NOTICE | NOTICE |
| GSAP (all plugins, incl. SplitText) | free standard licence (no Webflow-like builders) | yes, but ~25–70 KB and a second runtime | depend only | — |
| ogl | Unlicense | yes | yes | — |
| **React Bits** | MIT + Commons Clause (no selling or redistribution, "or as a ported version") | yes, via reactbits.dev | **no: clean-room only** | courtesy |
| **Animate UI** | MIT + Commons Clause ("in their original form") | yes | clean-room | courtesy |
| **Skiper UI** free / Pro | free: use and modify with **attribution required**; Pro paid | free with its licence comment kept | clean-room (else every user inherits attribution) | required if used |
| **Aceternity UI** | proprietary terms: no redistribution of item or source | copy free items into the app | clean-room | courtesy |
| **Hover.dev** | proprietary | per purchase | nothing | — |
| **21st.dev** | ToS 2026-07-20: use via the platform; per-author licences | per author's licence, via official MCP/CLI | design reference only | credit the author |
| shadcnblocks (paid), Skiper Pro, Aceternity Pro | commercial | buyers only, never in public repos | nothing | — |

Ideas and techniques are free to reuse; the source text is not. Details and per-library notes: `references/catalogue.md`.

## Fetch registry source (read-only; nothing is written to the repo)

```bash
curl -sL https://magicui.design/r/marquee.json | jq -r '.files[].content'          # source
curl -sL https://magicui.design/r/marquee.json | jq '{dependencies, registryDependencies, css, cssVars}'
npx shadcn@latest view @magicui/marquee                                             # via the 344-namespace index
curl -sL https://reactbits.dev/r/Aurora-TS-TW.json | jq -r '.files[0].content'      # <Name>-<TS|JS>-<TW|CSS>
curl -sL https://ui.aceternity.com/registry/spotlight.json | jq -r '.files[0].content'
curl -sL https://skiper-ui.com/registry/skiper40.json | jq -r '.files[0].content'   # new path; /r/skiperN.json still 200
curl -sL https://coss.com/ui/r/button.json | jq -r '.files[0].content'
curl -sL https://ui.shadcn.com/r/registries.json | jq -r '.[] | select(.name=="@smoothui") | .url'  # find any URL template
# 429 bot challenge (motion-primitives, Cult UI) → raw GitHub:
curl -sL https://raw.githubusercontent.com/ibelick/motion-primitives/main/public/c/magnetic.json | jq -r '.files[0].content'
curl -sL https://raw.githubusercontent.com/nolly-studio/cult-ui/main/apps/www/public/r/texture-card.json | jq -r '.files[0].content'
# no registry (opensourceui) → raw file:
curl -sL https://raw.githubusercontent.com/bidyut10/opensourceui/main/components/<cat>/<name>.tsx
```

- `401`/`403`/`Missing license key` means paid or authenticated. **Stop.** Don't look for mirrors.
- RareUI slugs differ from its docs (`/components/fluidorb` → `/r/fluid-orb.json`).
- 21st.dev `/r/*` returns 403 without auth. Use the official `@21st-dev/magic` MCP with the user's key, and treat results as reference.
- Record the dependencies. A custom `animate-*` class with no `css`/`cssVars` in the item means **the animation is dead on Tailwind v4** (Aceternity ships none).

## Audit (16 points; full grep hints in `references/audit-checklist.md`)

Each fail becomes a line in the rewrite plan.

1. **Licence:** the class from the matrix plus the actual file header.
2. **SSR safety:** no `window`/`document`/`navigator`/`matchMedia` in render or at module scope; no `Math.random()`/`Date.now()` in render (`lint/browser-global-in-render`).
3. **Server HTML complete:** headline, numbers and links present and visible; no `opacity-0`/`initial="hidden"` on text.
4. **Cleanup 1:1:** every `addEventListener`/`observe`/rAF/`setInterval`/`.on(`/`new Lenis`/`createGlobe` has a remove, disconnect, cancel, clear, off or destroy (`lint/listener-without-cleanup`, `lint/raf-without-cancel`, `lint/timer-in-render`).
5. **Hooks:** none inside conditionals or JSX; no `m.create()` in render; no `ref.current` in deps; no MotionValue `.on` in render (`lint/motionvalue-subscribe-in-render`).
6. **Zero per-frame React work:** no `setState` in pointer/scroll/rAF handlers; no allocation (`new Intl.NumberFormat`, `new Color`) in loops.
7. **Reduced motion:** a finished static frame with the content intact (`lint/no-reduced-motion`, `smooth/reduced-motion-ignored`).
8. **Offscreen pause:** infinite animations use `data-fx` or IntersectionObserver; JS loops also stop on `visibilitychange` (`smooth/raf-loop-idle`).
9. **DPR cap** ≤1.5 (1 for backdrops); full-bleed shader render scale ≤0.6.
10. **FPS cap** ≤30 for ambient T3/T4.
11. **Properties:** continuous animation only on transform/opacity; no `width/height/top/left`, gradient or `filter` loops (`lint/animate-layout-prop`, `smooth/animate-layout-property`).
12. **A11y:** decorative layers `aria-hidden`; duplicates `aria-hidden` + `inert`; real `<a>`/`<button type>` (`lint/div-button`); hover has a focus equivalent; content moving >5s has a pause.
13. **Hover gating:** `(hover:hover) and (pointer:fine)`; pointer handlers check `pointerType`.
14. **Weight and duplicates:** fits the tier; no `framer-motion` + `motion`, no `@base-ui-components/react` + `@base-ui/react`, no three for a 2D quad, no face-api (`lint/heavy-import`).
15. **Tokens:** no hex/rgb/`neutral-800`/`purple-500`; motion uses `--ease-*`/`--duration-*` (`lint/hardcoded-colors`, `design/purple-gradient`, `design/gradient-text`).
16. **Tailwind v4:** no `bg-gradient-to-*`, `flex-shrink-0`, `duration-[--x]`, config keyframes or undefined `animate-*` (`lint/tw-v3-arbitrary-var`, `lint/tw-v3-renamed`, `lint/tw-unknown-class`, `lint/tw-js-config-ignored`).

## Rewrite onto void tokens

**Clean-room procedure** (required for non-MIT sources, recommended for all):
1. Write a spec of 5–10 lines: what moves, which property, duration, trigger, and the reduced-motion frame.
2. Close the source.
3. Build at the lowest tier, reusing recipe patterns.
4. Diff check: no identifiers, comments, magic numbers or file structure carried over.

| Registry code | void |
|---|---|
| `bg-black`, `bg-neutral-950`, `dark:bg-black` | `bg-bg` (page) · `bg-surface` / `surface-card` (cards) |
| `text-white`, `text-black dark:text-white` · `text-neutral-400` | `text-fg` · `text-fg-muted` (`text-fg-subtle` for meta/captions/placeholders, AA 4.5:1; `text-fg-faint` only for disabled controls or `aria-hidden` decoration, 3:1, never readable text) |
| `border-neutral-800`, `border-white/10` | `border-line` (`border-line-strong` on hover) |
| `#9c40ff → #ffaa40`, `from-purple-500 to-pink-500` | one hue: `var(--brand)`, `color-mix(in oklab, var(--brand) 20%, transparent)`, `--brand-line` |
| shadcn `bg-background text-muted-foreground bg-primary bg-accent` | `bg-bg text-fg-muted bg-brand bg-surface-hover`, or import `@void/tokens/shadcn.css` (shadcn `accent` = hover surface, **not** brand) |
| `shadow-lg`, `rounded-3xl` | `shadow-2` (default shadows are removed), `rounded-xl` |
| `transition-all duration-500 ease-in-out` | `transition-[translate,opacity] duration-(--duration-slow) ease-out` |
| `bg-gradient-to-r`, `flex-shrink-0`, `duration-[--x]` | `bg-linear-to-r`, `shrink-0`, `duration-(--x)` |
| `animate-spotlight` with no CSS; `tailwind.config` keyframes | `@keyframes` in plain CSS inside `@layer components`, or `--animate-*` + `@keyframes` in `@theme` |
| `import { motion } from "framer-motion"` | CSS first; else `import * as m from "motion/react-m"` + `LazyMotion` |
| `useState` position on `mousemove` | a ref + rAF writing `el.style.translate` (recipe 2) |
| missing `'use client'` (all React Bits files) | add it only on the smallest interactive leaf; keep the rest server |

## Recipes (`references/recipes.md`, all verified in a Next 16 build)

| Component | Replaces | Tier / JS |
|---|---|---|
| `Marquee` | Magic UI marquee, React Bits LogoLoop | T1 / 0 |
| `SpotlightCard` | React Bits SpotlightCard, Magic UI magic-card | T2 / ~0.5 KB |
| `RevealText` | React Bits SplitText/BlurText, Aceternity text-generate-effect | T1 / 0 |
| `BeamBorder` | Magic UI border-beam, React Bits StarBorder | T1 / 0 |
| `PatternBackdrop` (+ `grain`) | Magic UI grid/dot-pattern, Aceternity background-beams | T0 / 0 |
| `NumberTicker` | Magic UI number-ticker, React Bits CountUp | T2 / ~0.6 KB |
| `Magnetic` | React Bits Magnet, motion-primitives magnetic | T2 / ~0.5 KB |
| `ShinyText` | React Bits ShinyText, Magic UI animated-shiny-text | T1-paint / 0 |
| `AuroraBackdrop` | React Bits Aurora/Silk (WebGL) | T1 / 0 |
| `HeroSpotlight` | Aceternity spotlight | T1 one-shot / 0 |

Shared infrastructure: `FxGate` (mount once, pauses every `[data-fx]` offscreen) and `src/styles/fx.css` imported last in `globals.css`. Two measured traps: never put a paint animation (ShinyText) over an animated backdrop, and never fade an animated layer with `mask-image` (use a static overlay).

`PatternBackdrop`/`AuroraBackdrop`/`HeroSpotlight` above cover T0/T1 section backgrounds. For the rest of the "React Bits Backgrounds" vocabulary the owner points to (liquid/fluid distortion, particle field, pixel/dither grid, displacement grid) — a measured decision table by cost tier, the cheap-first CSS/SVG ladder to try before any of them, the one-shared-WebGL-context pattern for a page with several background sections, and paste-ready clean-room OGL implementations: `motion/references/backgrounds.md`. Same rule 1 applies: these are the *idea*, rewritten from scratch — never a ported React Bits/Animate UI file.

## Attribution

```ts
/**
 * Adapted from Magic UI "marquee": https://magicui.design/docs/components/marquee
 * Copyright (c) Magic UI. MIT License (see THIRD_PARTY_NOTICES.md).
 * Changes: CSS-only, aria-hidden/inert duplicate, offscreen pause, reduced motion, void tokens.
 */
```

```ts
/**
 * Visual idea inspired by React Bits "SpotlightCard" (https://reactbits.dev).
 * Independent implementation; no source code copied.
 */
```

- **Vendored MIT:** keep the header, and append the full licence text to `THIRD_PARTY_NOTICES.md` (create it at the repo root).
- **Skiper free code actually used:** keep its entire trailing licence comment. Prefer clean-room.
- **21st.dev:** credit the component author and follow that author's licence.

## Verify

1. `void lint`: 0 errors, and no `lint/hardcoded-colors`, `lint/no-reduced-motion`, `lint/raf-without-cancel` or `lint/tw-*` in the new files.
2. `next build`; then `curl -s localhost:3000/ | grep '12,400'` (or the real headline and number) proves server HTML.
3. `void smooth --start "next start -p 3000" --port 3000`: dropped frames ≤5% while scrolling, and the WebGL context count ≤1.
4. Screenshot dark, light, `--force-prefers-reduced-motion` and 390px (see `audit`); every effect shows a finished, on-token frame.

## References

- `references/recipes.md`: the 10 components plus FxGate and fx.css, paste-ready, with a verification log.
- `references/catalogue.md`: 30+ libraries by best use, licence, registry URL, deps, a11y/RM quality and maintenance.
- `references/audit-checklist.md`: the 16 points with grep/AST commands and pass criteria.
- `references/upstream-bugs.md`: 48 known bugs grouped by pattern, with fix patterns.


---

<!-- https://void-design.vercel.app/docs/components/audit-checklist · updated 2026-09-16 -->

# Audit checklist: 16 points for registry/component-library source

Run this after fetching a registry item's JSON (`SKILL.md` → fetch commands) and before rewriting it. Every **fail** becomes one line in the rewrite plan (template at the bottom). `rg`/`jq` here are triage greps over the fetched source — fast, but pattern-matching, not real parsing. `void lint .` runs the same checks as real AST rules on committed files and is the ground truth; use it once the rewrite lands in the repo. Bug write-ups per pattern (with the library/component that shipped them): `references/upstream-bugs.md`. Paste-ready replacements: `references/recipes.md`.

**Re-verify rule ids before citing them elsewhere** — the rules files change:
```bash
grep -n 'id: "' packages/cli/src/rules/*.ts          # design/seo/perf/a11y/geo ids, verbatim
grep -oE 'r\("[a-z0-9-]+"' packages/cli/src/rules/lint.ts   # lint/* ids (prefix is added by the r() helper)
bun packages/cli/src/index.ts rules <id>              # print one rule's why/fix/example
bun packages/cli/src/index.ts rules                   # list every rule
```

**Registry item metadata in one shot:**
```bash
curl -sL <item>.json | jq '{dependencies, registryDependencies, css, cssVars}'
curl -sL <item>.json | jq -r '.files[].content' > /tmp/item.tsx   # source to a scratch file for rg
diff <(jq -r '.dependencies[]' /tmp/item.json) <(jq -r '.dependencies|keys[]' package.json)  # phantom/renamed deps
```

## 1. Licence

**Check:** which licence class the item's source repo/registry falls under, and what the actual file header says (registries sometimes ship code the docs site doesn't mention).
```bash
head -20 /tmp/item.tsx | grep -i 'copyright\|license'
curl -sL <repo>/LICENSE            # or LICENSING.md (coss/Origin UI), LICENCE.md (motion-primitives)
```
**Pass:** MIT/Apache/Unlicense → may vendor with notice kept. Commons Clause (React Bits, Animate UI), proprietary (Aceternity, Hover.dev) or attribution-required (Skiper free) → clean-room only. Full matrix: `SKILL.md` → Licence matrix, `references/catalogue.md`.
**Rule:** none (legal, not lintable).
**Fix:** MIT-class → vendor + fix every other fail below, keep the header. Anything else → write the 5–10 line spec (`SKILL.md` → Rewrite onto void tokens), close the source, build clean-room.

## 2. SSR safety

**Check:** browser globals or non-deterministic values evaluated at module scope or during render (outside `useEffect`/event handlers).
```bash
rg -n 'window\.|document\.|navigator\.|matchMedia\(|localStorage' /tmp/item.tsx
rg -n 'Math\.random\(\)|Date\.now\(\)|new Date\(\)' /tmp/item.tsx
rg -n -B2 'window\.|Math\.random' /tmp/item.tsx | rg -v 'useEffect|addEventListener\('   # crude: flags hits not near an effect
```
**Pass:** zero hits outside `useEffect`/`useSyncExternalStore`/event handlers.
**Rule:** `lint/browser-global-in-render`, `lint/random-in-render`.
**Fix:** read browser APIs in an effect or `useSyncExternalStore` (server snapshot = the neutral default); replace `Math.random()` with a value seeded from the index. Pattern group: `references/upstream-bugs.md` §3.

## 3. Server HTML is complete

**Check:** headline, numbers and links are present and visible in the raw HTML, not hidden until JS runs.
```bash
rg -n 'opacity-0|opacity:\s*0|initial=\{\{\s*opacity:\s*0|initial="hidden"' /tmp/item.tsx
curl -s <dev-url> | grep -oE '<h1[^>]*>.*</h1>'          # headline present, no opacity-0 class
curl -s <dev-url> | grep -o '12,400'                      # the real number, not "0" or empty
curl -s <dev-url> | grep -c '<a '                          # links present without JS
```
**Pass:** real content in the curl output; no `opacity-0`/`initial="hidden"` on above-the-fold text; a numeric component never SSRs `0`/`""`.
**Rule:** none static (grep + curl only); the runtime symptom shows up as `perf/lcp-slow` once it ships.
**Fix:** SSR the final value/text and animate position only (`RevealText`), or animate a number only if it starts offscreen (`NumberTicker`). Pattern group: `references/upstream-bugs.md` §2.

## 4. Cleanup is 1:1

**Check:** every subscription, observer, timer or library instance has a matching teardown.
```bash
rg -c 'addEventListener\(' /tmp/item.tsx;      rg -c 'removeEventListener\(' /tmp/item.tsx
rg -c 'requestAnimationFrame\(' /tmp/item.tsx; rg -c 'cancelAnimationFrame\(' /tmp/item.tsx
rg -c 'setInterval\(|setTimeout\(' /tmp/item.tsx; rg -c 'clearInterval\(|clearTimeout\(' /tmp/item.tsx
rg -n 'new Lenis\(|createGlobe\(' /tmp/item.tsx; rg -n '\.destroy\(\)' /tmp/item.tsx
rg -c '\.on\(' /tmp/item.tsx;                   rg -c '\.off\(' /tmp/item.tsx
```
**Pass:** each "add" count has a matching "remove" count (not just present somewhere — check it's in the same effect's cleanup return).
**Rule:** `lint/listener-without-cleanup`, `lint/raf-without-cancel`, `lint/timer-in-render`, `lint/unload-listener` (an `unload` listener specifically blocks bfcache).
**Fix:** return the remove/disconnect/cancel/clear/destroy from the same `useEffect`, or pass `{ signal }` from one `AbortController` and abort it. Pattern group: `references/upstream-bugs.md` §4.

## 5. Hooks correctness

**Check:** hooks called unconditionally at the top level; no component/MotionValue-subscription creation during render; stable effect deps.
```bash
rg -n '&&\s*<.*use[A-Z]\w*\(|\?\s*use[A-Z]\w*\(|style=\{\{[^}]*use[A-Z]\w*\(' /tmp/item.tsx   # AST hint, approximate: use[A-Z]… inside &&/ternary/JSX literal
rg -n 'm\.create\(|createElement\(' /tmp/item.tsx        # component defined/created in render
rg -n '\.on\((["'\'']change)' /tmp/item.tsx              # MotionValue .on outside useMotionValueEvent
rg -n '\[.*ref\.current.*\]' /tmp/item.tsx                # ref.current in a deps array
```
**Pass:** no hooks inside `&&`/ternary/JSX prop literals; `m.create()`/component factories only at module scope or `useMemo`; MotionValue subscriptions via `useMotionValueEvent`; refs never appear inside `[]` deps.
**Rule:** `lint/conditional-hook`, `lint/motionvalue-subscribe-in-render`, `lint/svg-global-id` (a related AST smell: a hardcoded SVG `id` instead of `useId()`, breaks the 2nd instance on the page).
**Fix:** hoist the hook call, feed the result in conditionally; `useMotionValueEvent(value, 'change', cb)`; `useId()`-scope any SVG `<filter>`/`<linearGradient>` id. Pattern group: `references/upstream-bugs.md` §5.

## 6. Zero per-frame React work

**Check:** no `setState` inside a pointer/scroll/rAF handler; no allocation inside a loop.
```bash
rg -n -B3 'set[A-Z]\w*\(' /tmp/item.tsx | rg -B3 'onPointerMove|onMouseMove|onScroll|requestAnimationFrame'   # AST hint: setState reachable from a per-frame handler
rg -n -B2 'new Intl\.NumberFormat|new Color\(' /tmp/item.tsx    # then confirm the call site is inside rAF/.on('change')/useAnimationFrame
rg -n 'getBoundingClientRect\(\)' /tmp/item.tsx                 # forced layout reads; flag if inside a loop/handler
```
**Pass:** pointer/scroll paths write to a ref's `style`/CSS custom property or a MotionValue `.set()`, never `setState`; formatters/colour objects built once (module scope or `useMemo`), not per frame.
**Rule:** `lint/setstate-per-pointer-move`, `lint/allocation-per-frame`.
**Fix:** ref + one rAF writing `el.style.translate`/`--x` (recipe 2 `SpotlightCard`, recipe 7 `Magnetic`); hoist `Intl.NumberFormat` to `useMemo`. Pattern group: `references/upstream-bugs.md` §6.

## 7. Reduced motion

**Check:** the file honours the OS preference, and the honouring actually disables the loop (not just present).
```bash
rg -c 'prefers-reduced-motion|useReducedMotion|motion-reduce:|motion-safe:' /tmp/item.tsx
```
**Pass:** count > 0, **and** manually confirm it gates the actual animation (a global CSS reduced-motion rule alone doesn't cover JS rAF loops or inline `style` transforms — those need their own check).
**Rule:** `lint/no-reduced-motion`, `smooth/reduced-motion-ignored`.
**Fix:** a static finished frame with content intact; disable via `motion-reduce:`/`@media (prefers-reduced-motion: reduce)` for CSS, `useReducedMotion()`/`<MotionConfig reducedMotion="user">` for JS. This is the single most common fail (~95% of registry items) — never skip it. Cross-cutting across every group in `references/upstream-bugs.md`.

## 8. Offscreen pause

**Check:** infinite animations stop when off-screen; JS loops also stop on tab hide.
```bash
rg -n 'IntersectionObserver|useInView|data-fx' /tmp/item.tsx
rg -n 'visibilitychange' /tmp/item.tsx    # JS rAF/WebGL loops only
rg -n 'repeat:\s*Infinity|infinite\b' /tmp/item.tsx   # candidates that need one of the above
```
**Pass:** every `repeat: Infinity`/`infinite`/rAF loop is paired with an IntersectionObserver, `useInView`, or the shared `[data-fx]` gate; JS loops also react to `visibilitychange`. Pausing must **cancel** the loop (`cancelAnimationFrame`), not just skip drawing — a skipped-but-still-scheduled rAF still burns a callback every frame.
**Rule:** `smooth/raf-loop-idle`.
**Fix:** CSS-driven effects get `data-fx` + the shared `FxGate` (recipes.md §0); JS/WebGL loops cancel on `visibilitychange` and IntersectionObserver both. Pattern group: `references/upstream-bugs.md` §7.

## 9. DPR / resolution cap

**Check:** canvas/WebGL pixel ratio is capped, not raw or hardcoded.
```bash
rg -n 'devicePixelRatio' /tmp/item.tsx
rg -n 'devicePixelRatio\s*[,)]\s*1\.5|Math\.min\(.*devicePixelRatio' /tmp/item.tsx   # the capped form
rg -n 'devicePixelRatio:\s*2|width\s*\*\s*2|height\s*\*\s*2' /tmp/item.tsx           # hardcoded 2x (Magic UI globe)
```
**Pass:** `Math.min(devicePixelRatio, 1.5)` (1 for full-bleed backdrops) read inside an effect, never a bare or hardcoded `2`; full-bleed shader render scale ≤0.6.
**Rule:** `lint/hardcoded-device-pixel-ratio`.
**Fix:** cap and resize via `ResizeObserver`, never at module scope. Pattern group: `references/upstream-bugs.md` §10.

## 10. Frame-rate cap

**Check:** ambient T3/T4 loops throttle to ≤30fps instead of running the full rAF rate.
```bash
rg -n 'requestAnimationFrame' /tmp/item.tsx    # then read the callback: does it throttle, or draw every frame unconditionally?
rg -n 'frameloop\s*=\s*.always.|fpsLimit|1000\s*/\s*[0-9]+' /tmp/item.tsx
```
**Pass:** ambient backgrounds (aurora, globes, particle fields) skip frames to land at ≤30fps; foreground/interactive canvas may run full rate. No static grep proves this — read the draw loop.
**Rule:** none static; verify with `void smooth <url> --routes <route>` (dropped-frame % and the profiler timeline).
**Fix:** accumulate elapsed time and only draw past a `1000/30` threshold. Pattern group: `references/upstream-bugs.md` §10.

## 11. Properties animated

**Check:** continuous motion touches only `transform`/`opacity`; anything else is one-shot or a single small element.
```bash
rg -n '@keyframes' -A6 /tmp/item.tsx   # read the keyframe body
rg -n 'width:|height:|top:|left:|margin|padding' /tmp/item.tsx        # layout properties in an animated block
rg -n 'background-position|filter:|box-shadow|linearGradient.*x1|y1' /tmp/item.tsx   # paint properties
rg -n 'transition:\s*all|transition-all\b' /tmp/item.tsx
```
**Pass:** no `width/height/top/left/margin/padding` inside a `repeat`/`infinite` block; paint properties (`filter`, `background-position`, `box-shadow`, SVG gradient coordinates) animate once or on one small element only, never continuously full-bleed; no `transition: all`.
**Rule:** `lint/animate-layout-prop`, `smooth/animate-layout-property`, `lint/transition-all`, `smooth/transition-all`.
**Fix:** `translate`/`scale`/`rotate`/`opacity` only; `grid-template-rows: 0fr → 1fr` for accordions; a static overlay instead of `mask-image` over animated children (measured: 10% dropped frames); `will-change: transform` on the one paint-animated element, never many. Pattern group: `references/upstream-bugs.md` §8 — also see the recipes.md aurora/shine gotcha (never stack a paint animation over an animated backdrop: 14–46% dropped frames measured).

## 12. Accessibility

**Check:** decorative layers hidden; duplicated content hidden from AT; real interactive elements; hover has a keyboard/focus equivalent; long-moving content can pause.
```bash
rg -c '\.map\(' /tmp/item.tsx                                    # candidates for duplicated marquee/loop content
rg -n 'aria-hidden' /tmp/item.tsx                                 # present on every duplicate + every decorative layer?
rg -n 'inert' /tmp/item.tsx                                       # duplicates need aria-hidden AND inert (aria-hidden alone still leaves links focusable)
rg -n '<div[^>]*onClick|<span[^>]*onClick' /tmp/item.tsx          # clickable non-interactive element
rg -n '<button(?![^>]*type=)' /tmp/item.tsx                       # <button> missing an explicit type (approximate)
rg -n ':hover|whileHover|group-hover' /tmp/item.tsx;  rg -n 'focus-visible|:focus\b' /tmp/item.tsx   # hover without a focus counterpart
```
**Pass:** decorative layers `aria-hidden`; duplicated content (marquee copies, `TextRoll`) has both `aria-hidden` and `inert`; clickable elements are real `<a href>`/`<button type>`; canvas/SVG art either `aria-hidden` or `role="img"` + label; every `:hover`/`whileHover` has a `:focus-visible` equivalent; content that moves for >5s can be paused (WCAG 2.2.2).
**Rule:** `lint/duplicate-children-not-hidden`, `lint/div-button`, `lint/button-missing-type`, `lint/svg-global-id`, `a11y/target-size`, `a11y/focus-not-visible`.
**Fix:** `Array.from({length: repeat}, (_, i) => <div aria-hidden={i>0} inert={i>0}>…)`; `<button type="button">`; pair hover styles with `focus-visible:`. Pattern group: `references/upstream-bugs.md` §11.

## 13. Hover gating

**Check:** hover-driven motion is gated to real pointers, and pointer handlers check the pointer type.
```bash
rg -n '@media\s*\(hover:\s*hover\)|hover:hover.*pointer:fine' /tmp/item.tsx
rg -n 'pointerType' /tmp/item.tsx
rg -n ':hover|whileHover|group-hover' /tmp/item.tsx    # candidates that should be inside the gate above
```
**Pass:** every hover-triggered animation sits under `@media (hover: hover) and (pointer: fine)` (Tailwind: `hover:hover:` variant stacking, or a plain media query in CSS); pointer event handlers branch on `e.pointerType !== 'touch'` where a mouse-only effect (magnetic, spotlight) would otherwise fire from a tap.
**Rule:** none dedicated; covered by manual review plus `lint/setstate-per-pointer-move` for the handler itself.
**Fix:** wrap the CSS in the media query; check `pointerType` before running pointer-follow logic. Pattern noted in `references/upstream-bugs.md` §11 (hover-only pause on the marquee is the same root cause: hover semantics applied without a touch/keyboard fallback).

## 14. Dependency weight and duplicates

**Check:** the item's dependencies fit the target tier, aren't phantom/renamed, and don't duplicate a library already in the app.
```bash
jq '.dependencies' /tmp/item.json
diff <(jq -r '.dependencies[]?' /tmp/item.json | sort) <(jq -r '.dependencies|keys[]' package.json | sort)   # phantom deps not in package.json
rg -n 'framer-motion' /tmp/item.tsx     # duplicate runtime if the app already uses `motion`
rg -n '@base-ui-components/react' /tmp/item.tsx   # renamed package (now @base-ui/react) — duplicate, stale
rg -n 'three|@react-three' /tmp/item.tsx          # three/R3F for what might be a 2D quad
rg -n 'face-api' /tmp/item.tsx                    # ~700KB bundle bomb (React Bits GridScan)
```
**Pass:** deps map to the tier the effect actually needs (nothing/CSS → T1, `motion` → T2, `getContext('2d')` → T3, `ogl`/`three`/`cobe` → T4 — see `SKILL.md` tier table); no `framer-motion` next to `motion`, no `@base-ui-components/react` next to `@base-ui/react`, no three.js for a single 2D quad.
**Rule:** `lint/heavy-import`, `smooth/multiple-webgl-contexts`, `lint/webgl-in-map` (a WebGL canvas rendered inside `.map()` — browsers evict contexts past ~8–16 per page).
**Fix:** down-tier (WebGL Aurora → CSS gradients, recipe 9) or swap for the matching library (`@number-flow/react`, `cobe`, `torph` — `SKILL.md` decision tree step 3). Also watch for DOM/node explosions from the same audit pass (`Array(n).map()` of animated nodes, e.g. Magic UI dot-pattern's ~5,130 `<circle>`s) — same fix direction, lower node count via CSS. Pattern groups: `references/upstream-bugs.md` §9, §10.

## 15. Tokens

**Check:** no raw colour literals or default Tailwind palette classes; motion timing uses tokens, not magic numbers.
```bash
rg -n '#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(' /tmp/item.tsx
rg -n '\b(neutral|zinc|gray|slate|purple|indigo|violet|pink)-[0-9]{2,3}\b' /tmp/item.tsx
rg -n 'duration-\[[0-9]|ease-\[cubic-bezier|[0-9]{2,4}ms' /tmp/item.tsx   # hardcoded durations/eases instead of --duration-*/--ease-*
```
**Pass:** every colour is a `var(--color-*)`/semantic utility (`bg-surface`, `text-fg-muted`, `border-line`); no default-palette classes; motion timing reads `--ease-*`/`--duration-*`.
**Rule:** `lint/hardcoded-colors`, `design/purple-gradient` (the violet→indigo hero trope), `design/gradient-text` (background-clip:text headlines), `design/multiple-accents`.
**Fix:** run the registry→void token table in `SKILL.md` → Rewrite onto void tokens; one hue via `var(--brand)` + `color-mix(in oklab, var(--brand) 20%, transparent)`.

## 16. Tailwind v4 correctness

**Check:** v3-only syntax that silently compiles to nothing or the wrong value under v4.
```bash
rg -n 'bg-gradient-to-|flex-shrink-0|flex-grow-0' /tmp/item.tsx
rg -n 'duration-\[--|ease-\[--|[a-z-]+-\[--[a-z-]+\]' /tmp/item.tsx     # v3 `[--var]` arbitrary-var syntax; v4 needs `(--var)`
rg -n '\bshadow\b(?!-)|\brounded\b(?!-)|\boutline-none\b' /tmp/item.tsx  # bare/renamed v3 utilities
rg -n 'animate-[a-z-]+' /tmp/item.tsx                                    # then confirm each has matching css/cssVars (jq check above) or a `--animate-*` in @theme
ls tailwind.config.* 2>/dev/null && rg -L '@config' src/**/*.css        # JS config present but never loaded from the CSS entry
```
**Pass:** `bg-linear-to-*` not `bg-gradient-to-*`; `shrink-0` not `flex-shrink-0`; `duration-(--x)` not `duration-[--x]`; `shadow-xs`/`rounded-xs`/`outline-hidden` (v4 renames); every custom `animate-*` class has matching CSS shipped with the item or defined in `@theme`; a `tailwind.config.*`, if present, is loaded via `@config` or has been migrated into `@theme`.
**Rule:** `lint/tw-unknown-class`, `lint/tw-v3-arbitrary-var`, `lint/tw-v3-renamed`, `lint/tw-js-config-ignored`.
**Fix:** rename per the v3→v4 map in `SKILL.md` → Rewrite onto void tokens; move keyframes into `@layer components` or `@theme`; `next build` and grep the compiled CSS for the class to confirm it exists. Pattern group: `references/upstream-bugs.md` §1.

## Rewrite plan template

Fill this in per item, in the PR/commit description, after running all 16 points:

| # | Point | Fail found | Fix applied | Rule id(s) closed |
|---|---|---|---|---|
| 2 | SSR safety | `navigator.userAgent` at module scope | moved into `useSyncExternalStore`, server snapshot `false` | `lint/browser-global-in-render` |
| 7 | Reduced motion | no `prefers-reduced-motion` anywhere | added a finished static frame + `motion-reduce:` | `lint/no-reduced-motion`, `smooth/reduced-motion-ignored` |
| 12 | Accessibility | 3 marquee copies, no `aria-hidden`/`inert` | `aria-hidden={i>0}` + `inert={i>0}` on copies 2–3 | `lint/duplicate-children-not-hidden` |
| 15 | Tokens | `#9c40ff → #ffaa40` gradient | one hue via `var(--brand)` | `lint/hardcoded-colors`, `design/purple-gradient` |

An item with zero fails after a real MIT-class licence may be vendored as-is (still keep the copyright header). Anything with a licence fail skips straight to clean-room regardless of how many other points pass.


---

<!-- https://void-design.vercel.app/docs/components/catalogue · updated 2026-09-16 -->

# Component library catalogue (verified 2026-09-16)

How this was verified: licences were read from the actual `LICENSE`/`LICENSING.md` files, and registry URLs were probed with `curl`. The shadcn registry index (`https://ui.shadcn.com/r/registries.json`) lists 344 namespaces. This is an engineering summary, not legal advice.

**Legend**
- **Tier:** T0 static · T1 CSS animated · T2 JS/motion · T3 canvas 2D · T4 WebGL (see SKILL.md).
- **A11y:** ●●● WAI-ARIA-grade · ●● mostly semantic · ● decorative, little ARIA · ○ none or broken.
- **RM:** reduced-motion handling as measured on the live registry.
- **Use as:** `depend` (npm), `vendor` (copy MIT source + notice, then fix), `clean-room` (read for technique, write your own), `reference` (look only).

## 1. Behaviour primitives (depend on these; style with tokens)

| Library | Licence | Install | Best for | A11y | Maintenance | Notes |
|---|---|---|---|---|---|---|
| **Base UI** `@base-ui/react@1.8.0` | MIT | npm; shadcn `base` style, `@basecn` | Dialog, Popover, Menu, **Drawer, Toast, Combobox, Autocomplete, OTPField** | ●●● | very active (2026-09-15) | void's default. Animate with `data-starting-style`/`data-ending-style`, `var(--transform-origin)` and `[data-instant]` (`motion` recipes). |
| **Radix** `radix-ui@1.6.7` | MIT | npm | same set, huge ecosystem (shadcn default) | ●●● | slower (2026-08-08) | origin var `--radix-<comp>-content-transform-origin`. Fine if the project already uses it; don't mix with Base UI. |
| **React Aria Components** `react-aria-components@1.21.1` | Apache-2.0 | npm; shadcn `@react-aria` → `react-aria.adobe.com/registry/tailwind-<name>.json` | date/time pickers, i18n, drag-and-drop a11y, touch | ●●●+ | very active | best i18n and touch; heavier; keep NOTICE if vendoring. |
| **Ark UI** `@ark-ui/react@5.39.2` | MIT | npm | multi-framework (React/Solid/Vue/Svelte) | ●●● | very active | zag.js state machines. |
| **sonner** `sonner@2.0.8` | MIT | npm; shadcn `sonner` | toasts | ●● (`aria-live`) | active (2026-08-09) | token-wire it: the shadcn wrapper references `var(--popover)`, which only exists with `shadcn.css` imported. |
| **cmdk** `cmdk@1.1.1` | MIT | npm | ⌘K palettes | ●●● | slow (2025-03) | no open/close animation (keyboard-triggered). ~15 KB. |
| **vaul** `vaul@1.1.2` | MIT | npm | — | ●●● | **unmaintained** (README) | read its source as the reference drawer (drag velocity, `--ease-drawer`); ship Base UI Drawer. |
| **@number-flow/react** `0.6.2` | MIT | npm | animated numbers | ●● | active | handles locale, digit width and RM. Prefer it over any registry ticker. |
| **cobe** `2.0.1` | MIT | npm | rotating globe (T4, ~5 KB) | ● | active | 1 context; Magic UI's globe pins `cobe@^0.6`. |
| **torph** `0.1.3` | MIT | npm | text morphing | ● | new | on Emil Kowalski's recommended list. |
| **GSAP** `gsap@3.15.0` | standard no-charge licence, **all plugins free** (SplitText, ScrollTrigger, Inertia) | npm | agency scroll choreography with pinning | ● | active | licence is no longer an issue; ~25 KB core, ~70 KB with ScrollTrigger + `@gsap/react`, and a second runtime next to motion. Needs a written reason. |
| **ogl** `1.0.11` | Unlicense | npm | full-screen shader quads (10–14 KB) | — | stable | use with the `speed` §8 hook; never three for a quad. |

## 2. Styled registries and effect libraries

| Library | Licence (exact) | Registry URL format | Typical deps → tier | A11y | RM | Maintenance | Best for | Use as |
|---|---|---|---|---|---|---|---|---|
| **shadcn/ui** + blocks | MIT | `npx shadcn add <name>`; blocks `sidebar-07`, `login-03` | Radix or Base UI, lucide, `tw-animate-css` → T0/T1 | ●●● | via tw-animate | very active (`shadcn@4.21.0`) | the baseline: primitives, dashboard/auth blocks, charts | vendor |
| **Magic UI** | MIT ("Copyright (c) Magic UI") | `https://magicui.design/r/<name>.json` (`@magicui`), `.json` optional | `motion`, `cobe`, `canvas-confetti`, `next-themes` → T1/T2; 8 of 78 C2D/GL | ●–●● | **5/78** | very active (2026-09-13) | marquee, terminal, bento, beams, number/text effects | vendor after audit; v4-native (`cssVars.theme` + `css` keys) |
| **React Bits** | **MIT + Commons Clause**: no selling, sublicensing or redistribution "alone, in a bundle, or as a ported version" | `https://reactbits.dev/r/<Name>-<TS\|JS>-<TW\|CSS>.json` (`@react-bits`) | `ogl`, `three` + R3F (+drei, postprocessing), `gsap` (+SplitText/ScrollTrigger/Inertia), `motion`, `face-api.js` → T2–T4 | ● | **3/56 backgrounds** | very active (2026-09-15), ~47k★, 171 comps | hero backgrounds, text effects, cursor toys | **clean-room** in OSS; apps may install it. Add `'use client'` (none of the 171 files have it); WebGL behind `next/dynamic({ ssr:false })`. Plasma is the hygiene reference. Avoid GridScan (face-api ~700 KB), Hyperspeed, LiquidEther and Silk as shipped. |
| **Aceternity UI** | no OSS licence; terms: no redistribution of "the Item … or its source" | `https://ui.aceternity.com/registry/<name>.json` (`@aceternity`); Pro → 401 | `motion`, `three`, `@react-three/*`, `cobe`, `simplex-noise` → T2–T4; 17 of 118 C2D/GL | ○–● | **3/118** | active, no public repo | hero spotlights and beams, 3D cards | **clean-room**. Items ship **no `css`/`cssVars`**, so custom `animate-*` classes are dead on v4. 26 of 118 call `Math.random()` (hydration risk). |
| **Skiper UI** | free tier: "Free to use and modify … Attribution to Skiper UI is required" (comment in every file); Pro $129/$549 | `https://skiper-ui.com/registry/skiperN.json` (old `/r/skiperN.json` also 200), `@skiper-ui`; Pro needs `Authorization: Bearer` → "Missing license key" | `framer-motion`, `lenis`, `gsap`, `swiper`, `@number-flow/react`, `media-chrome`, `use-sound` → T2 | ● | rare | active; 106 comps (37 free), N in 1–107 | scroll set pieces, micro-interactions, "inspired rebuilds" of cursor.com/vercel.com | clean-room (or keep the licence comment in an app). Codemod `framer-motion` → `motion/react`; demos reference unshipped `/images/lummi/*`. |
| **motion-primitives** | MIT (`LICENCE.md`) | `https://motion-primitives.com/c/<name>.json` (429 bot challenge) → `raw.githubusercontent.com/ibelick/motion-primitives/main/public/c/<name>.json` | `motion` only → T2 | ●● | few | active (2026-09-12) | text effects, in-view gate, morphing dialog, tilt, magnetic | vendor; `text-effect` must use `per="word"`. |
| **Animate UI** | **MIT + Commons Clause** ("in their original form") | `https://animate-ui.com/r/<name>.json`, e.g. `components-backgrounds-stars` (`@animate-ui`), 580 items | `motion` (366 items), `radix-ui`, `@base-ui-components/react` (**renamed package**), `@headlessui/react` → T2 | ●● | partial | repo 2025-12-31; registry healthy | animated Radix/Base primitives, animated icons | clean-room; 17 items pull the pre-1.0 Base UI package (duplicate install). |
| **coss ui** (ex-Origin UI) | repo AGPL-3.0; **`apps/ui/` and `apps/origin/` MIT** per `LICENSING.md` | `https://coss.com/ui/r/<name>.json` (`@coss`); legacy `coss.com/origin/r/<name>.json` | `@base-ui/react`, `@headless-tree` → T0 | ●●● | n/a | active (2026-09-08) | Base UI primitives and form controls, ~600 examples | vendor registry items; never Cal.com app code. **Corrects** older notes that called Origin UI AGPL. |
| **kibo-ui** | MIT (`shadcnblocks/kibo`) | `https://www.kibo-ui.com/r/<name>.json` (`@kibo-ui`) | shiki, dnd-kit, TipTap, Sandpack, jotai → T0 | ○–●● | none | slowing (2026-05-04) | dev-tool blocks: `snippet`, `code-block` (RSC), kanban | vendor `snippet`/`code-block/server`; avoid `tree` (zero ARIA), `gantt`, `relative-time`, `editor` (350–500 KB), `sandbox` (1 MB+). |
| **Cult UI** | MIT | `https://www.cult-ui.com/r/<name>.json` (429) → raw GitHub `nolly-studio/cult-ui/main/apps/www/public/r/<name>.json` | `motion`, `react-use-measure` → T2 | ●● | none | 2026-07-22 | texture cards, dynamic island, mock browser | vendor after audit (`direction-aware-tabs` hardcodes `layoutId`). |
| **Tailark** | MIT (`tailark/blocks`); hosted `/r/` blocks 401 | index `https://tailark.com/r/registry.json` (Base UI) + `/r/radix/registry.json`; source `registry/bases/{base,radix}/{dusk,mist,veil}/blocks/*` | `motion`, Base UI or Radix → T1/T2 | ●● | none | 2026-07-29 | **marketing blocks**: 92 heroes, 44 features, pricing, auth | layout reference / vendor from GitHub; skip its `tailwindcss-animate` plugin, `progressive-blur` (8 backdrop layers) and ungated `infinite-slider`. |
| **smoothui** | MIT | `https://smoothui.dev/r/<name>.json` (`@smoothui`), 178 items | `motion` (144), `usehooks-ts` → T2 | ●● | partial | 2026-09-12 | micro-interactions with Emil-adjacent taste | vendor after audit. |
| **Kokonut UI** | MIT | `https://kokonutui.com/r/<name>.json` (`@kokonutui`) | `motion` → T2 | ● | rare | 2026-08-20 | AI-app flourishes, cards | vendor after audit. |
| **Eldora UI** | MIT | `https://eldoraui.site/r/<name>.json` (`@eldoraui`) | `motion` → T2 | ● | rare | 2026-04-18 | Magic-UI-like text and background effects | vendor after audit. |
| **Luxe** | MIT | copy-paste (not in the shadcn index) | Radix, `motion` → T1/T2 | ●● | partial | 2026-09-04 | restrained buttons, inputs, OTP | vendor. |
| **RareUI** | MIT (`swamimalode07/rare-ui`) | `https://www.rareui.com/r/<kebab>.json` (slug ≠ docs slug) | `motion`, raw WebGL → T2/T4 | ● | none | very active (2026-09-15) | orbs, grid reveal, sidebar rail | vendor after audit; FluidOrb = one WebGL context per instance (never in lists); HookSidebar springs `top`/`height`. |
| **opensourceui** | MIT (`bidyut10/opensourceui`) | **no registry**: `raw.githubusercontent.com/bidyut10/opensourceui/main/components/<cat>/<name>.tsx` | clsx, tailwind-merge, lucide → T0 | ●● | n/a | 2026-09-11 | static structure, API contract (`data-slot`, `cn` last), docs search/TOC | vendor; all 207 backgrounds are static. |
| **uiverse.io** | MIT (Copyright 2023 Uiverse.io; `uiverse-io/galaxy`), per-author posts | none (HTML/CSS + Tailwind snippets) | none → T1 | ○–● | rare | site active, repo 2024-09 | CSS-only buttons, loaders, toggles to study | vendor with notice + author credit; usually needs a11y fixes. |
| **ReUI** | MIT (free `registry:ui`); Pro blocks 401 | `https://reui.io/r/<name>.json` | varies | ●● | none | active | tree, data grid | free items only; `scrollspy` and `frame` are broken. |
| **Hover.dev** | proprietary: "may not be redistributed without the written consent of Hover.dev" | none (copy from the site; Pro $49) | `framer-motion` → T2 | ● | rare | active | hover/scroll interaction studies | **reference only**. |
| **21st.dev** | ToS (2026-07-20): view and use "solely through the official 21st.dev platform"; per-author licences | `/r/<author>/<component>` → 403 without auth; official MCP `@21st-dev/magic` needs an API key | varies, often `framer-motion` + v3 config keyframes → T2–T4 | varies | rare | active | **visual search** across authors | reference; if building on an item, follow its author's licence and credit them. |
| **shadcnblocks** (paid) | proprietary | — | — | — | — | — | — | nothing in public repos (also ships a phantom `cn` dependency). |

### Watch list (2026 shadcn index, not audited)

| Namespace | What | Why watch |
|---|---|---|
| `@ai-elements`, `@assistant-ui`, `@prompt-kit`, `@tool-ui`, `@agents-ui`, `@elevenlabs-ui` | AI/agent chat and voice UI | the agent transcript is the 2026 "hero object" |
| `@basecn`, `@diceui`, `@intentui` | Base UI / React Aria component sets | Radix-era alternatives |
| `@lucide-animated`, `@heroicons-animated`, `@hugeicons-animated` | animated icon sets | micro-interactions; check RM first |
| `@motiq` (MIT) | claims "accessible, reduced-motion-safe, RSC-safe" | verify before trusting |
| `@vue-bits`, `@svelte-bits` | React Bits ports | proof the "ported version" clause matters |

## 3. Where to look for what

| Need | Look at (inspiration) | Ship |
|---|---|---|
| Hero backdrop mood | React Bits backgrounds, Aceternity, 21st.dev | `PatternBackdrop` / `AuroraBackdrop` / `HeroSpotlight`; a shader only per `speed` §8 |
| Headline entrance | React Bits SplitText/BlurText, motion-primitives text-effect | `RevealText`, or `motion` §10 masked lines |
| Logo wall | Magic UI marquee | `Marquee` |
| Feature grid flourish | React Bits SpotlightCard, Magic UI magic-card, Aceternity card-spotlight | `SpotlightCard` (one grid) |
| "Recommended" plan | Magic UI border-beam / shine-border | `BeamBorder` |
| Stats | Magic UI number-ticker | `@number-flow/react` or `NumberTicker` |
| Marketing blocks (hero, pricing, FAQ) | Tailark GitHub, shadcn blocks, coss ui | template primitives + `website` section archetypes |
| Scroll set pieces | skiper-ui, hellohello-style sites | `motion` §5–6 (`view()` timelines, one `useScroll`) |
| Button/loader micro-interactions | uiverse.io, smoothui, Hover.dev | `motion` recipes |
| Dev-tool UI (code, terminal, file tree) | kibo-ui, Magic UI terminal | kibo `code-block/server`, `@headless-tree/react` |
| Agent/chat UI | `@ai-elements`, `@assistant-ui` | vendor after audit |

## 4. Weight reference (gzip)

`motion` component +46 KB, `LazyMotion` + `m` + `domAnimation` +32 KB (measured on Next 16.3.5) · `gsap` ~25 KB, ~70 KB with ScrollTrigger + react · `three` 140–160 KB (+R3F 40–50, drei 15–60, postprocessing 50–70) · `ogl` 10–14 KB · `cobe` ~5 KB · `lenis` ~10 KB + permanent rAF (don't) · `swiper` ~40 KB (use embla) · `face-api.js` ~700 KB (ban) · `cmdk` ~15 KB · `thinking-orbs` 7.5 KB (shared-clock canvas done right).


---

<!-- https://void-design.vercel.app/docs/components/recipes · updated 2026-09-16 -->

# Signature effect recipes (library effect → lean void component)

Ten clean-room translations of the effects agents most often copy from React Bits, Magic UI, Aceternity, skiper-ui and motion-primitives. Every snippet here was built with `next build` (Next 16.3.5, React 19.2, Tailwind 4.3.3, `@void/tokens` precision direction), rendered in dark, light, reduced-motion and 390px, checked for real text in the server HTML with JS disabled, and scroll-measured with `void smooth` (verified 2026-09-16).

**Rules shared by all ten**
- Tokens only: `--brand`, `--brand-line`, `--fg`, `--fg-muted`, `--surface`, `--line`, `--bg`, `--radius-xl`, `--ease-out`, `--ease-in-out`, `--ease-out-expo`, `--duration-slow`, `--duration-slower`. No hex, no palette classes.
- Continuous motion uses only `transform`/`translate`/`opacity`. Shiny text (paint) is the one exception, and it is limited to one short string.
- Every infinite animation carries `data-fx` and is paused offscreen by `FxGate`. Every effect has a `prefers-reduced-motion: reduce` frame that shows the finished state.
- Server Components by default. Only `SpotlightCard`, `NumberTicker`, `Magnetic` and `FxGate` are client islands, each under 1 KB.
- Decorative layers are `aria-hidden`, `pointer-events-none`, absolutely positioned, and sit behind the content. Their parent needs `relative isolate`.
- These are **independent implementations**. No upstream source was copied, so they carry an "inspired by" courtesy comment and no licence obligation.

| # | Component | Inspired by | Tier / JS | Client? |
|---|---|---|---|---|
| 1 | `Marquee` | Magic UI marquee, React Bits LogoLoop | T1 / 0 KB | no |
| 2 | `SpotlightCard` | React Bits SpotlightCard, Magic UI magic-card | T2 / ~0.5 KB | yes |
| 3 | `RevealText` | React Bits SplitText/BlurText, Aceternity text-generate-effect | T1 / 0 KB | no |
| 4 | `BeamBorder` | Magic UI border-beam, React Bits StarBorder | T1 / 0 KB | no |
| 5 | `PatternBackdrop` (+ `grain`) | Magic UI grid/dot-pattern, Aceternity background-beams | T0 / 0 KB | no |
| 6 | `NumberTicker` | Magic UI number-ticker, React Bits CountUp | T2 / ~0.6 KB | yes |
| 7 | `Magnetic` | React Bits Magnet, motion-primitives magnetic | T2 / ~0.5 KB | yes |
| 8 | `ShinyText` | React Bits ShinyText, Magic UI animated-shiny-text | T1-paint / 0 KB | no |
| 9 | `AuroraBackdrop` | React Bits Aurora/Silk (WebGL) | T1 / 0 KB | no |
| 10 | `HeroSpotlight` | Aceternity spotlight | T1 one-shot / 0 KB | no |

Budget: **1 signature + 1 secondary per page** (see SKILL.md). This page shows all ten only because it is a test bench.

## 0. Setup (once per project)

1. Save each component to `src/components/fx/<kebab-name>.tsx`. They import `cn` from `@/lib/cn`, the template's class joiner.
2. Save the CSS blocks of the recipes you use to `src/styles/fx.css`, then add `@import "../styles/fx.css";` as the **last** line of `src/app/globals.css`, after the direction import. Only paste the blocks you use; each block is self-contained.
3. Mount `<FxGate />` once in `src/app/layout.tsx`, inside `<body>`, after `<SiteFooter />`.

Header and shared pause rule for `src/styles/fx.css`:

```css
/* fx.css: void signature effects. Import in globals.css AFTER the direction file.
   Tokens only (--brand, --fg, --fg-muted, --surface, --line, --bg, --radius-*, --ease-*, --duration-*).
   Classes live in @layer components so Tailwind utilities (p-6, rounded-2xl…) still override them. */

/* ── 0. Offscreen pause, toggled by <FxGate /> on every [data-fx] element ─────────────────── */
[data-fx-paused],
[data-fx-paused] *,
[data-fx-paused]::before,
[data-fx-paused]::after,
[data-fx-paused] ::before,
[data-fx-paused] ::after {
  animation-play-state: paused !important;
}
```

`src/components/fx/fx-gate.tsx` (a ~0.4 KB client island). Hidden tabs already stop CSS animations, so no `visibilitychange` listener is needed. For a single client effect you can wrap it in `speed` → `PauseOffscreen` instead; both mechanisms can coexist.

```tsx
"use client";
import { usePathname } from "next/navigation";
import { useEffect } from "react";

/** Pauses CSS animations of every [data-fx] element while it is offscreen. Mount once in
 *  app/layout.tsx. Starts running; pauses only on evidence of being offscreen (no delayed entrances). */
export function FxGate() {
  const pathname = usePathname();
  useEffect(() => {
    const els = document.querySelectorAll<HTMLElement>("[data-fx]");
    if (els.length === 0) return;
    const io = new IntersectionObserver(
      (entries) => {
        for (const e of entries) e.target.toggleAttribute("data-fx-paused", !e.isIntersecting);
      },
      { rootMargin: "120px" },
    );
    els.forEach((el) => io.observe(el));
    return () => io.disconnect();
  }, [pathname]); // re-scan after client navigation
  return null;
}
```

Why start *running* and only pause on evidence of being offscreen: starting paused delays entrances until hydration.

---

## 1. Marquee (logo wall, testimonials)

- **Inspired by:** Magic UI `marquee` (https://magicui.design/docs/components/marquee), React Bits `LogoLoop` (https://reactbits.dev).
- **Upstream bugs fixed:** 4× duplicated children with no `aria-hidden`/`inert` (links read and tabbed 4×); never pauses offscreen; no reduced motion; hover-only pause.
- **Tier:** T1, 0 KB JS. Compositor `transform` only.
- **Reduced motion:** no animation, the duplicate is removed, and the row becomes horizontally scrollable.
- **A11y:** a labelled `<section>` wrapping a `<ul>`. The duplicate track is `aria-hidden` + `inert`. The animation pauses on hover (fine pointers) and on `:focus-within` (keyboard). `pausable` adds a native checkbox pause (zero JS, WCAG 2.2.2) and is **required for testimonials** or any content that moves for more than 5s. Logo text must pass contrast: use `text-fg-muted`, not `text-fg-subtle`. (Historical: axe flagged 3.84:1 in the light theme before `--fg-subtle` was raised to AA; muted is still the right tier for a row of logo wordmarks.)
- **SSR:** a Server Component. Both tracks are in the HTML.

```tsx
import type { CSSProperties, ReactNode } from "react";
import { cn } from "@/lib/cn";

type MarqueeProps = {
  /** <li> elements. Logos: next/image with width/height + alt, equal visual weight. */
  children: ReactNode;
  /** Accessible name for the region, e.g. "Companies using Acme". */
  label: string;
  /** Seconds per loop. ≈ 40s per 1600px of content; never faster than ~60px/s. */
  duration?: number;
  reverse?: boolean;
  /** Content that moves > 5s and carries information (testimonials) needs a pause control (WCAG 2.2.2). */
  pausable?: boolean;
  className?: string;
};

/** Server Component. Two tracks; the duplicate is aria-hidden + inert (no double reading, no double tab stops). */
export function Marquee({ children, label, duration = 40, reverse = false, pausable = false, className }: MarqueeProps) {
  return (
    <div className={cn("fx-marquee-wrap", className)}>
      <section
        aria-label={label}
        data-fx="marquee"
        data-reverse={reverse || undefined}
        className="fx-marquee"
        style={{ "--fx-marquee-duration": `${duration}s` } as CSSProperties}
      >
        <ul className="fx-marquee__track">{children}</ul>
        <ul className="fx-marquee__track" aria-hidden="true" inert>
          {children}
        </ul>
      </section>
      {pausable ? (
        <label className="mt-3 inline-flex cursor-pointer items-center gap-2 text-sm text-fg-muted">
          <input type="checkbox" className="fx-marquee__pause" /> Pause scrolling
        </label>
      ) : null}
    </div>
  );
}
```

```css
/* ── 1. Marquee (T1) ──────────────────────────────────────────────────────────────────── */
@layer components {
  .fx-marquee {
    --fx-gap: 3rem;
    display: flex;
    gap: var(--fx-gap);
    overflow: hidden;
    padding-block: 0.25rem; /* room for focus rings */
    mask-image: linear-gradient(to right, transparent, black 8%, black 92%, transparent);
  }
  .fx-marquee__track {
    display: flex;
    flex-shrink: 0;
    align-items: center;
    justify-content: space-around;
    gap: var(--fx-gap);
    min-width: 100%;
    margin: 0;
    padding: 0;
    list-style: none;
    animation: fx-marquee var(--fx-marquee-duration, 40s) linear infinite;
  }
  .fx-marquee[data-reverse] .fx-marquee__track { animation-direction: reverse; }
  .fx-marquee:focus-within .fx-marquee__track,
  .fx-marquee-wrap:has(.fx-marquee__pause:checked) .fx-marquee__track { animation-play-state: paused; }
  @media (hover: hover) and (pointer: fine) {
    .fx-marquee:hover .fx-marquee__track { animation-play-state: paused; }
  }
  @media (prefers-reduced-motion: reduce) {
    .fx-marquee { overflow-x: auto; mask-image: none; }
    .fx-marquee__track { animation: none; }
    .fx-marquee__track[aria-hidden] { display: none; }
    .fx-marquee-wrap label:has(.fx-marquee__pause) { display: none; }
  }
}
@keyframes fx-marquee { to { transform: translateX(calc(-100% - var(--fx-gap))); } }
```

```tsx
<Marquee label="Companies using Acme">
  {logos.map((l) => (
    <li key={l.name}>
      <Image src={l.src} alt={l.name} width={120} height={32} className="h-8 w-auto opacity-80" />
    </li>
  ))}
</Marquee>
```

Each track has `min-width: 100%`, so the loop is seamless with any item count. For fewer than about 6 logos, use a static row instead.

## 2. SpotlightCard (pointer glow on a feature grid)

- **Inspired by:** React Bits `SpotlightCard` (https://reactbits.dev), Magic UI `magic-card`.
- **Upstream bugs fixed:** `setState` on every mousemove, which re-renders children and repaints a full-card `radial-gradient`; a conditional `useMotionTemplate` hook (magic-card); hardcoded neutral palette.
- **Technique:** a pre-rasterised radial blob moved through the `translate` property. Pointer events are coalesced into one rAF, with one `getBoundingClientRect` per frame and zero React renders. A 120ms `translate` transition retargets every frame, which reads as a light spring without motion.
- **Tier:** T2, ~0.5 KB.
- **Reduced motion / touch / pen:** the effect never attaches, leaving a static `surface-card`.
- **A11y:** the glow is `aria-hidden`. Keyboard parity comes from `:focus-within`, which raises the border to `--brand-line`. Put a real link or button inside the card.
- **SSR:** a client component, but children render on the server (pass Server Components as `children`).

```tsx
"use client";
import { useEffect, useRef, type ComponentProps } from "react";
import { cn } from "@/lib/cn";

/** Pointer-following glow. Zero React renders per move: one rAF-coalesced `translate` write on a
 *  pre-rasterised blob. Touch, pen, keyboard and reduced motion get the static card (+ focus border). */
export function SpotlightCard({ className, children, ...rest }: ComponentProps<"div">) {
  const cardRef = useRef<HTMLDivElement>(null);
  const glowRef = useRef<HTMLSpanElement>(null);

  useEffect(() => {
    const card = cardRef.current;
    const glow = glowRef.current;
    if (!card || !glow) return;
    if (!matchMedia("(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)").matches) return;

    let raf = 0;
    let px = 0;
    let py = 0;
    const write = () => {
      raf = 0;
      const r = card.getBoundingClientRect(); // one read per frame, not per event
      glow.style.translate = `${px - r.left}px ${py - r.top}px`;
    };
    const onMove = (e: PointerEvent) => {
      if (e.pointerType !== "mouse") return;
      px = e.clientX;
      py = e.clientY;
      if (!raf) raf = requestAnimationFrame(write);
    };
    card.addEventListener("pointermove", onMove, { passive: true });
    return () => {
      card.removeEventListener("pointermove", onMove);
      if (raf) cancelAnimationFrame(raf);
    };
  }, []);

  return (
    <div ref={cardRef} className={cn("fx-spot surface-card p-6", className)} {...rest}>
      <span ref={glowRef} className="fx-spot__glow" aria-hidden="true" />
      {children}
    </div>
  );
}
```

```css
/* ── 2. Spotlight card (T2, ~0.5 KB) ──────────────────────────────────────────────────── */
@layer components {
  .fx-spot {
    position: relative;
    isolation: isolate;
    overflow: hidden;
  }
  .fx-spot__glow {
    position: absolute;
    top: 0;
    left: 0;
    z-index: -1;
    width: var(--fx-spot-size, 22rem);
    aspect-ratio: 1;
    border-radius: 50%;
    pointer-events: none;
    background: radial-gradient(closest-side, color-mix(in oklab, var(--brand) 22%, transparent), transparent);
    transform: translate(-50%, -50%); /* centres on the pointer; JS writes the `translate` property */
    opacity: 0;
    transition: opacity var(--duration-slow) ease, translate 120ms var(--ease-out);
  }
  @media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
    .fx-spot:hover .fx-spot__glow { opacity: 1; }
  }
  .fx-spot:focus-within { border-color: var(--brand-line); } /* keyboard parity */
}
```

Use one spotlight grid per page (it is the "secondary" effect). Each card owns its own listener, so there are no window listeners.

## 3. RevealText (per-word headline reveal)

- **Inspired by:** React Bits `SplitText` / `BlurText` (https://reactbits.dev), Aceternity `text-generate-effect`, Magic UI `text-animate`.
- **Upstream bugs fixed:** GSAP + SplitText (45–70 KB); SSR text at `opacity:0` (invisible without JS, LCP delayed to the end of the stagger); hydration flash; per-character nodes; duplicate `aria-label` + `sr-only` copies.
- **Tier:** T1, 0 KB JS.
- **`trigger="load"`** (hero h1): words rise 0.3em via `@starting-style` with a 40ms stagger capped at 400ms. **Opacity stays 1**, so the LCP is the first paint. This also works on client navigations, since new elements get a starting style too.
- **`trigger="scroll"`** (below the fold only): each word fades and rises, scrubbed by `animation-timeline: view()`. Browsers without scroll timelines show static text.
- **Reduced motion:** static text, and `--duration-slower` is 0 in base.css.
- **A11y:** one copy of the text with real spaces between inline-block words, so screen readers read it normally.
- **SSR:** full text in the server HTML (verified with JS disabled).

```tsx
import type { CSSProperties, ElementType } from "react";
import { cn } from "@/lib/cn";

type RevealTextProps = {
  text: string;
  as?: ElementType;
  /** "load": hero/LCP-safe rise on first paint (opacity never 0).
   *  "scroll": scrubbed by a CSS view() timeline where supported, static elsewhere. Below the fold only. */
  trigger?: "load" | "scroll";
  className?: string;
};

/** Server Component. Splits per word (never per character); real spaces keep one readable copy for AT. */
export function RevealText({ text, as: Tag = "h2", trigger = "scroll", className }: RevealTextProps) {
  const words = text.trim().split(/\s+/);
  return (
    <Tag className={cn("fx-reveal", className)} data-trigger={trigger}>
      {words.map((word, i) => (
        <span key={i}>
          <span className="fx-reveal__word" style={{ "--i": i } as CSSProperties}>
            {word}
          </span>
          {i < words.length - 1 ? " " : null}
        </span>
      ))}
    </Tag>
  );
}
```

```css
/* ── 3. Text reveal (T1) ──────────────────────────────────────────────────────────────── */
@layer components {
  .fx-reveal { --fx-stagger: 40ms; }
  .fx-reveal__word { display: inline-block; }
  @media (prefers-reduced-motion: no-preference) {
    /* load: movement only, opacity stays 1 → LCP is the first paint */
    .fx-reveal[data-trigger="load"] .fx-reveal__word {
      transition: translate var(--duration-slower) var(--ease-out-expo);
      transition-delay: min(calc(var(--i) * var(--fx-stagger)), 400ms);
      @starting-style { translate: 0 0.3em; }
    }
    /* scroll: scrubbed by the view timeline; unsupported browsers show static text */
    @supports (animation-timeline: view()) {
      .fx-reveal[data-trigger="scroll"] .fx-reveal__word {
        animation: fx-rise linear both;
        animation-timeline: view();
        animation-range: entry calc(5% + var(--i) * 1.5%) cover calc(25% + var(--i) * 1.5%);
      }
    }
  }
}
@keyframes fx-rise { from { opacity: 0; translate: 0 0.5em; } to { opacity: 1; translate: 0 0; } }
```

```tsx
<RevealText as="h1" trigger="load" text="Ship interfaces that feel inevitable" className="text-display-lg" />
```

Keep it to 1–2 headings per page. For block-level section reveals use base.css `reveal`; for masked line rises use `motion` → `references/recipes.md` §10. Don't add `clip-path` or `opacity:0` to the load variant: whether a fully clipped first frame counts as painted for LCP is unverified.

## 4. BeamBorder (a light travelling around one card)

- **Inspired by:** Magic UI `border-beam` (https://magicui.design/docs/components/border-beam), React Bits `StarBorder`, motion-primitives `border-trail`.
- **Upstream bugs fixed:** motion animating `offset-distance` on the JS main thread forever (border-beam); keyframes that exist only in a commented `tailwind.config.js`, so the effect is dead on v4 (StarBorder); `<button>` without `type`; hardcoded `#ffaa40 → #9c40ff`.
- **Technique:** an oversized conic-gradient layer rotates (compositor `transform`) behind a 1px padding gap. The inner surface must be opaque.
- **Tier:** T1, 0 KB.
- **Reduced motion:** a static `--brand-line` border.
- **A11y:** purely decorative, with no extra DOM.
- **SSR:** a Server Component.

```tsx
import type { ComponentProps } from "react";
import { cn } from "@/lib/cn";

type Props = ComponentProps<"div"> & {
  /** Set when height > ~1.7 × width so the rotating layer still covers the corners. */
  tall?: boolean;
  innerClassName?: string;
};

/** Server Component. A 1px border whose brand highlight travels around once per 6s.
 *  Compositor-only (rotates a conic layer); paused offscreen via data-fx; static brand border under reduced motion. */
export function BeamBorder({ tall, className, innerClassName, children, ...rest }: Props) {
  return (
    <div data-fx="beam" data-tall={tall || undefined} className={cn("fx-beam", className)} {...rest}>
      <div className={cn("fx-beam__inner p-6", innerClassName)}>{children}</div>
    </div>
  );
}
```

```css
/* ── 4. Border beam (T1) ──────────────────────────────────────────────────────────────── */
@layer components {
  .fx-beam {
    position: relative;
    isolation: isolate;
    overflow: hidden;
    padding: 1px; /* border width */
    border-radius: var(--radius-xl);
    background: var(--line);
  }
  .fx-beam::before {
    content: "";
    position: absolute;
    z-index: -1;
    top: 50%;
    left: 50%;
    width: 200%; /* square ≥ diagonal while height ≤ 1.7 × width; use data-tall otherwise */
    aspect-ratio: 1;
    background: conic-gradient(from 0turn, transparent 0 75%, color-mix(in oklab, var(--brand) 90%, transparent) 90%, transparent 100%);
    transform: translate(-50%, -50%) rotate(0turn);
    animation: fx-spin var(--fx-beam-duration, 6s) linear infinite;
  }
  .fx-beam[data-tall]::before { width: auto; height: 200%; }
  .fx-beam__inner {
    height: 100%;
    border-radius: calc(var(--radius-xl) - 1px);
    background: var(--surface); /* must be opaque */
  }
  @media (prefers-reduced-motion: reduce) {
    .fx-beam { background: var(--brand-line); }
    .fx-beam::before { animation: none; display: none; }
  }
}
@keyframes fx-spin { to { transform: translate(-50%, -50%) rotate(1turn); } }
```

Use it on the **one** card that represents a state ("Recommended", "Live", "Provisioning"): ≤1 in view, ≤3 per page. Pass `tall` when height is more than 1.7× the width. For a transparent interior, animate an `@property --angle` conic `border-image` instead; that repaints every frame, so use one per viewport at most.

## 5. PatternBackdrop (grid, dots, glow) and grain

- **Inspired by:** Magic UI `grid-pattern` / `dot-pattern` (https://magicui.design), Aceternity `background-beams` / `dotted-glow-background`, pattern-craft.
- **Upstream bugs fixed:** `dot-pattern` with glow renders ~5,000 `<circle>`s, each with an infinite spring; `background-beams` animates 50 SVG gradients and calls `Math.random()` in render (hydration mismatch); `dotted-glow-background` keeps its rAF running offscreen and calls `getBoundingClientRect` inside the loop.
- **Tier:** T0, 0 KB, no animation.
- **Rules:** one glow per page, and patterns are always masked (an unmasked full-bleed grid is a template tell). For grain, add base.css `grain` to the section. It reads the direction's `--grain-opacity` (0 in light, ≤0.035 in dark), so never hardcode an opacity.
- **A11y / SSR:** `aria-hidden`, Server Component.

```tsx
import { cn } from "@/lib/cn";

type Props = { pattern?: "grid" | "dots" | "none"; glow?: boolean; className?: string };

/** Server Component, zero JS, no animation. One masked pattern + one brand glow.
 *  Parent needs `relative isolate`. Grain: add the base.css `grain` utility to the section instead. */
export function PatternBackdrop({ pattern = "grid", glow = true, className }: Props) {
  return (
    <div aria-hidden="true" className={cn("pointer-events-none absolute inset-0 -z-10", glow && "bg-fx-glow", className)}>
      {pattern !== "none" ? <div className={cn("absolute inset-0", pattern === "grid" ? "bg-fx-grid" : "bg-fx-dots")} /> : null}
    </div>
  );
}
```

```css
/* ── 5. Backgrounds (T0, zero JS). Grain: use base.css `grain` (per-direction --grain-opacity). ── */
@utility bg-fx-grid {
  --fx-cell: 32px;
  --fx-line: color-mix(in oklab, var(--fg) 7%, transparent);
  background-image:
    linear-gradient(to right, var(--fx-line) 1px, transparent 1px),
    linear-gradient(to bottom, var(--fx-line) 1px, transparent 1px);
  background-size: var(--fx-cell) var(--fx-cell);
  background-position: center top;
  mask-image: radial-gradient(ellipse 70% 60% at 50% 0%, black 30%, transparent 75%);
}
@utility bg-fx-dots {
  --fx-cell: 20px;
  --fx-dot: color-mix(in oklab, var(--fg) 14%, transparent);
  background-image: radial-gradient(circle at center, var(--fx-dot) 1px, transparent 1.5px);
  background-size: var(--fx-cell) var(--fx-cell);
  mask-image: radial-gradient(ellipse 60% 50% at 50% 40%, black 20%, transparent 70%);
}
@utility bg-fx-glow {
  background-image: radial-gradient(60% 50% at 50% -10%, color-mix(in oklab, var(--brand) 14%, transparent), transparent 70%);
}
```

```tsx
<section className="relative isolate grain">
  <PatternBackdrop pattern="grid" />
  …hero content…
</section>
```

Tune with arbitrary properties rather than new CSS: `[--fx-cell:24px]`, and for density `[--fx-line:color-mix(in_oklab,var(--fg)_10%,transparent)]`.

## 6. NumberTicker (stats that count up)

- **Inspired by:** Magic UI `number-ticker` (https://magicui.design/docs/components/number-ticker), React Bits `CountUp`.
- **Upstream bugs fixed:** server HTML contains `0` (Magic UI) or an empty string (React Bits), so crawlers, link previews and no-JS users see no number; a `new Intl.NumberFormat` every frame; hardcoded `en-US`; spring damping 60 (slow settle); CLS as the width grows; replays on every scroll-by.
- **Prefer `@number-flow/react`** when the dependency is acceptable (it handles locale, digit width and reduced motion). Use this component when it isn't.
- **Behaviour:** SSR renders the final formatted value. If the number is already visible on arrival, it never animates. If it starts offscreen, it is reset to 0 while invisible, reserves its final pixel width, and counts up once (900ms, ease-out quart) when 60% visible.
- **Tier:** T2, ~0.6 KB.
- **Reduced motion:** the final value, no animation.
- **A11y:** the animated copy is `aria-hidden`; screen readers get the stable `sr-only` copy. `data-numeric` gives tabular figures.
- **SSR:** `12,400` in the HTML (verified with JS disabled).

```tsx
"use client";
import { useEffect, useMemo, useRef } from "react";

const formatters = new Map<string, Intl.NumberFormat>();
function getFormatter(locale: string, optionsKey: string) {
  const key = `${locale}|${optionsKey}`;
  let f = formatters.get(key);
  if (!f) formatters.set(key, (f = new Intl.NumberFormat(locale, JSON.parse(optionsKey))));
  return f;
}

type Props = {
  value: number;
  locale?: string;
  options?: Intl.NumberFormatOptions;
  durationMs?: number;
  className?: string;
};

/** Server HTML contains the final formatted value. Counts up once, only if it starts offscreen.
 *  Screen readers get the stable copy; the animated copy is aria-hidden with reserved width (no CLS). */
export function NumberTicker({ value, locale = "en-US", options, durationMs = 900, className }: Props) {
  const ref = useRef<HTMLSpanElement>(null);
  const optionsKey = JSON.stringify(options ?? {});
  const fmt = useMemo(() => getFormatter(locale, optionsKey), [locale, optionsKey]);
  const final = fmt.format(value);

  useEffect(() => {
    const el = ref.current;
    if (!el || matchMedia("(prefers-reduced-motion: reduce)").matches) return;

    let raf = 0;
    let first = true;
    const io = new IntersectionObserver(
      ([entry]) => {
        if (first) {
          first = false;
          if (entry.isIntersecting) return io.disconnect(); // visible on arrival: never yank to 0
          el.style.minWidth = `${el.offsetWidth}px`; // reserve the final width: no shift while counting
          el.textContent = fmt.format(0); // offscreen, invisible reset
          return;
        }
        if (!entry.isIntersecting) return;
        io.disconnect(); // once
        let start = 0;
        const tick = (t: number) => {
          if (!start) start = t;
          const p = Math.min((t - start) / durationMs, 1);
          el.textContent = fmt.format(value * (1 - (1 - p) ** 4)); // ease-out quart
          if (p < 1) raf = requestAnimationFrame(tick);
        };
        raf = requestAnimationFrame(tick);
      },
      { threshold: 0.6 },
    );
    io.observe(el);
    return () => {
      io.disconnect();
      if (raf) cancelAnimationFrame(raf);
      el.textContent = final;
      el.style.minWidth = "";
    };
  }, [value, durationMs, fmt, final]);

  return (
    <span className={className} data-numeric>
      <span ref={ref} aria-hidden="true" className="inline-block tabular-nums">
        {final}
      </span>
      <span className="sr-only">{final}</span>
    </span>
  );
}
```

```tsx
<p className="font-display text-display-sm"><NumberTicker value={12400} /></p>
<NumberTicker value={98.6} options={{ maximumFractionDigits: 1 }} />%
```

## 7. Magnetic (a CTA that leans toward the cursor)

- **Inspired by:** React Bits `Magnet` (https://reactbits.dev), motion-primitives `magnetic`.
- **Upstream bugs fixed:** one `window` mousemove listener per instance; `getBoundingClientRect` + `setState` on every move; no touch or reduced-motion gate; `ease-in-out` release.
- **Technique:** element-scoped pointer events. It measures once on enter and writes `translate` once per frame. A CSS transition does the spring-back (450ms `--ease-out`) and smooth tracking (120ms). Strength is capped at 0.3, so the element stays under the cursor and `pointerleave` stays reliable. A `::before` 16px halo widens the capture zone without layout.
- **Tier:** T2, ~0.5 KB.
- **Reduced motion / touch:** never attaches.
- **A11y:** wraps a real `<a>`/`<button>`. Press feedback stays on the child (template `Button` has `press`). Never use it on toolbars, nav or anything clicked daily.
- **SSR:** a client wrapper; children render on the server.

```tsx
"use client";
import { useEffect, useRef, type ReactNode } from "react";

/** Pulls its child toward the pointer. Element-scoped listeners (no window listener), measure once on
 *  enter, one `translate` write per frame, CSS transition springs it home. Mouse + motion-OK only. */
export function Magnetic({ children, strength = 0.25 }: { children: ReactNode; strength?: number }) {
  const ref = useRef<HTMLSpanElement>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (!matchMedia("(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)").matches) return;
    const k = Math.min(strength, 0.3); // stay under the cursor so pointerleave stays reliable

    let raf = 0;
    let rect: DOMRect | null = null;
    let x = 0;
    let y = 0;
    const onEnter = () => {
      el.style.translate = "";
      rect = el.getBoundingClientRect();
      el.dataset.magnet = "on";
    };
    const onMove = (e: PointerEvent) => {
      if (!rect || e.pointerType !== "mouse") return;
      x = (e.clientX - (rect.left + rect.width / 2)) * k;
      y = (e.clientY - (rect.top + rect.height / 2)) * k;
      if (!raf)
        raf = requestAnimationFrame(() => {
          raf = 0;
          el.style.translate = `${x}px ${y}px`;
        });
    };
    const onLeave = () => {
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
      rect = null;
      delete el.dataset.magnet;
      el.style.translate = "";
    };
    el.addEventListener("pointerenter", onEnter);
    el.addEventListener("pointermove", onMove, { passive: true });
    el.addEventListener("pointerleave", onLeave);
    return () => {
      el.removeEventListener("pointerenter", onEnter);
      el.removeEventListener("pointermove", onMove);
      el.removeEventListener("pointerleave", onLeave);
      if (raf) cancelAnimationFrame(raf);
    };
  }, [strength]);

  return (
    <span ref={ref} className="fx-magnet">
      {children}
    </span>
  );
}
```

```css
/* ── 7. Magnetic (T2, ~0.5 KB) ────────────────────────────────────────────────────────── */
@layer components {
  .fx-magnet {
    position: relative;
    display: inline-block;
    transition: translate 450ms var(--ease-out); /* release: settles softly */
  }
  .fx-magnet[data-magnet] { transition-duration: 120ms; } /* tracking: retargets each frame */
  .fx-magnet::before { content: ""; position: absolute; inset: -16px; } /* capture zone, no layout */
}
```

```tsx
<Magnetic><Button size="lg" href="/start">Get started</Button></Magnetic>
```

## 8. ShinyText (a sweep of light across a label)

- **Inspired by:** React Bits `ShinyText` (https://reactbits.dev), Magic UI `animated-shiny-text`, motion-primitives `text-shimmer`.
- **Upstream bugs fixed:** a perpetual `useAnimationFrame` JS loop per instance writing `background-position` (it keeps ticking even when `disabled`); no offscreen or reduced-motion handling.
- **Tier:** T1-paint, 0 KB. `background-clip: text` can't be moved by transform, so this is a **paint** animation. It gets its own layer (`will-change: transform`) so each frame repaints only the label.
- **Measured:** without its own layer, a shiny eyebrow on top of `AuroraBackdrop` dropped 14–46% of frames while scrolling (headless, software raster); without the shine, 0–4%. **Don't put ShinyText over an animated backdrop** even with the fix. They count as two effects in one viewport anyway.
- **Reduced motion:** plain `--fg-muted` text.
- **A11y:** real text, and contrast stays within the fg scale. **Never on h1/h2** (that is `design/gradient-text`), never on body copy, never in brand colour.
- **SSR:** a Server Component.

```tsx
import type { ComponentProps } from "react";
import { cn } from "@/lib/cn";

/** Server Component. A light sweep across one short muted string (eyebrow, badge). Paint animation:
 *  one per viewport, never on h1/body copy, never on top of another animated layer. */
export function ShinyText({ className, ...rest }: ComponentProps<"span">) {
  return <span data-fx="shine" className={cn("fx-shine", className)} {...rest} />;
}
```

```css
/* ── 8. Shiny text (T1-paint: one short string per viewport) ─────────────────────────── */
@layer components {
  .fx-shine {
    color: var(--fg-muted); /* fallback */
    background-image: linear-gradient(100deg, var(--fg-muted) 40%, var(--fg) 50%, var(--fg-muted) 60%);
    background-size: 250% 100%;
    background-position: 100% 0;
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
    animation: fx-shine 3.2s var(--ease-in-out) infinite;
    /* own layer: otherwise each frame's repaint invalidates whatever big layer it shares (e.g. text over
       an animated backdrop repaints the whole hero: measured 15% → 1.4% dropped frames) */
    will-change: transform;
  }
  @media (prefers-reduced-motion: reduce) {
    .fx-shine { animation: none; background: none; -webkit-text-fill-color: currentColor; will-change: auto; }
  }
}
@keyframes fx-shine { 0% { background-position: 100% 0; } 55%, 100% { background-position: 0% 0; } }
```

```tsx
<p className="label-mono"><ShinyText>Now with agent skills</ShinyText></p>
```

## 9. AuroraBackdrop (WebGL aurora → CSS)

- **Inspired by:** React Bits `Aurora` / `Silk` / `SoftAurora` (https://reactbits.dev), Aceternity `aurora-background`.
- **Upstream bugs fixed:** three + R3F (~150 KB) or ogl for one full-screen quad; `new Color()` ×3 per frame; effect deps that rebuild the GL context; no DPR cap, offscreen pause or reduced motion.
- **Technique:** three large radial gradients with the softness baked in (**no `filter: blur`**), each drifting 27–41s via `transform`. That is 3 composited layers and zero repaints while idle. It gives about 80% of the shader mood at 0 KB. Single hue by construction (`--brand`): multi-hue meshes are a banned trope (`design/purple-gradient`).
- **Measured fix:** fading toward the fold with `mask-image` on the container forced a re-rendered surface every frame (10% dropped frames). A static gradient overlay (`::after` to `--bg`) measured 0%. If the section background isn't `--bg`, set `--fx-aurora-fade`.
- **Tier:** T1, 0 KB. This is the page's **signature**, so nothing else animates in the hero.
- **Reduced motion:** the blobs hold still (static glow).
- **A11y / SSR:** `aria-hidden`, Server Component.

```tsx
import type { CSSProperties } from "react";

/** Server Component. Three pre-softened brand blobs drifting on the compositor (no filter: blur, no WebGL).
 *  Parent needs `relative isolate`. Masked toward the fold so it never sits behind body copy. */
export function AuroraBackdrop({ intensity = 0.2 }: { intensity?: number }) {
  return (
    <div data-fx="aurora" aria-hidden="true" className="fx-aurora" style={{ "--fx-aurora-opacity": intensity } as CSSProperties}>
      <span />
      <span />
      <span />
    </div>
  );
}
```

```css
/* ── 9. CSS aurora (T1, replaces WebGL Aurora/Silk) ──────────────────────────────────── */
@layer components {
  .fx-aurora {
    position: absolute;
    inset: 0;
    z-index: -1;
    overflow: hidden;
    pointer-events: none;
    contain: strict;
  }
  /* fade toward the fold so it never sits behind body copy. A static overlay, not mask-image:
     a mask over animated children forces a re-rendered surface every frame (measured 10% dropped frames). */
  .fx-aurora::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(to bottom, transparent 35%, var(--fx-aurora-fade, var(--bg)) 92%);
  }
  .fx-aurora > span {
    position: absolute;
    width: 55vmax;
    aspect-ratio: 1;
    border-radius: 50%;
    opacity: var(--fx-aurora-opacity, 0.2);
    animation: fx-drift 32s var(--ease-in-out) infinite alternate;
  }
  .fx-aurora > span:nth-child(1) {
    top: -30vmax;
    left: -12vmax;
    background: radial-gradient(closest-side, var(--brand), color-mix(in oklab, var(--brand) 35%, transparent) 45%, transparent);
  }
  .fx-aurora > span:nth-child(2) {
    top: -26vmax;
    right: -20vmax;
    background: radial-gradient(closest-side, color-mix(in oklab, var(--brand) 55%, var(--fg)), transparent);
    animation-duration: 41s;
    animation-direction: alternate-reverse;
  }
  .fx-aurora > span:nth-child(3) {
    top: -10vmax;
    left: 30%;
    width: 40vmax;
    background: radial-gradient(closest-side, color-mix(in oklab, var(--brand) 45%, transparent), transparent);
    animation-duration: 27s;
    opacity: calc(var(--fx-aurora-opacity, 0.2) * 0.65);
  }
  @media (prefers-reduced-motion: reduce) {
    .fx-aurora > span { animation: none; }
  }
}
@keyframes fx-drift {
  from { transform: translate3d(0, 0, 0) scale(1); }
  to { transform: translate3d(8vmax, 5vmax, 0) scale(1.12); }
}
```

```tsx
<section className="relative isolate overflow-hidden">
  <AuroraBackdrop intensity={0.2} />
  …hero…
</section>
```

Keep `intensity` ≤ 0.25 on dark directions, and check gutters stay near the canvas colour. If the brief truly needs a shader, follow `speed` → `references/rendering-smoothness.md` §8 (WebGL2 hook) and `motion` → `references/webgl-canvas.md`, with this component as the CSS fallback underneath.

## 10. HeroSpotlight (angled light behind the hero)

- **Inspired by:** Aceternity `spotlight` (https://ui.aceternity.com/components/spotlight).
- **Upstream bugs fixed:** `animate-spotlight` ships with no CSS in the registry item, so the SVG stays at `opacity-0` forever on Tailwind v4 (silently dead); hardcoded `id="filter"` collides across instances; a 151px `feGaussianBlur`; `z-[1]` sits over content. `spotlight-new` loops forever.
- **Technique:** one rotated elliptical radial gradient that fades in once (1.4s `--ease-out-expo`, 0.2s delay). `light-dark()` tints it with `--brand` in light schemes and `--fg` in dark ones.
- **Tier:** T1 one-shot, 0 KB. Starting at opacity 0 is safe because gradients are never LCP candidates.
- **Reduced motion:** shown immediately.
- **A11y / SSR:** `aria-hidden`, Server Component.

```tsx
import { cn } from "@/lib/cn";

/** Server Component, zero JS. A soft angled light (brand-tinted in light mode, fg-tinted in dark) that
 *  fades in once on load. No SVG filter, no ids, no infinite loop. Parent needs `relative isolate`. */
export function HeroSpotlight({ className }: { className?: string }) {
  return (
    <div aria-hidden="true" className={cn("pointer-events-none absolute inset-0 -z-10 overflow-hidden", className)}>
      <div className="fx-spotlight" />
    </div>
  );
}
```

```css
/* ── 10. Hero spotlight (T1 one-shot) ─────────────────────────────────────────────────── */
@layer components {
  .fx-spotlight {
    position: absolute;
    top: -45%;
    left: -25%;
    width: 110%;
    height: 130%;
    background: radial-gradient(
      ellipse 38% 14% at 50% 50%,
      light-dark(color-mix(in oklab, var(--brand) 16%, transparent), color-mix(in oklab, var(--fg) 16%, transparent)),
      transparent 70%
    );
    transform: rotate(32deg);
    animation: fx-spotlight-in 1.4s var(--ease-out-expo) 0.2s both; /* decorative, never LCP */
  }
  @media (prefers-reduced-motion: reduce) {
    .fx-spotlight { animation: none; }
  }
}
@keyframes fx-spotlight-in {
  from { opacity: 0; transform: translate(-6%, -5%) rotate(32deg) scale(0.96); }
  to { opacity: 1; transform: translate(0, 0) rotate(32deg) scale(1); }
}
```

---

## Verification log (2026-09-16)

- `next build` passed (TypeScript strict); every demo route is static (`○`). `void lint` reported 0 errors, and no findings in `src/components/fx` or `fx.css`.
- Server HTML with JavaScript disabled: the h1 text is complete, the ticker shows `12,400`, the marquee duplicate has `aria-hidden="true" inert`, and no inline `opacity:0`.
- Playwright (Chromium 149): SpotlightCard glow `translate: 120px 60px`, opacity 1 on hover; Magnetic `translate: 14.8px 5.5px` while tracking; the below-fold ticker counts 0 → 65.8 → 98.6 once, with the `sr-only` copy stable at 98.6; FxGate set `data-fx-paused` on the offscreen shine only; no console errors from fx code.
- Screenshots at 1440px (dark, light, `--force-prefers-reduced-motion`) and 390px: all effects visible and on-token. Reduced motion showed the static marquee (no mask), a static brand beam border, and still blobs.
- `void smooth` (desktop, scroll, headless software raster): each effect alone dropped 0–5% of frames. The full ten-effect page dropped 22–39% before the two fixes above (the shine's own layer and the aurora overlay instead of a mask) and 0% in two runs after them. The machine load average was 10–22, so repeat measurements varied by ±15 points; re-measure on your own route.
- `void a11y`: a marquee logo contrast failure with `text-fg-subtle` in the light theme, measured before `--fg-subtle` was raised to AA (fixed by using `text-fg-muted`, noted in §1).


---

<!-- https://void-design.vercel.app/docs/components/upstream-bugs · updated 2026-09-16 -->

# Upstream bugs in component libraries, grouped by pattern

These 48 bugs were found by reading registry source (fetched 2026-09-16) and in shipped apps. They are grouped by **pattern**: when you audit any registry component, scan for each pattern's signature, not just for the named components. Lint ids are `void lint` rules that catch the pattern statically.

Format: **Library / component**: the bug → the impact. The fix pattern closes each group.

## 1. Dead on Tailwind v4 (silent: no error, the effect never shows)

Signature: `animate-<custom>` classes with no `css`/`cssVars` in the registry item; keyframes in `tailwind.config.*` or in comments; v3 utility names; undefined theme classes.

- **Aceternity `spotlight`**: `animate-spotlight` ships with no CSS, and the base class is `opacity-0` → invisible forever.
- **Aceternity `meteors`, `moving-border`** (and other custom `animate-*` items): no CSS in the registry item → no animation.
- **React Bits `StarBorder`**: keyframes exist only as a commented `tailwind.config.js` block → static.
- **Registries generally**: `bg-gradient-to-*`, `flex-shrink-0`, bare `shadow`/`rounded`, `outline-none`, config keyframes → gradients missing, sizes one step off.
- **Cult `direction-aware-tabs`**: undefined `shadow-inner-shadow` → silent style loss.
- **kibo `gantt`**: undefined `--color-backdrop` → silent style loss.
- **ReUI `frame`**: served JSON lost variant prefixes → dead variants.

**Fix pattern:** put `@keyframes` + a class in `@layer components`, or `--animate-x` + `@keyframes` in `@theme`. Rename v3 utilities (`bg-linear-to-*`, `shrink-0`, `shadow-xs`, `outline-hidden`). Run `next build` and confirm the class exists in the compiled CSS. Lint: `lint/tw-unknown-class`, `lint/tw-js-config-ignored`, `lint/tw-v3-renamed`, `lint/tw-v3-arbitrary-var`.

## 2. Hidden or placeholder server HTML (SEO, no-JS, LCP)

Signature: `opacity-0`, `initial={{ opacity: 0 }}` or `initial="hidden"` on text; a number component rendering `0`/`""`/`startValue`; GSAP `set` after hydration.

- **Magic UI `number-ticker`**: SSR renders `startValue` (`0`), and a width change from `0` to `12,400` causes CLS → crawlers and link previews see 0.
- **React Bits `CountUp`**: SSR renders an empty `<span />` → no number without JS.
- **Aceternity `text-generate-effect`**: every word SSRs at `opacity-0` + `blur(10px)` → invisible without JS; LCP delayed to the end of a 0.2s × N stagger.
- **Magic UI `text-animate`**: `initial="hidden"` → text at opacity 0 before hydration.
- **React Bits `SplitText`**: full text on the server, then GSAP sets `opacity:0; y:40` after `fonts.ready` → flash, hide, reveal; LCP moves.
- **React Bits `SplitText`**: `willChange: "transform, opacity"` left on the parent after completion; pulls gsap + ScrollTrigger + SplitText (~45–70 KB) for one headline → a permanent layer; weight. Lint: `lint/will-change-static`.

**Fix pattern:** SSR the final value and visible text. Animate position, not visibility, on LCP text (`RevealText`). Numbers animate only if they start offscreen (`NumberTicker`, or `@number-flow/react`).

## 3. SSR crashes and hydration mismatches

Signature: `window`/`document`/`navigator`/`matchMedia`/`localStorage` at module scope or in render; `Math.random()`/`Date.now()` in render; `createPortal(document.body)` in render.

- **Skiper106**: `navigator.userAgent` at module scope → SSR crash.
- **Aceternity `background-beams`**: `Math.random()` in render for duration, delay and `y2` → hydration mismatch, and values re-randomise each render.
- **Aceternity (26 of 118 free items)**: `Math.random()` in render → hydration risk.
- **Magic UI `globe`**: `setTimeout(() => style.opacity = "1", 0)` → a hydration-timing hack.
- **React Bits (all 171 files)**: no `'use client'` → RSC import errors.

**Fix pattern:** browser globals only inside effects or `useSyncExternalStore` (server snapshot). Seeded or precomputed values instead of `Math.random()` in render. `'use client'` only on the interactive leaf. Lint: `lint/browser-global-in-render`.

## 4. Leaks: subscriptions and loops without cleanup

Signature: `.on('change'` or `addEventListener` outside `useEffect`; `requestAnimationFrame` without `cancelAnimationFrame`; `new Lenis` without `destroy`; `api.on` without `off`; `setInterval` per instance.

- **Skiper34, Skiper37**: `scrollY.on('change')` in the render body → a new listener every render.
- **Skiper30** (+16/17/28/31/34): the Lenis rAF loop is never cancelled and `lenis.destroy()` is missing → a permanent rAF after unmount.
- **Skiper54**: `api.on('select')` is never removed → listener leak.
- **kibo `relative-time`**: a `setInterval` per instance, never paused → N timers.

**Fix pattern:** `useMotionValueEvent` or an effect with an unsubscribe. Match every add, observe, rAF, interval or create with its remove, disconnect, cancel, clear or destroy. One shared ticker for lists. No Lenis. Lint: `lint/motionvalue-subscribe-in-render`, `lint/raf-without-cancel`, `lint/listener-without-cleanup`, `lint/timer-in-render`.

## 5. Hooks misuse

Signature: `use[A-Z]…(` inside `&&`, ternaries, JSX or `style={{}}`; `m.create()` or component definitions in render; `ref.current` in deps; props already read from refs listed in effect deps.

- **Magic UI `magic-card`**: `useMotionTemplate` inside `{mode === "gradient" && …}` → React throws when `mode` changes.
- **Skiper19**: `useTransform` inside a `style={{}}` literal → rules-of-hooks violation.
- **tailark `animated-group`**: `m.create()` in render → remount and replay every render.
- **Aceternity `text-generate-effect`**: `[scope.current]` as an effect dep → a stale or ineffective effect.
- **React Bits `Aurora`**: deps `[amplitude]` although the value is read from a ref → destroys and rebuilds the WebGL context on prop change.
- **Magic UI `globe`**: `config` object in effect deps → inline `config={{…}}` recreates the globe every render.
- **Cult `direction-aware-tabs`**: hardcoded `layoutId="bubble"` → two instances fight.

**Fix pattern:** hooks at the top level; `useMemo` for created components; `useId()`-scoped `layoutId`; stable primitives in deps; update uniforms instead of rebuilding.

## 6. Per-frame React work and allocation

Signature: `setState` inside `mousemove`/`pointermove`/`scroll`/rAF/`useAnimationFrame`; `new Intl.NumberFormat`, `new Color` or `getBoundingClientRect` inside loops; `window` listeners per instance.

- **React Bits `Magnet`**: a window `mousemove` per instance + `getBoundingClientRect` + `setState` on every move → N listeners, layout reads and re-renders per event.
- **React Bits `SpotlightCard`**: `setState` per move and a repainted full-card `radial-gradient` → re-renders children every frame.
- **Magic UI `number-ticker`, React Bits `CountUp`**: a new `Intl.NumberFormat` per frame, locale hardcoded to `en-US` → GC churn; wrong locale.
- **React Bits `Aurora`**: `new Color()` ×3 per frame → ~420 allocations/s.
- **Aceternity `dotted-glow-background`**: `getBoundingClientRect` inside draw + `shadowBlur` per dot → forced layout at 60fps.
- **ReUI `scrollspy`**: capture-phase scroll + a forced layout loop; its `throttleTime` prop is unused → scroll jank.
- **Magic UI `number-ticker`**: `useSpring` with damping 60 → slow settle (the top idle cost in a shipped app).

**Fix pattern:** refs + one rAF writing `el.style.translate` (`SpotlightCard`, `Magnetic`); measure once on enter or in a ResizeObserver; hoist formatters; IntersectionObserver instead of scroll listeners; near-critical springs.

## 7. Never stops: offscreen spin and no visibility pause

Signature: `repeat: Infinity`, `infinite`, `useAnimationFrame` or a rAF loop with no IntersectionObserver, `useInView` or `data-fx`; WebGL libraries' internal loops.

- **Magic UI `border-beam`**: motion animates `offset-distance` on the main thread forever → idle CPU.
- **motion-primitives `border-trail`, tailark `infinite-slider`**: `repeat: Infinity`, never paused → idle main thread.
- **React Bits `ShinyText`**: a perpetual `useAnimationFrame` writing `background-position` (still ticking when `disabled`) → a paint every frame per instance.
- **Magic UI `globe`**: cobe's rAF runs until unmount; no IntersectionObserver or visibility pause → GPU burn.
- **Magic UI `animated-beam`**: `repeat = Infinity` JS gradient animation → a repaint every frame.
- **Magic UI `animated-beam`**: its ResizeObserver watches only the container; endpoints that move (font swap, image load, accordion) leave the beam pointing at stale coordinates.
- **React Bits backgrounds**: 35/56 have no offscreen pause, 53/56 no reduced motion, 23/56 no DPR cap → GPU burn; WCAG 2.3.3.
- **Aceternity `spotlight-new`**: infinite motion + `z-40` → covers content and never rests.

**Fix pattern:** CSS animation + `data-fx` + `FxGate` (recipes §0). JS loops cancel (not skip) on IntersectionObserver and `visibilitychange`. Lint: `smooth/raf-loop-idle`.

## 8. Paint-bound or layout-bound animation

Signature: animating `background`, `background-position`, `filter`, `box-shadow`, SVG gradient attributes, `width`/`height`/`top`/`left`; many stacked `backdrop-filter`s; `mask-image` over animated children.

- **RareUI `HookSidebar`**: springs `top`/`height` → layout every frame.
- **Magic UI `animated-beam`**: animates `x1/x2/y1/y2` of a `linearGradient` → path repaint per frame.
- **Aceternity `background-beams`**: 50 SVG paths × animated gradients, no IntersectionObserver or RM → CPU raster.
- **Magic UI `magic-card`** (orb mode): `filter: blur(60px)` on a 420px element moving with the pointer → a huge blurred layer per frame.
- **Aceternity `text-generate-effect`**: `filter: blur` animated per word → rasterisation per word per frame.
- **tailark `progressive-blur`**: 8 stacked `backdrop-filter` layers → 8–15ms per frame.
- **kibo `gantt`**: 5× `backdrop-blur` on sticky elements → scroll jank.
- **void measurement** (recipes §8–9): a `mask-image` fade over animated blobs → 10% dropped frames; a paint animation over an animated backdrop without its own layer → 14–46%.

**Fix pattern:** move pre-rasterised layers with `translate`/`rotate` (`BeamBorder`, `AuroraBackdrop`, `SpotlightCard`); one blur + one mask; a static overlay instead of a mask over animation; `will-change: transform` on a single small paint-animated element. Lint: `lint/animate-layout-prop`, `lint/transition-all`, `smooth/animate-layout-property`.

## 9. DOM and node explosions

Signature: `Array(n).fill().map` of animated nodes; per-character motion spans; SVG `<circle>` grids.

- **Magic UI `dot-pattern`** (glow): ~5,130 `<circle>`s, each with an infinite spring → DOM and animation explosion.
- **Magic UI `animated-grid-pattern`**: the same node explosion.
- **motion-primitives `text-effect`**: per-character motion nodes by default → ~60 nodes per headline.

**Fix pattern:** CSS gradients (`PatternBackdrop`); `per="word"` or CSS per-word (`RevealText`).

## 10. WebGL, canvas and expensive dependencies

Signature: a `getContext('webgl')`/`<Canvas>`/`createGlobe` per instance or in `.map()`; DPR 2 hardcoded; three/R3F for a 2D quad; heavy or stale dependencies.

- **RareUI `FluidOrb`**: one WebGL context per instance, uncapped rAF, DPR 2 → the 9th context evicts the 1st in lists.
- **React Bits `Silk`**: three + R3F (~150 KB) for one quad, `frameloop="always"` → the worst cost/benefit.
- **React Bits `GridScan`**: imports `face-api.js` (~700 KB + weights) → a bundle bomb.
- **Aceternity `globe`/`3d-globe`/`canvas-reveal-effect`/`vortex`**: three-globe or R3F, one context each → 600 KB+.
- **Magic UI `globe`**: pins `cobe@^0.6.4` (current 2.0.1); `devicePixelRatio: 2` and `width * 2` hardcoded; `mapSamples: 16000` → a stale major; 4× pixels on DPR-1 screens.
- **Magic UI `globe`**: drag uses `onPointerOut` with no `setPointerCapture` → the drag drops at the canvas edge.
- **Animate UI** (17 items): depends on `@base-ui-components/react` (renamed to `@base-ui/react`) → a duplicate, older Base UI.
- **shadcnblocks**: `import { cn } from "cn"` + a `"cn"` dependency → phantom dependency.
- **ReUI `tree`/`rating`**: site-internal `@/app/(create)/…` imports → build break.

**Fix pattern:** ≤1 context per page, never in lists (static seeded frame for many instances); DPR ≤1.5; ≤30fps; ogl or raw WebGL2 per `speed` → `references/rendering-smoothness.md` §8; cobe 2.x for globes; diff registry `dependencies` against `package.json`. Lint: `lint/webgl-in-map`, `lint/heavy-import`, `smooth/multiple-webgl-contexts`.

## 11. Accessibility

Signature: duplicated content without `aria-hidden`/`inert`; `aria-label` on generic elements; clickable `<li>`/`<div>`; `<button>` without `type`; hover-only behaviour; decorative SVG without `aria-hidden`; global SVG ids.

- **Magic UI `marquee`**: 3 duplicate copies without `aria-hidden`/`inert`; hover-only pause → links read and tabbed 4×; WCAG 2.2.2.
- **Skiper58 `TextRoll`**: two copies of every letter with no `aria-hidden` → "H o m e H o m e".
- **Skiper58 `TextRoll`**: nav items with `href` data render `<li className="cursor-pointer">` with no `<a>` → not focusable, not crawlable.
- **Skiper58 `TextRoll`**: hover-only, `ease: "easeInOut"` on enter, `key={index}`, `framer-motion` import → no focus parity.
- **Magic UI `text-animate`**: `aria-label` on a generic div/p + `sr-only` duplicate + `aria-hidden` segments → double announcement; label ignored.
- **React Bits `StarBorder`**: `as` defaults to `'button'` with no `type` → submits enclosing forms.
- **Magic UI `animated-beam`**: decorative SVG without `aria-hidden` → noise for screen readers.
- **Aceternity `spotlight`**: hardcoded `id="filter"` + a 151px `feGaussianBlur` + `z-[1]` over content → cross-instance filter collisions; expensive raster.
- **kibo `tree`**: zero ARIA while the docs claim keyboard support → inaccessible.
- **RareUI `HookSidebar`**: exact `href === pathname` → nested routes highlight nothing.
- **Skiper101**: exports its demo as `Skiper102` → name collision.

**Fix pattern:** duplicates get `aria-hidden="true" inert`; decorative layers get `aria-hidden`; real `<a href>`/`<button type="button">`; `:focus-visible`/`:focus-within` parity; a pause control for >5s motion; `useId()` for SVG ids; `@headless-tree/react` for trees; longest-prefix route matching. Lint: `lint/div-button`.

## 12. Off-token styling (every library)

Signature: hex/rgb/hsl literals, `neutral-*`/`zinc-*`/`purple-*` classes, `dark:text-white text-black`, purple→pink gradient pairs, `next-themes` just to pick a blend mode.

- **Magic UI `border-beam`**: `#ffaa40 → #9c40ff` → off-brand; `design/purple-gradient`.
- **Magic UI `magic-card`**: `#9E7AFF → #FE8BBB`, `#ee4f27 → #6b21ef`; `next-themes` dependency → second accent; extra dependency.
- **Aceternity `background-beams`**: `#18CCFC → #6344F5 → #AE48FF` → the purple→pink trope.
- **React Bits `SpotlightCard`, `StarBorder`**: `neutral-800/900`, `#000/#fff/#222` → breaks light mode and directions.
- **RareUI**: orange defaults → off-brand.

**Fix pattern:** the token mapping table in SKILL.md; one hue via `--brand` + `color-mix(in oklab, …)`. Lint: `lint/hardcoded-colors`, `design/multiple-accents`, `design/gradient-text`.

---

Sources: registry JSON under `magicui.design/r`, `reactbits.dev/r`, `ui.aceternity.com/registry`, `skiper-ui.com/registry`, `coss.com/ui/r`, `animate-ui.com/r`; repos listed in `catalogue.md`; owner research `~/code/sandbox/void/ui/v3/component-libs.md` §3 and `local-digest.md` §4.


---

<!-- https://void-design.vercel.app/docs/inspire · updated 2026-09-16 -->

# inspire — borrow the feel, not the site

A site's feel comes from a few measurable choices: the display type tuple, the neutral ramp, how rationed the accent is, radius, shadow grammar, section rhythm and motion personality. Those transfer. Logos, words, images, fonts files and signature effects don't.
After this skill, continue with `website` from phase 2. Effects: `motion`, `components`. Tokens: `craft`.

## Rules

1. **Measure before you imitate.** Run `void extract`; don't eyeball colors or guess fonts.
2. **Borrow relationships, not values that identify the brand.** Tracking ratio, weight, leading, text tiers, accent coverage, rhythm: yes. Exact brand hex, logo, wordmark, copy, illustrations: no.
3. **Never copy:** logos, favicons, product names, headlines/taglines/CTA wording, testimonials, client lists, photos/video/3D/illustrations/icons made for the brand, proprietary font files, CSS/JS bundles, and the source's signature composition in combination (exact hero + exact motion + exact palette).
4. **Proprietary fonts → free alternatives** matched on tuple (weight, tracking, leading), from `references/font-alternatives.md`.
5. **Rebuild effects at a lower tier.** GSAP/Lenis/WebGL become CSS scroll-driven animation, grain and gradients, or one budgeted canvas (`references/effect-downgrades.md`).
6. **Three deliberate departures**, written down, before building.
7. **Compare feel, not pixels.** Side-by-side screenshots judged on the 12 DNA traits, not overlay parity.
8. **Ours must be dramatically lighter.** Report source vs ours: JS, fonts, total transfer.

## Step 1 — Extract

```bash
void extract https://example.com --out .void/inspire/example.com
# more pages from the same site: --pages /pricing,/about   or   --max-pages 3
```

Writes into `.void/inspire/<host>/`: `brief.md` (human/agent brief), `dna.json` (every measurement with 0–1 confidence and evidence), `direction.css` (a draft void direction with overrides baked in), `shots/` (desktop fold, full page, mobile fold, section crops, dark fold if the site responds to `prefers-color-scheme`).

Then:
1. Read `brief.md` fully: TL;DR, nearest direction, color, typography, layout rhythm, components, motion, page outline, weight, do-not-copy, 3 moves, confidence & gaps.
2. **Look at the screenshots** (open the PNGs): fold, full page, mobile. Note the hero archetype, imagery style, density, and what you actually *feel* (calm? loud? editorial? technical?).
3. Treat anything with confidence < 0.5 (listed under "Confidence & gaps") as unknown; verify it manually (Step 1b) or ignore it.

`void extract` unavailable or failing (bot wall, login): use the manual procedure in `references/extraction-guide.md` §4 (DevTools, 12 measurements, ~15 minutes). WebGL/video pages need SwiftShader or they screenshot black; `void extract` already enables it.

## Step 2 — Decide: borrow / adapt / drop

Write this table into the brief. Keep it short.

| Trait | Source (measured) | Decision | Ours |
|---|---|---|---|
| Display tuple (family / weight / lh / tracking) | e.g. NHG Display 700 / 0.91 / −0.013em | borrow structure | Schibsted Grotesk 700 / 0.92 / −0.03em |
| Type roles and workhorse size | | borrow | |
| Neutral ramp (bg, 3 text tiers, hairline alpha) | | borrow relationships | direction tokens, tuned |
| Accent (hue, coverage) | | adapt: keep coverage, change hue | |
| Radius mode, shadow grammar | | borrow | |
| Container, gutter, section rhythm, header | | borrow | |
| Hero archetype, section outline | | adapt (order + our content) | |
| Motion personality (durations, easing, what moves) | | borrow personality | tokens |
| Signature effects | | downgrade tier or drop | |
| Imagery / illustrations / logos / copy | | **never copy** | our real assets |

What transfers best, in order of how much it explains the feel (from tearing down 24 sites): display tuple → text-style histogram (workhorse size, mono texture) → neutral ramp temperature → accent share → radius mode → shadow grammar → layout skeleton/rhythm → button spec → transitions/easing → motion runtime. Details: `references/extraction-guide.md`.

## Step 3 — Map to a void direction

1. Start from `brief.md` → "Start from a void direction" (the recommended direction and scores). Sanity-check against `craft` §1: the audience of **our** site wins over the source's look if they conflict.
2. Open the draft `direction.css`. Keep it only if its values are plausible; otherwise import the stock direction and add a small override block.
3. Allowed overrides (edit `direction.css` or add after the direction import in `globals.css`): fonts and `next/font` variables, `--weight-display`, `--weight-heading`, `--tracking-display`, `--label-tracking`, `--radius`, `--section-y`, `--container-page`, durations, `--grain-opacity`, neutral hue/chroma, brand roles (via `craft/references/color.md` §5). Keep every semantic role name; never add hex to components.
4. Check contrast after edits: the draft is contrast-checked only for generated roles. Run `void lint`, then `void design <url>` on the running site.
5. More than ~8 overrides means a different stock direction fits better. Switch.

## Step 4 — Rebuild signature effects cheaper

For each effect in `brief.md` → Motion (and `dna.json` → `motion.effects`, `motion.libraries`, `motion.smoothScroll`):

1. Is it a signature of that brand (preloader shader, CRT, scrubbed word fill, custom cursor)? Then don't reproduce it as-is; take its **personality** (e.g. "text arrives line by line, confident, 15–60ms stagger") and pick at most one to rebuild.
2. Rebuild at the lowest tier that keeps the personality: `references/effect-downgrades.md` maps GSAP ScrollTrigger, SplitText, Lenis, pinning, OGL/three shaders, Lottie, video backgrounds, preloaders and cursor followers to CSS recipes in `motion/references/recipes.md` or lean components in `components`.
3. Budget from `motion` §3: one signature + at most one secondary; ≤ 1 WebGL context; no smooth-scroll library.

## Step 5 — Differentiate (3 deliberate departures)

Write three departures into the design statement. `brief.md` → "Make it your own (3 moves)" suggests some. Pick from:

| Lever | Example |
|---|---|
| Accent hue | move ≥ 60° away from the source hue at the same L and chroma; prefer less crowded bands (gold/citron 57–117°, cyan/azure 174–241°) |
| Type voice | different display family class (grotesk ↔ serif ↔ mono) with the same tuple rhythm |
| Scheme | dark source → light-first site (or the reverse), keeping the ramp relationships |
| Hero composition | product shot ↔ typographic poster ↔ split |
| Signature detail | replace their signature with one tied to **our** product (a real log, a real metric, a real map) |
| Radius / depth | 0 → 6px, rings → hairlines, soft shadows → flat |
| Rhythm | compress (dense product) or expand (editorial) section spacing by a step |

Test: someone who knows the source site should say "similar taste", not "copy of X". If any screenshot could be mistaken for the source, add a departure.

## Step 6 — Build, compare, report

1. Build with `website` phases 2–6 using the brief, our content and the direction.
2. Compare screenshots side by side (`.void/inspire/<host>/shots/` vs `.void/shots/`) at 1440 and 390 on feel: type presence, density, temperature, accent restraint, rhythm, motion personality. Write 3 bullets: what matches, what's intentionally different, what still feels off.
3. Weight: read the source's `tech.transfer` from `dna.json` (or `brief.md` → Weight) and ours from `void audit` (`perf` section). Report a table: JS KB, font KB, image+media KB, total KB, requests. Target: JS ≤ 170 KB first-load, fonts ≤ 120 KB, total ≤ 1 MB for a marketing page.

## Worked examples (from the 2026-09-15 teardown)

### A. hellohello.is → void `swiss` (dark scheme)

| Measured | Translation |
|---|---|
| Neue Haas Grotesk Display/Text 75 Bold, **one weight (700)**, hierarchy from size + white/#777 split | Schibsted Grotesk 700 display + 400 body (break the single weight on body for readability); `text-fg` / `text-fg-muted` split; `--weight-display: 700` (swiss default) |
| vw-scaled type: hero 104px (7.24vw), lh 0.91, −0.013em; statements 64px lh 0.86; body −0.04em | `text-[clamp(3rem,1.2rem+6.2vw,6.75rem)] leading-[0.9]` poster (archetype 1c); statements `text-display-lg`; no negative body tracking (Schibsted isn't NHG) |
| black #000 / panels #131313 / #fafafa / #777 (206 elements), 1px #2e2e2e row rules, no shadows, radius only on dots | swiss `.dark` roles (L 14% bg, not #000); `divide-y divide-line-subtle` rows; `shadow-*` unused; radius 0 |
| red #d83831 on 14 elements + selection red/black | swiss red is nearly the same hue → **departure 1:** shift accent to citron `oklch(85% 0.17 105)` (dark text on solids) at similar coverage; keep a brand-solid selection |
| GSAP + ScrollTrigger + Lenis (`lerp .05`) + OGL CRT shader; masked line reveals 15–60ms stagger; grey→white scrubbed word fill; 900px pinned scenes; ~8s preloader | no preloader; hero lockup `void-settle` + staggered lines (T1); statements with scroll-driven masked line rise (`motion/references/recipes.md` §10), one section only; word fill dropped (**departure 2**); native scroll; CRT → CSS scanlines + grain over a static AVIF (T0), or omitted; no pinning |
| Corner metadata: live Montevideo time, ©2026, last.fm "now playing" | this is their signature → don't copy. **Departure 3:** one corner detail tied to our product (e.g. latest release version from the changelog, server-rendered) |
| Capabilities as numbered 3-column editorial list | archetype 5 (editorial capability list) — a convention, fine to borrow |
| 16 JS requests / **396 KB** JS, 157 KB fonts (OTF/TTF), 2.2 MB video, **3.3 MB** total | target ≈ 140 KB JS (Next baseline + 0 client effects), ~60 KB woff2 fonts, 1 AVIF poster, **< 700 KB** total |

### B. linear.app → void `precision`

| Measured | Translation |
|---|---|
| Inter Variable 510 (cv01, ss03), H1 64/64, −0.022em at 64 and 48px (constant ratio); hero sub only 15px #8a8f98; mono 14/24 most frequent style | Geist 500 via precision (`--tracking-display: 1` gives −0.04em at display-lg; set `--tracking-display: 0.6` to land near −0.024em); sub `text-md text-fg-muted`; `label-mono` + mono metadata for texture |
| #08090a bg, text #f7f8f8 / #d0d6e0 / #8a8f98 / #62666d, hairlines rgba(255,255,255,.08) ×68, blurred fixed 73px header | precision dark roles as-is (bg L 14.5%, line 9% white); template header (56px, blur optional) |
| #5e6ad2 indigo on skip link only; multi-hue pastel labels | precision's indigo *is* this hue → **departure 1:** accent to teal `oklch(62% 0.11 195)` or amber; no pastel label rainbow |
| pill buttons (32 nav / 44 hero), `:active scale(.97)`, 160ms `(.25,.46,.45,.94)` | `Button` + `press`; keep radius-lg buttons (**departure 2**: not pills) |
| hero: left H1 + sub + changelog link right, then full product UI window | archetype 1b with **our** product; if we have no product UI yet, a real terminal/log block instead (**departure 3**) |
| H1 words SSR'd at `opacity:0; blur(10px)` then WAAPI 1000–1500ms; 4,380 `dotIn` dot-grid animations; View Transitions CSS; 73 reduced-motion queries | H1 `void-settle` 450ms without opacity/blur (LCP-safe); no dot-grid animation; View Transitions via `<ViewTransition>` ≤ 400ms; reduced motion from base |
| 431 JS requests / 1.76 MB JS, 541 KB fonts (full Inter var), HTML 1.24 MB decoded, 6.2 MB total | ≈ 150 KB JS, ~51 KB fonts, HTML < 60 KB, total < 1 MB |

### C. anthropic.com → void `editorial`

| Measured | Translation |
|---|---|
| Anthropic Sans/Serif (custom, variable 300–800); **serif body 20/28**; H1 sans 61px 700, tracking 0, `balance`; hero paragraph serif 24/33.6 in a right column | Newsreader for body `text-lg`/`text-xl` at 1.6; H1 in Newsreader 400 `text-display-lg` (**departure 1:** serif H1 instead of bold sans); split hero archetype 1d (convention, borrow) |
| ivory #faf9f5, ink #141413, secondary #b0aea5, borders rgba(20,20,19,.1), dark footer | editorial light roles (paper bg L 98.3% hue 85, ink L 21%) — already this relationship; `bg-fg text-bg` footer band only if it serves the page |
| clay #d97757 on 1 background; selection clay 50% | keep single-use restraint; editorial oxblood instead of clay (**departure 2**) |
| link words underlined **inside** the H1 | their signature → drop; use underlined links in the deck paragraph instead |
| 0/25 headings centered; skip links; radii 8/12/16/24; no computed shadows | left-aligned throughout; editorial radius 2px (**departure 3**: sharper, rules instead of rounded cards) |
| Webflow + GSAP + ScrollTrigger + Lottie; 983 KB video; 3 fonts / 353 KB; no JSON-LD, no llms.txt; 1.75 MB total | CSS `reveal` on 1–2 figures; AVIF poster instead of video; ~75 KB fonts (Newsreader + Plex Mono); JSON-LD + llms.txt from the template (`seo`); total < 600 KB |

## Legal and ethical note

Visual style, layout conventions, spacing systems and color relationships are generally not protectable. Logos, trademarks, copywriting, photography, illustrations, font software, code and distinctive trade dress can be. `void extract` records measurements for inspiration only: never download or self-host the source's font files, lift its CSS/JS, reuse its assets or text, or make a site that could be confused with the source or imply affiliation. When the user asks for a literal clone of a brand they don't own, explain this and offer the inspired version. When in doubt, add another departure. Not legal advice; the user is responsible for their final site.

## References

- `references/extraction-guide.md` — what each DNA field means, reliability, pitfalls, manual DevTools procedure
- `references/font-alternatives.md` — proprietary → free families with licences and tuple adjustments
- `references/effect-downgrades.md` — heavy library effect → cheaper equivalent with code pointers


---

<!-- https://void-design.vercel.app/docs/inspire/effect-downgrades · updated 2026-09-16 -->

# Effect downgrades: heavy library effect → cheap equivalent

Keep the **personality** of an effect (timing, direction, restraint), rebuild it at the lowest tier that carries it. Tiers and budgets: `motion` §3. Recipes: `motion/references/recipes.md` (§ numbers below). Library-shaped effects: the `components` skill.

## 1. Mapping table

| Source effect (typical stack) | Cost in the source | void equivalent | Tier / JS | Where |
|---|---|---|---|---|
| Preloader / intro video before content (hellohello ~8s) | time-to-content, LCP | **delete**; hero renders immediately, one ≤ 600ms lockup settle | T1 / 0 | recipes §10 |
| Char-by-char hero reveal (GSAP SplitText, rAF) | 45–70 KB, hidden LCP | H1 `void-settle` (translate, no fade) + staggered sub/CTA `void-rise` | T1 / 0 | recipes §10 |
| Masked line reveal on scroll (ScrollTrigger + `translate3d(0,N%,0)` in overflow-hidden) | GSAP + ScrollTrigger ~70 KB, main-thread scroll | authored lines + CSS `view-timeline` masked rise, one statement section | T1 / 0 | recipes §10 |
| Per-word reveal (SplitText, Framer `staggerChildren`) | per-node motion components | server-split words + `@starting-style` / `view()` stagger | T1 / 0 | `components` → RevealText |
| Section fade-up on scroll (framer `whileInView`, AOS) | +32–46 KB, JS per section | `reveal` utility on 1–3 key visuals | T1 / 0 | `craft` / base.css |
| Scroll-scrubbed grey→white word fill | ScrollTrigger scrub | per-word `color` animation on a named view timeline (§2), one paragraph; or drop | T1-paint / 0 | below |
| Pinned full-viewport scenes (ScrollTrigger `pin`) | scroll hijack feel, layout thrash | one `sticky` track (`h-[200svh]` wrapper, `sticky top-0` child) + CSS `view-timeline` for progress; or normal flow | T1 / 0 | below |
| Parallax image stacks (ScrollTrigger / Locomotive) | main-thread transforms | `animation-timeline: view()` translate ≤ 6% on one image per viewport | T1 / 0 | `speed/references/rendering-smoothness.md` §3 |
| Smooth scroll (Lenis, Locomotive, ScrollSmoother) | ~10 KB + permanent rAF, scroll on main thread | **native scroll**; `scroll-behavior: smooth` for anchors only (base) | — | `motion` §8 |
| WebGL CRT / film / dither shader (OGL, three) | 10–160 KB + GPU, context limits | CSS scanlines + `grain` + inset vignette over a static AVIF; or one canvas under all rules | T0 / 0 (or T4 budgeted) | `motion/references/webgl-canvas.md` §7 |
| Shader gradient / aurora / mesh (three, ogl, Stripe-like ribbon) | 140 KB+ | 2–3 blurred radial layers drifting via `translate` 20–40s, paused offscreen | T1 / 0 | `components` → AuroraBackdrop |
| Particle / star / flow field (tsParticles, canvas) | perpetual rAF | static SVG dot field + one slow layer translate; or `useCanvas2D` ≤ 30fps | T0–T1 / 0, or T3 | `webgl-canvas.md` §4 |
| 3D globe (three) | 140 KB+ | static SVG/AVIF; `cobe` (5 KB) only if it must rotate | T0 / T4 | `components` |
| Lottie illustrations | lottie-web ~60 KB + JSON | SVG with CSS keyframes on 1–3 paths, or an AVIF/short muted video with poster | T1 / 0 | — |
| Background video hero (Mux/HLS) | 2–7 MB | AVIF poster; `<video muted playsInline loop preload="none" poster>` started in view, pausable | T0 / 0 | `speed` |
| Custom cursor / cursor follower | rAF + hidden native cursor | drop; or spotlight gradient following pointer on one card grid, `pointer-fine` only | T2 / ~0.5 KB | `components` → SpotlightCard |
| Magnetic buttons | window listeners + setState | drop (hurts precision); or element-scoped translate ≤ 4px | T2 / ~0.5 KB | `components` → Magnetic |
| Infinite logo marquee (JS slider) | perpetual JS loop, duplicate a11y | CSS two-track marquee, `inert` duplicate, pause offscreen and on hover/focus | T1 / 0 | `components` → Marquee |
| Animated counters (CountUp, springs) | SSR "0", replay on every view | SSR the real value; animate once if it starts offscreen; or `@number-flow/react` | T2 / ~0.6 KB | `components` → NumberTicker |
| Border beam / animated gradient border | infinite JS | rotating conic layer with `@property`, one element | T1 / 0 | `components` → BeamBorder |
| Shiny / shimmer text | perpetual JS | CSS `background-position` keyframes on one short label, paused offscreen; never on headlines | T1-paint / 0 | `components` |
| Page transitions (Barba, GSAP Flip, framer `AnimatePresence` routes) | JS orchestration | React `<ViewTransition>` + CSS ≤ 400ms | T1 / 0 | recipes §11 |
| Shared-element morph (Framer `layoutId`, GSAP Flip) | +46 KB | `<ViewTransition name>` for route changes; `LazyMotion` `layoutId` only in-page when essential | T1 / T2 | recipes §11, `motion` §7 |
| Hover-play project videos | many videos preloading | poster images; `preload="none"`; play on hover/focus under `pointer-fine`, one at a time | T1 / small | — |
| Typewriter headline | JS timers, CLS risk | static headline; type a terminal log instead (fixed box, CSS `steps()`) | T1 / 0 | `craft/references/directions.md` terminal |

## 2. Recipes not covered elsewhere

**Scrubbed word fill** (one paragraph; Chromium/Safari 26; static full color elsewhere):

```tsx
const words = statement.split(" ");
<p className="void-fill text-display-sm">
  {words.map((w, i) => (
    <span key={i} className="void-fill-word" style={{ "--i": i / words.length } as React.CSSProperties}>{w} </span>
  ))}
</p>
```

```css
@layer components {
  @media (prefers-reduced-motion: no-preference) {
    @supports (animation-timeline: view()) {
      .void-fill { view-timeline-name: --void-fill; }
      .void-fill-word {
        color: var(--fg-subtle);
        animation: void-fill linear both;
        animation-timeline: --void-fill;
        animation-range: cover calc(25% + var(--i) * 30%) cover calc(30% + var(--i) * 30%);
      }
    }
  }
}
@keyframes void-fill { to { color: var(--fg); } }
```

Color animation paints; keep it to one paragraph of ≤ 30 words. The text is in HTML either way.

**Single sticky scene** instead of a pinned timeline:

```tsx
<section className="void-scene relative h-[200svh]" aria-labelledby="scene-title">
  <div className="sticky top-0 flex min-h-svh items-center">
    <Container className="grid gap-10 lg:grid-cols-12">
      <h2 id="scene-title" className="text-display text-fg lg:col-span-5">…</h2>
      <div className="void-scene-media lg:col-span-7">{/* real visual */}</div>
    </Container>
  </div>
</section>
```

```css
@layer components {
  @media (prefers-reduced-motion: no-preference) {
    @supports (animation-timeline: view()) {
      .void-scene { view-timeline-name: --void-scene; }
      .void-scene-media { animation: void-scene linear both; animation-timeline: --void-scene; animation-range: contain 0% contain 100%; }
    }
  }
  @media (prefers-reduced-motion: reduce) { .void-scene { height: auto; } }
}
@keyframes void-scene { from { scale: 0.92; opacity: 0.4; } to { scale: 1; opacity: 1; } }
```

One per page. Firefox shows the final state in a tall sticky section; if that looks empty, drop `h-[200svh]` inside `@supports not (animation-timeline: view())`.

**CRT look without a shader** (terminal/agency hero):

```tsx
<div className="relative overflow-clip rounded-xl bg-bg-subtle">
  <Image src={still} alt="…" className="h-auto w-full [filter:contrast(1.1)_saturate(0.8)]" />
  <div aria-hidden="true" className="pointer-events-none absolute inset-0 bg-[repeating-linear-gradient(to_bottom,transparent_0_2px,color-mix(in_oklch,var(--bg)_55%,transparent)_2px_3px)] opacity-50" />
  <div aria-hidden="true" className="pointer-events-none absolute inset-0 shadow-[inset_0_0_120px_var(--bg)]" />
</div>
```

Add `grain` on the wrapper for noise. A slow roll bar (`translate` keyframes 7s linear infinite, `data-fx`/PauseOffscreen, none under reduced motion) is the only motion allowed here.

## 3. Deciding what to drop entirely

Drop when any is true: it's the source brand's most recognizable signature; it hides content or delays the fold; it hijacks scroll or the cursor; it needs > 1 WebGL context; it has no reduced-motion story and can't get one; it would be the second T2+ effect on the page. Replace with a stronger static idea (typography, a real product surface, a better photo) before adding any other motion.


---

<!-- https://void-design.vercel.app/docs/inspire/extraction-guide · updated 2026-09-16 -->

# Extraction guide

## 1. What `void extract` measures

Headless Chromium (SwiftShader on, so WebGL/video pages don't screenshot black) at 1440×900 and 390×844. Load + wait, wheel-scroll in steps (so Lenis/ScrollTrigger fire), capture computed styles, CSS, animations, inline-style diffs, network. Output schema: `packages/cli/src/extract/types.ts` (`DesignDNA`, schema version 1). Every inferred field is `{ value, confidence 0–1, evidence?[] }`.

## 2. Fields and how to use them

| `dna.json` path | Meaning | Use it for |
|---|---|---|
| `typography.display` | H1 px (+ mobile px, px/viewport ratio), weight, line-height ratio, tracking in em, transform | **the single best fingerprint**; map to `text-display*` + `--weight-display` + `--tracking-display` |
| `typography.roles[]` | display/heading/body/ui/mono/label → real family name (hash-cleaned), weights, classification, `free`, licence, `alternatives[]` | font choice; never self-host non-free families |
| `typography.styles[]`, `sizes[]` | text-style histogram (size, weight, lh, tracking, family, character count) | workhorse size (usually 13–14px), mono texture, positive small-size tracking |
| `typography.scale` | fitted base + ratio, clamp() steps | only if confidence ≥ 0.6; otherwise keep void's scale |
| `typography.labels` | uppercase share, label tracking, px | `--label-tracking`, whether mono labels are the voice |
| `typography.features`, `textWrap` | OpenType features (`cv01`, `ss03`…), balance/pretty usage | `font-feature-settings` override (only if the free alternative has them) |
| `color.scheme`, `respondsToColorScheme`, `alternate` | dark/light default and whether a second scheme exists | `theme.default` in site.config |
| `color.roles.*` | measured bg, bgSubtle, surface, fg, fgMuted, fgSubtle, line, brand (null = monochrome), selection | ramp relationships: L steps between tiers, hairline alpha |
| `color.neutral` | hue, chroma, temperature (cool/warm/neutral/green) | neutral hue/chroma for the generator |
| `color.accent` | hue, chroma, step-9 L, text on solid | accent L and chroma (change the hue: departure) |
| `color.palette[]` (coverage, channels, roles) | weighted colors per channel | accent **share**: restrained (1 hue, few elements) vs playful |
| `color.bands[]` | alternating dark/light section bands | whether to use `bg-bg-subtle`/inverted bands |
| `color.pixels` | dominant colors of the full screenshot, colorfulness, imagery share | how much of the feel comes from imagery (can't copy that) |
| `color.tokens` | void semantic tokens generated with the source's lightness feel | already baked into `direction.css` |
| `layout.contentWidth`, `containers`, `gutter` | content width at 1440, gutter desktop/mobile | `--container-page`, `--gutter` |
| `layout.sectionPadding` | desktop/mobile padding values | `--section-y` and rhythm variation (low confidence when spacing lives on children) |
| `layout.nav` | height, position, blur, translucent, floating, item count | header variant |
| `layout.hero` | align (left/center/split), h1 px, media side, height in vh, CTA count | hero archetype |
| `layout.density` | text blocks per viewport → airy/balanced/dense | spacing scale feel |
| `layout.radius` | knob px, value histogram, pill share | `--radius`, pill vs rounded buttons |
| `layout.shadows[]` (kind ring/soft/hard/inset/layered) | shadow grammar | elevation style (draft `direction.css` picks one) |
| `layout.texture` | grain, SVG filters, blend modes, backdrop blur | `--grain-opacity`, header blur |
| `components` (in brief) | button spec: height, padding, radius, font, bg/fg, shadow, transition | `Button` size/variant tuning |
| `motion.durations`, `easings`, `properties` | CSS transition/animation inventory | personality → duration tokens |
| `motion.animationsOnLoad/OnScroll`, `scrollDriven`, `viewTransitions`, `reducedMotionQueries` | what moves and how | what to rebuild; reduced-motion coverage (0 = don't copy the behaviour) |
| `motion.libraries` | GSAP, ScrollTrigger, Lenis, OGL/three, Lottie, motion… | effect-downgrades table |
| `motion.smoothScroll` | hijacked + library | never reproduce |
| `motion.cursor` | custom/hidden cursor | never hide the real cursor |
| `motion.effects[]` | named effects with evidence and a suggested rebuild | Step 4 |
| `motion.personality` | label + suggested tokens + summary | design statement line 5 |
| `tech.transfer` | total/JS/CSS/font/image/media KB, requests; third-party KB; HTML KB; webgl | the weight comparison |
| `structure.sections[]` | per-section composition, counts (images, cards, logos, columns), bg, padding, crop screenshot | section plan (adapt order; our content) |
| `structure.headings`, `ctaLabels` | outline and CTA wording | **structure only; never reuse the words** |
| `direction` | recommended void direction, confidence, per-direction scores with reasons, token overrides with why | Step 3 |
| `warnings[]` | capture problems (timeouts, consent walls, black WebGL) | what to verify manually |

## 3. Reliability

| Aspect | Reliable automatically | Sanity-check | Needs your judgement |
|---|---|---|---|
| Type | families in use, sizes, weights, lh, tracking, features, font files | alias → real typeface; display style when the H1 is hidden or split into spans | which free face matches; whether a serif accent is the voice or a one-off |
| Color | frequency-ranked bg/text/border, alpha, selection, theme-color | brand accent vs product-screenshot or syntax colors; dark bands inside light pages | temperature intent; photographic color |
| Layout | container, gutter, header, section padding on `<section>`, centered ratio | rhythm when spacing lives on children/margins; grid columns | section archetypes, narrative, what the hero shows |
| Components | radius, padding, heights, shadow strings, focus ring, `:active` | which button is primary; hover states | whether a recipe works out of context |
| Motion | CSS transitions/keyframes, easings, scroll timelines, WAAPI, reduced-motion count, library presence | which elements animate (inline diffs show transforms, not intent); GSAP params as sets | choreography quality; essential vs decorative |
| Imagery | formats, sizes, counts, lazy/priority, video attributes | product UI vs illustration vs photo | art direction (never replicable) |
| Performance | transfer by type, requests, third parties | full-scroll totals include lazy + tracking pixels | trade-offs (a 2 MB video may be the brand) |

## 4. Manual fallback (no CLI, ~15 minutes in DevTools)

Open the site at 1440×900, disable cache, scroll the whole page once, then record:

1. **Display tuple.** Inspect the largest heading in the fold (not just `h1`; it may be hidden or split): Computed → `font-family`, `font-size`, `font-weight`, `line-height` (÷ size), `letter-spacing` (÷ size = em).
2. **Workhorse text.** Inspect nav links, buttons, card text: size/weight/tracking; is there mono? uppercase labels + tracking?
3. **Fonts.** Network → Font: file names reveal real faces (`HaasGrotDisp-75Bold.otf`, `SuisseIntl_Book`); count and KB. Check the licence before mapping.
4. **Neutral ramp.** `html`/`body` background; color of H1, body paragraph, muted meta text; the most common border color (Elements → Computed → filter `border`).
5. **Accent.** Primary CTA background; link color; count roughly how many elements in the fold use it; `::selection` (select text).
6. **Radius mode.** Primary button, cards, inputs, images. Pill ratio.
7. **Shadows.** Copy `box-shadow` from button, card, menu. Note ring layers, inset highlights, negative spread, tint.
8. **Layout skeleton.** Main container max-width and padding; section padding (several sections); header height/position/blur; how many headings are centered.
9. **Button spec.** Height, padding, radius, font size/weight, transition string, `:active` transform (Styles pane → `:active` toggle).
10. **Motion inventory.** Styles: `transition` values and `cubic-bezier`s; Animations panel while loading and scrolling; Sources search for `gsap`, `ScrollTrigger`, `lenis`, `three`, `ogl`, `lottie`, `animation-timeline`.
11. **Reduced motion.** Rendering panel → emulate `prefers-reduced-motion: reduce`, reload, scroll. Does content still appear?
12. **Weight.** Network summary after full scroll: transferred KB total; filter JS, Font, Img, Media.
13. **Screenshots.** Full-size screenshot of fold and full page (Command menu → "Capture full size screenshot") at 1440 and 390 (device toolbar).

Write the numbers into the Step 2 table in `inspire` and continue.

## 5. Pitfalls

- **Preloaders** hide the real fold (hellohello ~8s). Measure after content appears; never reproduce a preloader.
- **Consent banners and geo redirects** (Stripe served `/in`) change layout and colors; dismiss first; note the region.
- **`getAnimations()` misses JS motion.** GSAP/Framer/Lenis/canvas are invisible to it; rely on library detection and inline-style diffs.
- **`framer-motion` / `startViewTransition` strings** appear inside frameworks: weak evidence of use.
- **Product screenshots inflate the palette.** Exclude colors inside images, canvases and app mocks before choosing an accent.
- **CSS-in-JS** (styled-components, Framer, StyleX) isn't in fetched CSS files; counts under-report.
- **Hidden or split headings:** hellohello's `h1` is 1×1px; Stripe's H1 contains the subhead; use the largest visible text.
- **Units:** tracking in em, line-height as a ratio, colors in OKLCH, fluid sizes as "vw at 1440 + floor", or values won't transfer across viewports.
- **Mobile timeouts** on media-heavy pages: mobile values may be missing (check `warnings`).
- **Heavy isn't premium.** The best-feeling sites in the teardown were not the heaviest (Paco 367 KB, Rauno 640 KB vs Family 15.3 MB); don't import weight to import feel.


---

<!-- https://void-design.vercel.app/docs/inspire/font-alternatives · updated 2026-09-16 -->

# Proprietary fonts → free alternatives

Never download, self-host or subset a font file from the source site. Match the **tuple** instead: classification, x-height/width feel, weight used, then re-tune tracking and leading with the alternative. `void extract` suggests alternatives per role (`dna.json` → `typography.roles[].alternatives`, map in `packages/cli/src/extract/fonts.ts`); this table adds tuning notes.

Licences: **OFL** = SIL Open Font License 1.1 (free for commercial use, embedding and self-hosting; can't sell the font alone). All OFL faces below are on `next/font/google` unless marked.

## Sans / grotesk

| Source (foundry, licence) | Seen on | Free alternative (licence) | Tuning |
|---|---|---|---|
| Söhne (Klim, commercial) | Stripe | Inter Tight (OFL) for display, Inter or Geist (OFL) for text | Söhne 300 display → Inter Tight 300–400; keep −0.02em; Inter `cv01, ss03` for a Söhne-like `1`/`a` |
| Neue Haas Grotesk / Helvetica Now (Monotype, commercial) | hellohello | Inter Tight (OFL) display, Inter (OFL) text; Schibsted Grotesk (OFL) for a sturdier poster feel; Arimo (Apache 2.0) metric fallback | NHG Bold is wide: Inter Tight 700 needs ~−0.02em more tracking; don't copy −0.04em body tracking onto Inter |
| Suisse Int'l (Swiss Typefaces, commercial) | Clerk | Inter or Geist (OFL), Instrument Sans (OFL) | Suisse 700 H1 → Geist 600 at −0.03em |
| GT America / GT Standard (Grilli Type, commercial) | — | Inter, Geist, Schibsted Grotesk, Hanken Grotesk (OFL) | condensed cuts → Instrument Sans `wdth` 85 |
| GT Walsheim (Grilli Type, commercial) | Framer | DM Sans, Figtree, Outfit (OFL) | Walsheim 500 −0.04em → Figtree 600 −0.03em |
| Graphik (Commercial Type, commercial) | — | Geist, Inter, Albert Sans (OFL) | — |
| Circular (Lineto, commercial) | — | Figtree, DM Sans (OFL) | avoid Plus Jakarta Sans (overused) |
| Akkurat, Brown, Unica 77 (Lineto, commercial) | — | Inter, Schibsted Grotesk, Instrument Sans (OFL) | — |
| ABC Diatype / ABC Favorit / ABC Oracle (Dinamo, commercial) | Resend H2, TBC, Dia | Geist, Schibsted Grotesk (OFL); Space Grotesk (OFL) for Favorit's quirk at display only | Favorit −0.05em H2 → Space Grotesk −0.035em; never Space Grotesk as body |
| Aeonik (CoType, commercial) | — | Manrope, Geist, Figtree (OFL) | — |
| PP Neue Montreal / PP Mori (Pangram Pangram, commercial; free for personal use only) | — | Inter Tight, Hanken Grotesk, Instrument Sans (OFL) | "free for personal" is not a licence for client or company sites |
| Founders Grotesk (Klim, commercial) | — | Schibsted Grotesk, Hanken Grotesk (OFL) | — |
| Untitled Sans (Klim, commercial) | — | Inter, Geist (OFL) | — |
| Basis Grotesque, Apercu (Colophon, commercial) | — | Hanken Grotesk, DM Sans, Figtree (OFL) | — |
| Proxima Nova, Gotham, Sofia Pro, Avenir/Futura (commercial) | — | Figtree, Outfit (OFL); Jost (OFL) for Futura geometry | Montserrat is the literal match but reads as template; prefer Figtree |
| SF Pro / system-ui (Apple; not licensed for web embedding) | many | Inter (OFL); or keep `system-ui` as a fallback stack only | — |

## Serif

| Source | Seen on | Free alternative | Tuning |
|---|---|---|---|
| Tiempos Headline/Text, Domaine, Financier, Untitled Serif (Klim, commercial) | Linear accent, Resend H1, Attio | Newsreader (OFL, opsz), Source Serif 4 (OFL, opsz); Instrument Serif (OFL) for headlines ≥ 40px | Domaine 400 at 96px −0.01em → Newsreader 400 display −0.012em (editorial knob 0.35) |
| Canela, Ogg, Recoleta, Editorial Old, Saol, Lyon, Publico, Noe Display (commercial) | — | Instrument Serif, Fraunces (`SOFT` 50, `WONK` 0), Newsreader (OFL) | high-contrast display: Instrument Serif; soft: Fraunces |
| GT Super / GT Sectra / GT Alpina (Grilli Type, commercial) | — | Instrument Serif (display), Newsreader, Fraunces (OFL) | — |
| PP Editorial New (Pangram Pangram, commercial) | — | Instrument Serif, Cormorant Garamond, Newsreader (OFL) | Cormorant only ≥ 32px |
| Ivar, Tobias, Signifier, Freight, Caslon, Sabon, Minion (commercial) | TBC (IvarText) | Newsreader, EB Garamond, Source Serif 4 (OFL) | EB Garamond needs +1–2px body size |

## Mono

| Source | Seen on | Free alternative | Notes |
|---|---|---|---|
| Berkeley Mono (US Graphics; licence forbids editors/terminals/OSS apps) | Linear | Geist Mono, JetBrains Mono, Commit Mono (OFL; Commit Mono via `@fontsource` + `next/font/local`) | local mono: `adjustFontFallback: false` |
| Söhne Mono, GT America Mono, Suisse Int'l Mono, ABC Diatype Mono, Aeonik Mono (commercial) | TBC labels | Geist Mono, IBM Plex Mono (OFL) | labels at +0.1–0.15em uppercase via `label-mono` |
| ABC Favorit Mono (Dinamo, commercial) | Arc, Dia | Space Mono, JetBrains Mono (OFL) | — |
| SF Mono, Operator Mono, MonoLisa, Input, PragmataPro (commercial or non-redistributable) | — | JetBrains Mono, Geist Mono, Commit Mono (OFL) | SF Mono only as a fallback stack |

## Display and custom brand faces

| Source | Seen on | Free alternative | Notes |
|---|---|---|---|
| Custom brand faces (CursorGothic, Anthropic Sans/Serif, arcadia, quadrant, melange, "Family", Marlin Soft SQ, Exposure) | Cursor, Anthropic, Mercury, Granola, Family, Arc, Dia | match classification: Geist / Instrument Sans for grotesks, Newsreader / Instrument Serif for serifs, Fraunces for soft serifs, Figtree or Rubik (OFL) for rounded | custom faces are part of trade dress: a different free face is already a departure |
| Mercury's variable intermediates 360/420/480 | Mercury | any variable OFL face (Inter, Geist, Figtree) at 420/480 | intermediate weights are a premium signal worth borrowing |
| PP Right Grotesk / PP Neue Machina (commercial display) | — | Anton, Big Shoulders Display; Space Grotesk / Unbounded (OFL) | poster use only |

## Licence traps

- **Fontshare** (Satoshi, General Sans, Cabinet Grotesk, Clash Display, Switzer): ITF Free Font License, not OFL; forbids subsetting/conversion. Don't use in open-source repos or with `next/font` subsetting.
- **"Free for personal use"** (Pangram Pangram, many foundry trials) is not a web licence for a business site.
- **Google-hosted ≠ always OFL:** Roboto and Roboto Mono are Apache 2.0 (also fine).
- **Adobe Fonts / Typekit** faces can't be self-hosted outside the subscription.
- Custom brand fonts are never available; don't look for mirrors.

## After choosing

1. Add the loader with the direction's CSS variable name (`craft/references/directions.md`), or rename the variable in the direction file consistently.
2. Re-tune `--weight-display` and `--tracking-display` against the source tuple, then compare H1 screenshots side by side.
3. Budget: ≤ 2 families + mono, ≤ 4 files, ≤ 120 KB (`perf/font-budget`).


---

<!-- https://void-design.vercel.app/docs/speed · updated 2026-09-16 -->

# speed — fast to load, smooth to use

Targets are for a mid-range phone on Slow 4G (Lighthouse mobile: 150 ms RTT, 1.6 Mbps, 4x CPU).
Verify every claim with `void perf` and `void smooth` against a **production build** (see the `audit` skill).

## The 10 rules that matter most

1. **Server-render by default.** `'use client'` only on leaf components that need state, effects or event handlers. Never on `page.tsx` or `layout.tsx`.
2. **The LCP element is in the initial HTML and visible at first paint.** No `loading="lazy"`, no `opacity:0` entrance, no CSS `background-image`, no `<Suspense>` waiting for data around it.
3. **One high-priority image per route:** `fetchPriority="high"` + `loading="eager"` (or `preload`) on `next/image`. `priority` is deprecated in Next 16. Never use it.
4. **Every `<img>`, `<video>`, `<iframe>`, `<canvas>` has `width`+`height` or `aspect-ratio`.** Late UI (banners, toasts, consent) is `position: fixed` or has reserved `min-height`.
5. **Marketing routes: ≤ 170 KB gzip first-load JS** (Next's own baseline is ~133 KB, so you have ~40 KB). App routes ≤ 300 KB.
6. **Prefer CSS over JS for motion.** Animate only `transform` and `opacity`. If you need Motion, use `LazyMotion` + `m` (+32 KB), never the full `motion` component (+46 KB).
7. **Fonts: ≤ 2 families, ≤ 4 files, `next/font`, variable WOFF2, `subsets: ['latin']`.** No Google Fonts `<link>`, no icon fonts.
8. **Third-party scripts load with `next/script strategy="lazyOnload"`.** Chat widgets and video embeds get a static facade that loads the real thing on click.
9. **No scroll listeners for effects.** Use `IntersectionObserver` or CSS scroll-driven animations. Touch/wheel listeners are `{ passive: true }`. No Lenis or other smooth-scroll libraries.
10. **Every loop stops when unseen.** rAF, intervals and infinite CSS animations pause offscreen (`IntersectionObserver`) and when the tab is hidden (`visibilitychange`). At most one WebGL context per page.

## Budgets

"Gate" = the `void perf` default (`kind: "marketing"` or `"app"` in `void.config.ts`); exceeding it fails the run. "Target" = what a polished marketing page should reach.

| Metric (mobile lab, median of runs) | Marketing gate | Marketing target | App gate | Rule id |
|---|---|---|---|---|
| LCP | ≤ 2.5 s (desktop ≤ 1.5 s) | desktop ≤ 1.2 s | ≤ 2.5 s | `perf/lcp-slow` |
| TBT (lab proxy for INP) | ≤ 200 ms | ≤ 100 ms | ≤ 300 ms | `perf/tbt-high` |
| INP (scripted interactions) | ≤ 200 ms | ≤ 100 ms desktop (unthrottled) | ≤ 200 ms | `smooth/inp-slow` |
| CLS | ≤ 0.1 | ≤ 0.05 after full scroll | ≤ 0.1 | `perf/cls-high`, `smooth/layout-shift-on-scroll` |
| TTFB | ≤ 800 ms | ≤ 600 ms | ≤ 800 ms | `perf/ttfb-slow` |
| JS transfer at load | ≤ 170 KB | ≤ 40 KB above the framework | ≤ 300 KB | `perf/js-budget` |
| CSS transfer | ≤ 25 KB | — | ≤ 40 KB | `perf/css-budget` |
| Fonts | ≤ 120 KB, ≤ 2 families, ≤ 4 files | ≤ 2 files preloaded | same | `perf/font-budget`, `perf/too-many-fonts` |
| Images at load (total) | ≤ 1,000 KB | LCP image ≤ 150 KB | — | `perf/image-budget` |
| HTML document | ≤ 30 KB | — | ≤ 60 KB | — |
| Dropped frames while scrolling | ≤ 5% | 0 ms long-frame blocking | ≤ 5% | `smooth/scroll-jank`, `smooth/long-frames-during-scroll` |
| DOM | ≤ 1,400 elements, depth ≤ 32 | — | same | `perf/dom-size` |
| Lighthouse performance (`--lighthouse`) | ≥ 90 | — | ≥ 80 | `perf/lighthouse-score-low` |

Next's own framework baseline is ~133 KB gzip, so a marketing page has ~40 KB for its own client code. Astro/static pages with no framework runtime: ≤ 50 KB JS (islands only). Override budgets in `void.config.ts` (see the `audit` skill).

## Before adding any client JS, ask in order

1. **Can HTML/CSS do it?** `<details>`, `<dialog>`, `popover`, `:has()`, `@starting-style`, CSS transitions, scroll-snap. Then write zero JS.
2. **Can the server do it?** Markdown, syntax highlighting (Shiki at build time), charts as static SVG, date formatting, icons (lucide in a Server Component costs ~1 KB). Then do it in a Server Component.
3. **Is it needed at first paint?** No: `next/dynamic` (inside a client component) mounted on interaction or visibility.
4. **Is it a third party?** `lazyOnload` or a facade. Never synchronous in `<head>`.
5. **Does it add > 5 KB gzip?** Check the size (bundlephobia, `next experimental-analyze`) and find a lighter option or cut it. Known costs: `three` 140–160 KB, `swiper` ~40 KB, `react-markdown` ~50 KB, Prism/`react-syntax-highlighter` ~250 KB, `lenis` ~10 KB plus a permanent rAF loop.

Banned in `'use client'` modules on marketing routes: `prismjs`, `highlight.js`, `shiki`, `react-markdown`, `remark*`, `rehype*`, `moment`, `chart.js`, `recharts`, `d3`, `framer-motion`, and the `motion` component (`import { motion } from "motion/react"`; use `m` from `motion/react-m` instead).

## Server-first component boundaries

- Push `'use client'` down to the smallest interactive leaf (button, menu, carousel). Pass Server Components into client wrappers as `children`.
- A Server Component imported from a `'use client'` file gets bundled as client code. Import it from a server parent and pass it down.
- Pass only the props the client needs. Whole CMS documents or ASTs get serialized twice: once into the HTML, once into the RSC payload.
- Put slow data behind `<Suspense>` with a skeleton of the **same height**. Keep the hero and h1 outside it.
- Pages that can be static stay static (`○` in `next build`). Use `'use cache'` with `cacheComponents: true` for cached dynamic data.
- Details and code: `references/next-performance.md`. Hook and effect bugs: `references/react-pitfalls.md`.

## LCP recipe

1. Know the LCP element. On marketing pages it is the hero image or the h1. A **typographic hero is cheaper** (it only needs HTML, CSS and the font).
2. Serve it from static or prerendered HTML behind a CDN. TTFB is about 40% of LCP.
3. Image hero: a static import plus `fill sizes="100vw" fetchPriority="high" loading="eager"`, AVIF/WebP, ≤ 150 KB on mobile. Every other image stays lazy with an accurate `sizes`.
4. Don't hide it. An element at `opacity:0` is not an LCP candidate until it becomes visible. Animate secondary elements, or start the hero from a visible state.
5. No render-blocking third parties before it. With `next/font` and `next/image` everything is same-origin, so you need zero `preconnect`s.

Recipes: `references/images-fonts.md`. Ids: `perf/lcp-image-lazy`, `perf/lcp-image-no-priority`, `perf/lcp-background-image`, `perf/image-oversized`, `perf/image-legacy-format`, `perf/render-blocking`.

## INP recipe (every click/tap/key → next frame ≤ 200 ms)

- Ship less JS (above). Hydration cost scales with the size of the client component tree.
- Handlers: do the visual acknowledgement synchronously (toggle a class or pending state), then `await yieldToMain()`, then do the heavy work, analytics or network.
- Use `startTransition` for non-urgent state updates (filters, tab content). Use `useDeferredValue` for expensive renders driven by typing.
- No long task > 50 ms after any interaction. Split loops with `scheduler.yield()` (Safari needs the `setTimeout` fallback).
- Per-frame values (pointer position, scroll progress) never go through React state. Write them to the DOM, a CSS variable on the element itself, or a MotionValue.
- Consider `reactCompiler: true` in `next.config.ts` for app routes with heavy re-renders.

```ts
export const yieldToMain = () =>
  (globalThis as any).scheduler?.yield ? (globalThis as any).scheduler.yield() : new Promise<void>((r) => setTimeout(r, 0));
```

## CLS recipe

- Media: `width`/`height` attributes or `aspect-ratio`. `next/image` with `fill` needs a sized, positioned parent. Id: `perf/images-missing-dimensions`.
- Fonts: `next/font` keeps `adjustFontFallback` on. Outside Next, add a `size-adjust` fallback `@font-face`. `font-display: swap` or `optional`, never `block`.
- Late content: announcement bars are server-rendered; cookie banners and toasts are `fixed`; ad and embed slots have `min-height`; skeletons match the final height within ±8 px.
- Never animate `width`, `height`, `top`, `left`, `margin` or `padding`. For accordions use `grid-template-rows: 0fr → 1fr`.
- `scrollbar-gutter: stable` on `html` so modals and scroll locks don't shift the page.

## Images, fonts, third parties (summary)

- `next.config.ts`: `images: { formats: ['image/avif', 'image/webp'] }`. Next 16 only allows `quality` 75 unless you add values to `images.qualities`.
- Below-the-fold: default lazy + `sizes` (e.g. `sizes="(min-width: 1024px) 33vw, 100vw"`). Decorative images: `alt=""`.
- Video: `poster`, `preload="none"`, no autoplay with sound. YouTube/Vimeo: a thumbnail facade with `aspect-ratio: 16/9` that swaps in the iframe on click.
- Scripts: `beforeInteractive` only for consent or bot detection, `afterInteractive` only for analytics you truly need at load, `lazyOnload` for everything else. `strategy="worker"` does not work in the App Router.
- Fonts: turn `adjustFontFallback` **off** for monospace `next/font/local` (it scales Arial to ~131% and code renders oversized until the swap). Use `preload: false` on decorative or italic faces. Scope display fonts to the route layout that uses them.

## Animation and smoothness

Timing, easing and choreography live in the `motion` skill. The performance rules:
- Only `transform` and `opacity`. `filter`/`clip-path` on small elements only. No `backdrop-filter` over large or scrolling areas (at most one blurred header). No `transition: all`.
- Set `will-change` only while an animation runs, never globally, and on at most 3 elements.
- Scroll effects: CSS `animation-timeline: view()` inside `@supports (animation-timeline: view())`, because Firefox lacks it and the page must look complete without it. Or use `IntersectionObserver`. At most one scroll-scrubbed set piece per page.
- Long pages (> 3 viewports): `content-visibility: auto; contain-intrinsic-size: auto 800px` on repeated below-fold sections. Never on the first viewport.
- Every animation respects `prefers-reduced-motion: reduce` and falls back to a finished static frame. Content is never hidden.
- Details: `references/rendering-smoothness.md`. Ids: `smooth/animate-layout-property`, `smooth/transition-all`, `smooth/will-change-overuse`, `smooth/reduced-motion-ignored`, `smooth/scroll-listener-nonpassive` (list all: `void rules smooth`).

## WebGL / canvas: allowed only if all of these hold

Allowed for a hero or ambient backdrop on marketing pages. In app routes only when the canvas **is** the product.
1. **One live context per page** (two at most). Never inside `.map()` or a list item: browsers cap contexts at ~8–16 and silently kill the oldest. For many instances, draw one static frame or use CSS gradients.
2. **Pause, don't skip.** Cancel the rAF when offscreen (`IntersectionObserver`) or when `document.hidden`. Never pause because the user is idle.
3. **Cap DPR at 1.5** (1 for full-screen backdrops) and fps at 30 for ambient effects. Size backdrops to the viewport (`position: fixed`), not the page.
4. **Reduced motion:** render one static frame and stop.
5. **Cleanup:** cancel the rAF, remove listeners, `getExtension('WEBGL_lose_context')?.loseContext()` on unmount, handle `webglcontextlost`. Update uniforms when props change; don't rebuild the program.
6. **Load after paint:** `next/dynamic(..., { ssr: false })` from a client component, a reserved box with the shader's average color as placeholder, `aria-hidden` + `pointer-events-none` when decorative.
7. Prefer `ogl` (10–14 KB) or raw WebGL2 over `three` (140 KB+) for full-screen quads. Mount persistent backdrops once in the root layout so they don't re-init on every navigation.

Paste-ready hook: `references/rendering-smoothness.md` §8. Ids: `smooth/multiple-webgl-contexts`, `smooth/raf-loop-idle`, `lint/webgl-in-map`, `lint/raf-without-cancel`.

## bfcache and caching

- No `unload` listeners. Use `pagehide`. Add `beforeunload` only while there are unsaved changes. Id: `perf/bfcache-blocked`.
- Hashed assets (`/_next/static/*`): `public, max-age=31536000, immutable` (Next does this). HTML: `no-cache` or CDN `s-maxage` + `stale-while-revalidate`. **Never `immutable` on HTML.** Id: `perf/cache-headers`.
- Compression: `next start` only gzips. Put brotli/zstd at the CDN or proxy (and set `compress: false` when the proxy compresses). Id: `perf/no-text-compression`.
- `output: 'standalone'` doesn't copy `public/` or `.next/static`. Copy them, or fonts and images 404.
- Next App Router: rely on `<Link>` prefetch. Don't add document-wide Speculation Rules `prerender`. Multi-page apps (Astro): `prefetch` at `moderate`.

## Never do

- `'use client'` at the top of a page or layout.
- `priority` on `next/image` (Next 16) · `loading="lazy"` on the hero · a hero as CSS `background-image`.
- An h1 or hero at `initial={{ opacity: 0 }}` waiting for hydration.
- Google Fonts `<link>`/`@import` · icon fonts · more than 2 families.
- `framer-motion`, or `motion.div` without `LazyMotion` · Lenis, locomotive-scroll, GSAP ScrollTrigger pinning.
- Animating `width`/`height`/`top`/`left`/`margin`/`box-shadow` · `transition: all` · permanent `will-change`.
- `setState` on `scroll`/`mousemove`/rAF · `setInterval` or subscriptions in the render body · `window`/`document`/`navigator` at module scope or during render.
- Non-passive `wheel`/`touchstart` listeners on `window`/`document`.
- A WebGL context per card · rAF that keeps running offscreen · canvas at DPR 2+.
- Synchronous third-party scripts in `<head>` · autoplaying background video · chat widgets that load at startup.
- `unload` handlers · `Cache-Control: immutable` on HTML.
- Client-side fetches of rate-limited public APIs (e.g. GitHub stars) on every visit. Fetch on the server with `revalidate`.
- Measuring against `next dev`.

## Verify

1. After each significant edit: `void lint` (seconds). It catches `lint/image-priority-deprecated`, `lint/heavy-import`, `lint/page-level-use-client`, `lint/animate-layout-prop`, `lint/scroll-listener-nonpassive`, `lint/no-reduced-motion`, `lint/timer-in-render`, `lint/browser-global-in-render`, `lint/raf-without-cancel`, `lint/webgl-in-map`, `lint/unload-listener`, `lint/google-fonts-link`, `lint/script-strategy`.
2. Before declaring done: `next build`, then `void perf --start "next start -p 3000" --port 3000` and `void smooth --start "next start -p 3000" --port 3000` (scroll jank, INP of interactions, non-composited animations, idle rAF loops, WebGL context count).
3. Fix in order: errors → budgets → warnings. Look up any id with `void rules <id>`. The full loop is in the `audit` skill.


---

<!-- https://void-design.vercel.app/docs/speed/images-fonts · updated 2026-09-16 -->

# Images and fonts: paste-ready recipes

Verified against Next 16.3.5 `next/image` and `next/font` docs (2026-09-16). Portable HTML/CSS is given for other stacks.

## 1. next/image in Next 16: what changed

| Prop / config | Next 16 behaviour | Do |
|---|---|---|
| `priority` | **Deprecated** | Never use. Use `loading="eager"` + `fetchPriority="high"` (the docs' preferred form) or `preload` |
| `preload` | Adds `<link rel="preload">` in `<head>` | Use when the image isn't the first thing discovered. Don't combine it with `loading`/`fetchPriority` |
| `loading` | Default `lazy` | Leave it lazy for everything below the fold |
| `sizes` | Missing means the browser assumes `100vw` and Next emits only 1x/2x | **Always pass `sizes`** unless the image is fixed-width (`width={64}`) |
| `quality` | Default 75. `images.qualities` defaults to `[75]`, so other values are coerced | Add `qualities: [60, 75, 90]` to config if you need them |
| `images.formats` | Default `['image/webp']` | Set `['image/avif', 'image/webp']` (AVIF ~20% smaller, slower first encode) |
| `images.minimumCacheTTL` | Default 4 h (was 60 s) | Fine |
| `imageSizes` | 16 removed. Default `[32, 48, 64, 96, 128, 256, 384]` | Fine |
| `placeholder="blur"` | Automatic `blurDataURL` for static imports | Adds inline base64 to the HTML. Use on the hero only if it helps |
| Static import | Content hash + `immutable` cache + intrinsic size | Prefer static imports for local images |

## 2. Hero (LCP) image

```tsx
// components/hero.tsx: Server Component
import Image from "next/image";
import hero from "@/public/hero.jpg"; // static import: hashed, sized, blur-ready

export function Hero() {
  return (
    <section className="relative isolate min-h-[70svh] overflow-clip">
      <Image
        src={hero}
        alt="Product dashboard showing weekly deploys"   // or alt="" if purely decorative
        fill
        sizes="100vw"
        loading="eager"
        fetchPriority="high"
        className="-z-10 object-cover"
      />
      <h1 className="text-display-xl text-balance">Ship on Fridays.</h1>
    </section>
  );
}
```

- Mark exactly **one** image per route as eager + high priority. More defeats the purpose.
- Mobile hero ≤ 150 KB transfer. If it's larger, crop tighter, lower `quality`, or art-direct (§4).
- `fill` requires a positioned parent with a size (`relative` + height or aspect ratio).
- Never set `opacity-0` on the hero waiting for an entrance animation.

Plain HTML equivalent:

```html
<img src="/hero-1200.avif"
     srcset="/hero-640.avif 640w, /hero-1200.avif 1200w, /hero-1920.avif 1920w"
     sizes="100vw" width="1920" height="1080"
     fetchpriority="high" decoding="async" alt="…" style="width:100%;height:auto">
```

## 3. Content images: writing `sizes`

`sizes` states the **rendered CSS width** at each breakpoint, from widest to narrowest:

| Layout | `sizes` |
|---|---|
| Full-bleed | `100vw` |
| Inside `max-w-page` (1200px) container with 24px gutters | `(min-width: 1248px) 1200px, calc(100vw - 48px)` |
| 3-col grid ≥1024, 2-col ≥640, else 1 | `(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw` |
| Prose column (65ch ≈ 680px) | `(min-width: 728px) 680px, calc(100vw - 48px)` |
| Fixed avatar 40px | no `sizes`; `width={40} height={40}` |

```tsx
<Image src={shot} alt="Settings page with the dark theme enabled" sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
       className="h-auto w-full rounded-lg" />
```

Id `perf/image-oversized` fires when a delivered image is much larger than its rendered size × DPR.

## 4. Art direction (different crop on mobile)

```tsx
import { getImageProps } from "next/image";

export function ArtDirectedHero() {
  const common = { alt: "Team at the launch event", sizes: "100vw" };
  const { props: { srcSet: desktop } } = getImageProps({ ...common, width: 1920, height: 1080, quality: 75, src: "/hero-wide.jpg" });
  const { props: { srcSet: mobile, ...rest } } = getImageProps({ ...common, width: 750, height: 1000, quality: 75, src: "/hero-tall.jpg", loading: "eager", fetchPriority: "high" });
  return (
    <picture>
      <source media="(min-width: 768px)" srcSet={desktop} />
      <source media="(max-width: 767px)" srcSet={mobile} />
      <img {...rest} className="h-auto w-full" />
    </picture>
  );
}
```

## 5. Video and embeds

```tsx
// Background/ambient video: never the LCP, never autoplays with sound
<video className="aspect-video w-full" poster="/demo-poster.avif" preload="none" muted loop playsInline
       aria-label="Demo: creating a project in 30 seconds" />
```
Start playback with an `IntersectionObserver` when in view, and pause it when out of view or under `prefers-reduced-motion: reduce`. Autoplaying motion longer than 5 s needs a visible pause control.

YouTube facade (saves ~500 KB+ of third-party JS until click):

```tsx
"use client";
import { useState } from "react";

export function YouTube({ id, title }: { id: string; title: string }) {
  const [on, setOn] = useState(false);
  return (
    <div className="relative aspect-video w-full overflow-hidden rounded-lg bg-surface">
      {on ? (
        <iframe className="absolute inset-0 size-full" src={`https://www.youtube-nocookie.com/embed/${id}?autoplay=1`}
                title={title} allow="autoplay; encrypted-media; picture-in-picture" allowFullScreen />
      ) : (
        <button type="button" onClick={() => setOn(true)} className="group absolute inset-0 size-full" aria-label={`Play video: ${title}`}>
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img src={`https://i.ytimg.com/vi/${id}/hqdefault.jpg`} alt="" loading="lazy" width={480} height={360} className="size-full object-cover" />
        </button>
      )}
    </div>
  );
}
```
Add `i.ytimg.com` to `images.remotePatterns` if you switch to `next/image`.

## 6. next/font

```ts
// app/fonts.ts
import { Geist, Geist_Mono, Instrument_Serif } from "next/font/google";

export const sans = Geist({ subsets: ["latin"], variable: "--font-geist", display: "swap" });
export const mono = Geist_Mono({ subsets: ["latin"], variable: "--font-geist-mono", display: "swap", preload: false });
export const display = Instrument_Serif({ subsets: ["latin"], weight: "400", variable: "--font-instrument", display: "swap" });
```

```tsx
// app/layout.tsx
import { sans, mono } from "./fonts";
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${sans.variable} ${mono.variable}`}>
      <body className="font-sans">{children}</body>
    </html>
  );
}
```

```css
/* app/globals.css: @theme inline is REQUIRED when tokens reference next/font variables */
@theme inline {
  --font-sans: var(--font-geist), ui-sans-serif, system-ui, sans-serif;
  --font-mono: var(--font-geist-mono), ui-monospace, "SFMono-Regular", Menlo, monospace;
  --font-display: var(--font-instrument), ui-serif, Georgia, serif;
}
```

Rules:
- **Variable fonts:** omit `weight` (or `weight: "variable"`) for one file. An array of weights produces N static files. Restricting the weight range saves nothing.
- **Axes cost bytes:** Inter +opsz went 47 → 71 KB. Never add `axes` without measuring.
- **Route-scope** display or editor fonts: call the loader in the layout of the route that uses them, so other routes don't preload them.
- `preload: false` for mono, italics and decorative faces. Preload at most 2 files.
- `display: "swap"` (default) for site text. `"optional"` is valid for body text when you'd rather skip the swap than shift.
- **Monospace with `next/font/local`:** set `adjustFontFallback: false` and supply a `ui-monospace` fallback. The default Arial fallback scales to ~131% and code renders oversized until the swap.
- Measured latin WOFF2: Geist 28.7 KB, Geist Mono 22.6, Inter 47.1, JetBrains Mono 39.5, Space Grotesk 21.8, Instrument Serif 20.5.
- License traps: Fontshare faces (Satoshi, General Sans, Cabinet, Clash) forbid subsetting; Berkeley Mono forbids IDE/terminal and OSS use; SF Mono can't be redistributed.

Local font:

```ts
import localFont from "next/font/local";
export const brand = localFont({
  src: [{ path: "./BrandVF.woff2", style: "normal" }],
  variable: "--font-brand",
  display: "swap",
  declarations: [{ prop: "font-feature-settings", value: "'ss01' on" }],
});
```

## 7. Fonts outside Next (Astro, Vite, plain HTML)

```html
<link rel="preload" href="/fonts/brand-latin.woff2" as="font" type="font/woff2" crossorigin>
```

```css
@font-face {
  font-family: "Brand";
  src: url("/fonts/brand-latin.woff2") format("woff2");
  font-weight: 100 900;                /* variable range */
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {                            /* metric-matched fallback: kills the swap shift */
  font-family: "Brand-fallback";
  src: local("Arial");
  size-adjust: 104%; ascent-override: 92%; descent-override: 24%; line-gap-override: 0%;
}
:root { --font-sans: "Brand", "Brand-fallback", system-ui, sans-serif; }
```

Generate the override numbers with Fontaine or Capsize rather than guessing. `size-adjust` works everywhere. The `*-override` descriptors are Chromium and Firefox only (Safari ignores them).

## 8. Budgets recap

| Item | Budget | Id |
|---|---|---|
| Families per route | ≤ 2 (+ optional mono) | `perf/too-many-fonts` |
| Font files per route | ≤ 4, ≤ 2 preloaded | `perf/too-many-fonts` |
| Each latin WOFF2 | ≤ 50 KB | `perf/font-budget` |
| Total font transfer | ≤ 120 KB | `perf/font-budget` |
| LCP image (mobile) | ≤ 150 KB | `perf/image-budget` |
| Any image | ≤ 1.5× rendered width × DPR | `perf/image-oversized` |
| Public assets | nothing > 500 KB; delete unreferenced files | — |

## Sources

- https://nextjs.org/docs/app/api-reference/components/image
- https://nextjs.org/docs/app/api-reference/components/font
- https://web.dev/articles/optimize-lcp · https://web.dev/articles/css-size-adjust
- https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#sizes


---

<!-- https://void-design.vercel.app/docs/speed/next-performance · updated 2026-09-16 -->

# Next.js 16 App Router performance: specifics

Verified against Next 16.3.5, React 19.2, Tailwind 4.3.3 (production builds measured 2026-09-16).
The canonical config is `templates/next/next.config.ts`.

## 1. Baseline facts

| Fact | Value |
|---|---|
| Hello-world App Router first-load JS | **133 KB gzip / 114 KB brotli** (6 chunks; plus a 39 KB `noModule` polyfill that modern browsers skip) |
| + 3 lucide icons in a Server Component | +1 KB |
| + `LazyMotion` + `m` + `domAnimation` | +32 KB |
| + full `motion.div` | +46 KB |
| Default Tailwind v4 CSS for the starter page | 4.1 KB gzip / 3.6 KB brotli |
| `next build` output | **no "First Load JS" column any more.** Measure it yourself (§4) |
| `next start` compression | **gzip only**, even when `br`/`zstd` are accepted |
| Browser targets | Chrome/Edge/Firefox 111+, Safari 16.4+ |

## 2. Component boundaries

```tsx
// app/page.tsx: Server Component (no directive)
import { Hero } from "@/components/hero";          // server
import { Pricing } from "@/components/pricing";    // server
import { BillingToggle } from "@/components/billing-toggle"; // 'use client' leaf

export default function Page() {
  return (
    <>
      <Hero />
      <Pricing toggle={<BillingToggle />} />
    </>
  );
}
```

```tsx
// components/disclosure-shell.tsx: client wrapper that receives server children
"use client";
import { useState, type ReactNode } from "react";

export function DisclosureShell({ label, children }: { label: string; children: ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button type="button" aria-expanded={open} onClick={() => setOpen((o) => !o)}>{label}</button>
      <div hidden={!open}>{children /* rendered on the server, zero client JS for this subtree */}</div>
    </div>
  );
}
```

Rules:
- `'use client'` marks a **module boundary**. Everything it imports becomes client code, including Server Components. Pass those as `children` or props instead.
- Never put `'use client'` in `page.tsx` or `layout.tsx`. Extract the interactive part.
- Props that cross the boundary are serialized into the RSC payload (`self.__next_f.push`) **in addition to** the HTML. Pass `{ title, href }`, not the whole post.
- Heavy data→UI work (Markdown, Shiki highlighting with **one** theme, static SVG charts) runs in Server Components. Measured: docs route 448 KB → 115 KB first-load JS after moving Shiki to build time.

## 3. Streaming, caching, static

```ts
// next.config.ts (excerpt)
import type { NextConfig } from "next";
const config: NextConfig = {
  cacheComponents: true,        // 'use cache' + static shell with dynamic holes (PPR)
  reactCompiler: true,          // stable in 16, not default; auto-memoizes (needs babel-plugin-react-compiler)
  images: { formats: ["image/avif", "image/webp"] },
  experimental: {
    optimizePackageImports: ["@radix-ui/react-icons"], // lucide-react, date-fns, lodash-es, @headlessui/react, recharts, react-icons/* are already optimized by default
  },
  // compress: false,           // only when a CDN/proxy compresses with brotli/zstd
};
export default config;
```

- Keep marketing routes **static** (`○ (Static)` in the build output). Anything that reads `cookies()`, `headers()`, `connection()` or uncached fetches makes the route dynamic.
- Cached data: `'use cache'` at the top of an async function or component (requires `cacheComponents: true`), with `cacheLife`/`cacheTag` for revalidation.
- `<Suspense fallback={<Skeleton className="h-[420px]" />}>` around **secondary** slow content only. Never around the hero or h1. Skeleton height = final height (±8 px).
- Resolve existence (`notFound()`, `redirect()`) **before** any Suspense boundary. Inside a streamed boundary the status is already 200 (see the `seo` skill).
- `loading.tsx` wraps the whole page in Suspense. Use it for app routes, not for marketing pages whose content is static.
- Cache Components plus `'use cache'` inside `generateMetadata` keeps metadata in `<head>` (see the `seo` skill).

## 4. Measure first-load JS (Next 16 removed it from build output)

Run after `next build`. It sums the non-`noModule` scripts referenced by each prerendered HTML file.

```ts
// scripts/firstload.ts: bun scripts/firstload.ts (or node --experimental-strip-types)
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { gzipSync, brotliCompressSync } from "node:zlib";

const root = ".next/server/app";
const walk = (d: string): string[] =>
  readdirSync(d).flatMap((f) => (statSync(join(d, f)).isDirectory() ? walk(join(d, f)) : [join(d, f)]));

for (const file of walk(root).filter((f) => f.endsWith(".html"))) {
  const html = readFileSync(file, "utf8");
  const js = [...html.matchAll(/<script([^>]*)src="\/_next\/(static\/[^"]+\.js)"([^>]*)>/g)]
    .filter((m) => !/nomodule/i.test(m[1] + m[3]))
    .map((m) => m[2]);
  const css = [...html.matchAll(/<link[^>]+rel="stylesheet"[^>]+href="\/_next\/(static\/[^"]+\.css)"/g)].map((m) => m[1]);
  const size = (files: string[], f: (b: Buffer) => Buffer) =>
    Math.round(files.reduce((s, p) => s + f(readFileSync(join(".next", p))).length, 0) / 1024);
  const rsc = [...html.matchAll(/self\.__next_f\.push\((.*?)\)<\/script>/gs)].reduce((s, m) => s + m[1].length, 0);
  console.log(
    file.replace(root, "") || "/",
    `js ${size([...new Set(js)], (b) => gzipSync(b, { level: 9 }))} KB gz`,
    `css ${size([...new Set(css)], (b) => brotliCompressSync(b))} KB br`,
    `html ${Math.round(brotliCompressSync(Buffer.from(html)).length / 1024)} KB br`,
    `rsc ${Math.round((rsc / html.length) * 100)}% of html`,
  );
}
```

Targets: js ≤ 170 KB (marketing), css ≤ 25 KB, html ≤ 30 KB, rsc ≤ 50%. For dynamic routes, fetch the HTML from `next start` and apply the same extraction. `void perf` reports the measured transfer as `perf/js-budget`.

Explore what's in a chunk: `next experimental-analyze` (Turbopack, Next ≥ 16.1; `--output` writes to `.next/diagnostics/analyze`). `@next/bundle-analyzer` works only with `next build --webpack`.

## 5. Motion without the weight

Prefer CSS (transitions, `@starting-style`, keyframes, View Transitions). Use Motion only for gestures, layout animations or exit orchestration.

```tsx
// components/motion-provider.tsx
"use client";
import { LazyMotion } from "motion/react";
import type { ReactNode } from "react";

const loadFeatures = () => import("./motion-features").then((m) => m.default);

export function MotionProvider({ children }: { children: ReactNode }) {
  return <LazyMotion features={loadFeatures} strict>{children}</LazyMotion>;
}
```

```ts
// components/motion-features.ts
import { domAnimation } from "motion/react"; // domMax (+~10 KB) only if you need drag or layout animations
export default domAnimation;
```

```tsx
// any client leaf
"use client";
import * as m from "motion/react-m";
export const FadeIn = (p: { children: React.ReactNode }) => (
  <m.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}>{p.children}</m.div>
);
```

`strict` throws if someone uses `motion.div` inside the provider. Never use this for the LCP element. Add `<MotionConfig reducedMotion="user">` at the same level.

## 6. Deferring heavy widgets

```tsx
// components/map-slot.tsx
"use client";                                   // ssr:false is only allowed in client components
import dynamic from "next/dynamic";
import { useEffect, useRef, useState } from "react";

const Map = dynamic(() => import("./map"), { ssr: false, loading: () => null });

export function MapSlot() {
  const ref = useRef<HTMLDivElement>(null);
  const [show, setShow] = useState(false);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(([e]) => e.isIntersecting && (setShow(true), io.disconnect()), { rootMargin: "200px" });
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return <div ref={ref} className="aspect-[16/9] w-full bg-surface">{show && <Map />}</div>;
}
```

Candidates: maps, code editors (Monaco ~2 MB), terminals, Sandpack, Mermaid, 3D, carousels below the fold.

## 7. Third-party scripts

```tsx
import Script from "next/script";
// app/layout.tsx, inside <body>
<Script src="https://analytics.example.com/script.js" strategy="lazyOnload" data-site="…" />
```

| Strategy | Use for |
|---|---|
| `beforeInteractive` | consent manager or bot detection only (root layout) |
| `afterInteractive` (default) | analytics that must measure the first view |
| `lazyOnload` | everything else: chat, social, heatmaps, A/B tags |
| `worker` | **does not work with App Router**. Don't use |

Facade for chat: render a styled `<button>` that imports and boots the widget on click. JSON-LD uses a native `<script type="application/ld+json">`, not `next/script`.

## 8. Navigation and caching

- `<Link>` prefetches in production when links enter the viewport. Don't add Speculation Rules `prerender` to App Router sites.
- Next 16 removed automatic `scroll-behavior: smooth`. Opt in with `<html data-scroll-behavior="smooth">`, and still disable it under reduced motion.
- Headers for non-Next static paths:

```ts
// next.config.ts
async headers() {
  return [
    { source: "/fonts/:path*", headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }] }, // only if file names are hashed
    { source: "/docs/:path*", headers: [{ key: "Cache-Control", value: "public, s-maxage=3600, stale-while-revalidate=86400" }] },
  ];
},
```

- `output: "standalone"`: copy `public/` and `.next/static/` into the image yourself.
- Set `turbopack.root` / `outputFileTracingRoot` when a stray lockfile higher up the tree confuses root detection.

## 9. Package hygiene

- One animation runtime, one icon set, one carousel (`embla-carousel` rather than `swiper`).
- Codemod `framer-motion` imports to `motion/react`. Delete duplicates like `xterm` + `@xterm/xterm`.
- Your own packages: per-component `exports`, `"sideEffects": ["*.css"]`, no internal barrel files in client code.
- A dependency diet measured on a real portfolio: 21 → 9 deps, first-load JS 225 → 115 KB, build 47 s → 4.5 s.

## Sources

- https://nextjs.org/blog/next-16 · https://nextjs.org/blog/next-16-3
- https://nextjs.org/docs/app/guides/package-bundling
- https://nextjs.org/docs/app/api-reference/config/next-config-js/optimizePackageImports
- https://nextjs.org/docs/app/api-reference/components/script
- https://nextjs.org/docs/app/api-reference/components/link
- https://motion.dev/docs/react-reduce-bundle-size
- https://react.dev/reference/react/useTransition


---

<!-- https://void-design.vercel.app/docs/speed/react-pitfalls · updated 2026-09-16 -->

# React pitfalls that cost speed or correctness

Every rule below comes from a real bug in shipped apps or vendored registry components (commit hashes in the void research notes). React 19.2, Next 16.

## 1. Nothing with side effects in the render body

Render runs on the server, and it may run many times on the client. Subscriptions, timers and DOM access in render leak listeners, crash SSR, or cause hydration mismatches.

| Bug seen in the wild | Fix |
|---|---|
| `scrollY.on("change", …)` in the component body (adds a listener on every render) | `useMotionValueEvent(scrollY, "change", cb)` or `useEffect` with the unsubscribe returned |
| `setInterval(…)` in render (stale-closure slideshow; a terminal refocused its input 10×/s and stole clicks) | `useEffect` + `clearInterval` in cleanup. Blinking carets in CSS |
| `m.create(Component)` / `styled(...)` / `const Inner = () => …` inside render (remounts and replays the animation every render) | Define at module scope, or `useMemo(() => m.create(C), [])` |
| `useTransform(...)` inside a `style={{}}` literal | Hooks at the top level only |
| `createPortal(x, document.body)` in render | Portal after mount (§3) or use the primitive's `Portal` |
| `navigator.userAgent` at **module scope** | Read it inside an effect or `useSyncExternalStore` |
| `navigator.platform` in render to show ⌘ vs Ctrl (showed ⌘K on Linux) | `useSyncExternalStore` with a server snapshot (§3) |

```tsx
// ❌
function Header() {
  const { scrollY } = useScroll();
  scrollY.on("change", (y) => setScrolled(y > 8));   // leaks + setState per frame
}
// ✅
"use client";
import { useScroll, useMotionValueEvent } from "motion/react";
function Header() {
  const { scrollY } = useScroll();
  const ref = useRef<HTMLElement>(null);
  useMotionValueEvent(scrollY, "change", (y) => ref.current?.toggleAttribute("data-scrolled", y > 8)); // DOM write, no re-render
  return <header ref={ref} className="data-[scrolled]:shadow-1">…</header>;
}
```

## 2. Effects: always clean up

```tsx
useEffect(() => {
  const id = setInterval(tick, 1000);
  const onKey = (e: KeyboardEvent) => { /* … */ };
  document.addEventListener("keydown", onKey);
  const io = new IntersectionObserver(cb);
  io.observe(el);
  const ctrl = new AbortController();
  fetch(url, { signal: ctrl.signal }).then(/* … */).catch(() => {});
  let raf = requestAnimationFrame(loop);
  return () => {
    clearInterval(id);
    document.removeEventListener("keydown", onKey);
    io.disconnect();
    ctrl.abort();
    cancelAnimationFrame(raf);
    // library instances: carousel.destroy(), lenis?.destroy(), api.off("select", cb), editor.dispose()
  };
}, [/* every value read inside */]);
```

- **Exhaustive deps.** A handler that reads `collapsed` without listing it is a stale-closure bug. If re-subscribing is expensive, read the latest value from a ref updated in an effect, or use `useEffectEvent` (React 19.2).
- **Never put `ref.current` in a dependency array.** It isn't reactive and hides bugs.
- **Ref-backed props never go in effect deps.** A GL backdrop destroyed and rebuilt its context whenever `amplitude` changed, though it read the value from a ref.
- One `keydown` listener on `document` for all shortcuts, not one per row (20 listeners were found in a list).
- No `useEffect(() => fetch…)` for page data in App Router. Fetch in the Server Component.

## 3. Browser-only values: `useSyncExternalStore`

Gives the server and hydration a stable snapshot, then the real value, with no mismatch warning and no extra `useEffect` + `useState` render.

```tsx
"use client";
import { useSyncExternalStore } from "react";

/** Reactive media query. Server snapshot = fallback. */
export function useMediaQuery(query: string, serverFallback = false) {
  return useSyncExternalStore(
    (onChange) => {
      const mql = matchMedia(query);
      mql.addEventListener("change", onChange);
      return () => mql.removeEventListener("change", onChange);
    },
    () => matchMedia(query).matches,
    () => serverFallback,
  );
}

export const usePrefersReducedMotion = () => useMediaQuery("(prefers-reduced-motion: reduce)", false);
export const useCanHover = () => useMediaQuery("(hover: hover) and (pointer: fine)", false);

/** Platform modifier key label. "Ctrl" on the server, "⌘" after hydration on Apple devices. */
const noopSubscribe = () => () => {};
export function useModKey() {
  return useSyncExternalStore(
    noopSubscribe,
    () => (/Mac|iPhone|iPad/.test(navigator.userAgent) ? "⌘" : "Ctrl"),
    () => "Ctrl",
  );
}

/** true only after hydration (for portals and client-only widgets) */
export function useMounted() {
  return useSyncExternalStore(noopSubscribe, () => true, () => false);
}
```

- Use `matchMedia` listeners, not `resize` + `innerWidth` in state. One resize tick fired 4 setStates in a real app.
- Don't attach both a `ResizeObserver` and a window `resize` listener to the same `fit()`. That's double layout.
- Layout that depends on viewport size belongs in CSS (container queries, `clamp()`), not JS.
- Theme (light/dark) is set by a blocking inline script in `<head>` and read from CSS, not from React state (see the `craft` skill).

## 4. Per-frame values never go through React state

| Bug | Cost | Fix |
|---|---|---|
| Spotlight effect: `setState({x, y})` on every `mousemove` | Full re-render ~60–120×/s | Write a CSS variable on the element: `el.style.setProperty("--x", x + "px")` |
| Scramble text: `setState` per letter per rAF | Main-thread churn | Mutate `textContent` in rAF, fixed-size box, `sr-only` real text |
| `setInterval` → `setState` → spring on `height` | Layout every frame | Transform-only animation, or CSS |
| NumberTicker creating `Intl.NumberFormat` every frame | GC pressure | Hoist the formatter; skip no-op writes |

```tsx
"use client";
import { useRef } from "react";
export function Spotlight({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  return (
    <div
      ref={ref}
      onPointerMove={(e) => {
        const r = e.currentTarget.getBoundingClientRect();
        e.currentTarget.style.setProperty("--x", `${e.clientX - r.left}px`);
        e.currentTarget.style.setProperty("--y", `${e.clientY - r.top}px`);
      }}
      className="relative [background:radial-gradient(400px_circle_at_var(--x)_var(--y),var(--brand-subtle),transparent_60%)]"
    >
      {children}
    </div>
  );
}
```

Set the variable on the element itself, not on a parent with many children. Updating a variable on a parent recalculates styles for the whole subtree. During drags, set `transform` directly.

## 5. Keep interactions under 200 ms

```tsx
"use client";
import { useState, useTransition, useDeferredValue } from "react";
import { yieldToMain } from "@/lib/yield";

export function Filters({ items }: { items: Item[] }) {
  const [tab, setTab] = useState("all");
  const [isPending, startTransition] = useTransition();
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);          // typing stays instant; the list catches up

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Filter" />
      <button
        type="button"
        aria-pressed={tab === "active"}
        onClick={() => startTransition(() => setTab("active"))}   // non-urgent: interruptible
      >Active</button>
      <List items={items} tab={tab} query={deferredQuery} dim={isPending} />
    </>
  );
}

async function onSave(e: React.MouseEvent<HTMLButtonElement>) {
  e.currentTarget.dataset.state = "saving";   // 1. visual acknowledgement now
  await yieldToMain();                        // 2. let the browser paint
  track("save");                              // 3. analytics and heavy work after the paint
}
```

- State set after an `await` inside `startTransition` needs its own `startTransition` wrapper.
- Coalesce document-sized work triggered by typing (diffs, search indexes, socket emits) into a ~300 ms idle window.
- `React.memo` only helps with stable props. `editor={{…}}` object literals or unmemoized callbacks from custom hooks defeat it. Or enable the React Compiler.
- No write-only state (state that is set but never rendered). It's a re-render for nothing. Use a ref.
- Use `<Activity mode="hidden">` (React 19.2) for tab panels that should keep their state without re-mounting.

## 6. Server/client module hygiene

- Importing a Server Component into a `'use client'` file bundles it for the client. Compose from a server parent instead.
- Guard browser-only modules: `import "client-only"` in files that touch `window`. Use `import "server-only"` in files with secrets or database access.
- Declare every import in `package.json`. No transitive-dependency imports (`xterm` used while `@xterm/xterm` was declared).
- Scope Motion `layoutId`s with `useId()`, or two instances of the same component fight over the id.
- Normalize API shapes at the boundary: a Go `nil` slice arrives as `null` and crashes `.map`.
- Redirects happen on the server (`redirect()`, `next.config` redirects), never with `router.push` in a client page.
- Internal links use `next/link`. A bare `<a href="/x">` causes a full page reload.
- Toasts are for mutations only, not for navigation or selection. No `window.prompt`/`confirm`.

## 7. Global keyboard shortcuts

```ts
export function isTypingTarget(t: EventTarget | null) {
  const el = t as HTMLElement | null;
  return !!el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || !!el.closest("[role=textbox], .monaco-editor, .xterm"));
}
useEffect(() => {
  const onKey = (e: KeyboardEvent) => {
    if (e.defaultPrevented || isTypingTarget(e.target)) return;
    if (e.key === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); openPalette(); }
  };
  document.addEventListener("keydown", onKey);
  return () => document.removeEventListener("keydown", onKey);
}, [openPalette]);
```

One binding per chord across the app (Ctrl+K once opened two dialogs).

## 8. Lint checklist (what `void lint` and review should catch)

- [ ] No `window`/`document`/`navigator`/`localStorage` at module scope or in render
- [ ] No `.on(`/`addEventListener`/`setInterval`/`setTimeout` outside effects or handlers
- [ ] Every effect that subscribes returns a cleanup
- [ ] No `ref.current` in dependency arrays
- [ ] No component or `m.create` defined inside a component
- [ ] No `setState` in `scroll`/`pointermove`/rAF handlers
- [ ] No `'use client'` in `page.tsx`/`layout.tsx`
- [ ] rAF loops check visibility and are cancelled in cleanup

## Sources

- https://react.dev/reference/react/useSyncExternalStore
- https://react.dev/reference/react/useTransition · https://react.dev/reference/react/useDeferredValue
- https://react.dev/reference/react/useEffectEvent · https://react.dev/reference/react/Activity
- https://motion.dev/docs/react-use-motion-value-event
- https://nextjs.org/docs/app/getting-started/server-and-client-components


---

<!-- https://void-design.vercel.app/docs/speed/rendering-smoothness · updated 2026-09-16 -->

# Rendering smoothness: animation, scroll, view transitions, canvas

Support data from webstatus.dev (web-features/BCD), checked 2026-09-16. Choreography, durations and easing tokens are in the `motion` skill. This file covers the **performance** side.

## 1. What is cheap to animate

| Property | Cost | Use |
|---|---|---|
| `transform` (translate, scale, rotate), `opacity` | Compositor only | Everything |
| `filter`, `clip-path` | Paint-risky; sometimes composited in Chromium | Small elements, short durations |
| `background-color`, `color`, `border-color` | Paint | Small elements (buttons, links) |
| `box-shadow` | Paint, expensive on large areas | Cross-fade an `opacity` on a pseudo-element that holds the shadow instead |
| `backdrop-filter` | Very expensive over large or scrolling content | One sticky header at most. Never 8 stacked "progressive blur" layers; use `mask-image` |
| `width`, `height`, `top`, `left`, `margin`, `padding`, `font-size`, `grid-template-*` | **Layout every frame** + CLS | Never (exception: the `0fr → 1fr` accordion, §6) |

Animating `transform` never counts as a layout shift. Animating `top` does.

```css
/* Shadow lift without animating box-shadow */
.card { position: relative; transition: transform var(--duration-fast) var(--ease-out); }
.card::after { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: var(--shadow-3);
  opacity: 0; transition: opacity var(--duration-fast) var(--ease-out); pointer-events: none; }
@media (hover: hover) and (pointer: fine) {
  .card:hover { transform: translateY(-1px); }
  .card:hover::after { opacity: 1; }
}
```

### `will-change`
- It's a last resort. It creates a stacking context and a layer up front, which can break `z-index` and `position: fixed` children.
- Set it just before an animation and remove it afterwards. Never on `html`, `body` or large wrappers. At most 3 static uses per route.

```ts
el.style.willChange = "transform";
el.addEventListener("transitionend", () => { el.style.willChange = ""; }, { once: true });
```

### Containing-block traps
- A `transform`, `filter` or `backdrop-filter` on an ancestor makes `position: fixed` children position against that ancestor. **Portal overlays to `document.body`.** On page-transition wrappers use `animation-fill-mode: backwards` so the transform doesn't persist.
- `overflow-x: hidden` on `html`/`body` breaks `position: sticky`. Use `overflow-x: clip`.
- Full-height layouts: `min-h-svh`/`dvh`, not `100vh`.

## 2. Reduced motion (hard gate)

```css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 1ms !important;
    animation-delay: 0ms !important;          /* important with fill-mode both, or elements stay hidden */
    animation-iteration-count: 1 !important;
    transition-duration: 1ms !important;
    scroll-behavior: auto !important;
  }
  ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
}
```

Keyframes must **end** in the visible state so a 1 ms animation lands on a finished frame. JS: see `react-pitfalls.md` §3 for `usePrefersReducedMotion`. Motion: `<MotionConfig reducedMotion="user">`.

## 3. Scroll-linked effects without scroll listeners

### Reveal on enter (works everywhere)

```css
@media (prefers-reduced-motion: no-preference) {
  [data-reveal] { opacity: 0; transform: translateY(12px); transition: opacity 500ms var(--ease-out), transform 500ms var(--ease-out); }
  [data-reveal].is-in { opacity: 1; transform: none; }
}
```

```tsx
"use client";
import { useEffect } from "react";
/** Mount once in the root layout. Reveals [data-reveal] elements the first time they enter. */
export function RevealObserver() {
  useEffect(() => {
    const io = new IntersectionObserver((entries) => {
      for (const e of entries) if (e.isIntersecting) { e.target.classList.add("is-in"); io.unobserve(e.target); }
    }, { rootMargin: "0px 0px -10% 0px" });
    document.querySelectorAll("[data-reveal]").forEach((el) => io.observe(el));
    return () => io.disconnect();
  }, []);
  return null;
}
```

Limits: never on the hero or h1 (LCP). **Not on every section** (at most a few set pieces per page). Content must be readable with JS disabled: the `opacity: 0` rule only applies under `no-preference`, and crawlers read HTML, not pixels. If you need it to be JS-proof, gate the rule on a `.js` class that you set on `<html>`.

### Scroll-driven animations (progressive enhancement)

Support: Chrome/Edge 115+, Safari 26+, **Firefox: no** (flag only). Status "limited". Always wrap the rule in `@supports`, and the page must look complete without it.

```css
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .parallax-img {
      animation: drift linear both;
      animation-timeline: view();
      animation-range: entry 0% exit 100%;
    }
    @keyframes drift { from { transform: translateY(-6%) scale(1.06); } to { transform: translateY(6%) scale(1.06); } }

    .read-progress {
      position: fixed; inset: 0 0 auto 0; height: 2px; background: var(--brand); transform-origin: 0 50%;
      animation: grow linear both; animation-timeline: scroll(root);
    }
    @keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
  }
}
```

These run off the main thread in Chromium when they animate `transform`/`opacity`.

### If JS must read scroll

```ts
let ticking = false;
addEventListener("scroll", () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    const y = scrollY;                                  // one read per frame
    header.toggleAttribute("data-scrolled", y > 8);     // one write per frame, no React state
    ticking = false;
  });
}, { passive: true });
```

- `wheel`, `touchstart` and `touchmove` listeners are `{ passive: true }` unless you truly call `preventDefault()` (then attach to the element, never `window`).
- Use `matchMedia(...).addEventListener("change")` instead of `resize` + `innerWidth` state.
- Never call `getBoundingClientRect` inside a rAF loop. Measure in a `ResizeObserver` and cache the value.
- **No smooth-scroll libraries** (Lenis, locomotive). They make scrolling main-thread-bound, so any long task freezes scroll. No GSAP ScrollTrigger pinning.

## 4. View Transitions

| Feature | Chrome/Edge | Firefox | Safari | Status | Use |
|---|---|---|---|---|---|
| Same-document `document.startViewTransition()` | 111 | 144 | 18 | Baseline newly (2025-10) | Yes, as enhancement |
| `view-transition-class` | 125 | 144 | 18.2 | Baseline newly | Yes |
| `:active-view-transition` | 125 | 147 | 18.2 | Baseline newly (2026-01) | Yes |
| Cross-document `@view-transition { navigation: auto }` | 126 | ✗ | 18.2 | Limited | MPA/Astro only |
| Element-scoped view transitions | 147 | ✗ | ✗ | Experimental | No |

```ts
function withTransition(update: () => void) {
  if (!document.startViewTransition || matchMedia("(prefers-reduced-motion: reduce)").matches) return update();
  document.startViewTransition(update);
}
```

- React 19.2 `<ViewTransition>` + Next 16 `<Link transitionTypes={[...]}>` (16.2+) integrate this with navigations.
- Name ≤ 10 elements per transition (each becomes a captured layer). Page-level ≤ 400 ms, UI ≤ 250 ms.
- Theme toggles: disable transitions during the swap (see the `craft` skill). A circular-reveal view transition is an optional flourish, gated by reduced motion.

```css
/* MPA / Astro cross-document */
@view-transition { navigation: auto; }
```

## 5. Long pages: `content-visibility`

Baseline newly available (Chrome 108, Firefox 130, Safari 26).

```css
.section-offscreen { content-visibility: auto; contain-intrinsic-size: auto 900px; }
```

- Only on repeated sections **below the first viewport** on pages taller than 3 viewports, and on long lists. Never the LCP.
- `auto` in `contain-intrinsic-size` remembers the real size after first render, which prevents scrollbar jumps.
- Content stays in the accessibility tree and find-in-page. Reading layout (`getBoundingClientRect`) inside a skipped section cancels the benefit.
- DOM over ~1,400 nodes: virtualize lists over ~200 rows (`@tanstack/react-virtual`) and delete duplicate mobile/desktop markup.

## 6. Cheap patterns that replace JS

```css
/* Accordion height without animating height */
.acc-panel { display: grid; grid-template-rows: 0fr; transition: grid-template-rows var(--duration-base) var(--ease-out); }
.acc-panel[data-open="true"] { grid-template-rows: 1fr; }
.acc-panel > div { overflow: hidden; }

/* Popover/dialog entry with @starting-style (Chrome 117, Firefox 129, Safari 17.5) */
[popover], dialog {
  transition: opacity var(--duration-fast) var(--ease-out), transform var(--duration-fast) var(--ease-out),
              overlay var(--duration-fast) allow-discrete, display var(--duration-fast) allow-discrete;
  opacity: 0; transform: scale(0.96);
}
[popover]:popover-open, dialog[open] { opacity: 1; transform: none; }
@starting-style { [popover]:popover-open, dialog[open] { opacity: 0; transform: scale(0.96); } }
```

`interpolate-size: allow-keywords` (height to `auto`) is **Chromium-only**. Treat it as enhancement.

## 7. Infinite animations must pause offscreen

```css
.marquee-track { animation: marquee 30s linear infinite; }
[data-paused] .marquee-track { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) { .marquee-track { animation: none; } }
```

```tsx
"use client";
import { useEffect, useRef, type ReactNode } from "react";
/** Sets data-paused when offscreen or when the tab is hidden. Wrap any infinite CSS animation. */
export function PauseOffscreen({ children, className }: { children: ReactNode; className?: string }) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    let visible = true;
    const sync = () => el.toggleAttribute("data-paused", !visible || document.hidden);
    const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; sync(); });
    io.observe(el);
    document.addEventListener("visibilitychange", sync);
    return () => { io.disconnect(); document.removeEventListener("visibilitychange", sync); };
  }, []);
  return <div ref={ref} className={className}>{children}</div>;
}
```

Lists share one ticker (one `setInterval` for all "3 min ago" labels), not one per item. Spinners and carets animate in CSS, not with setState.

## 8. WebGL / canvas hook

```tsx
// components/shader-backdrop.tsx
"use client";
import { useEffect, useRef } from "react";

type Draw = (gl: WebGL2RenderingContext, timeMs: number) => void;
type Setup = (gl: WebGL2RenderingContext) => Draw;   // compile program, return a per-frame draw

export function useWebGLLoop(setup: Setup, { fps = 30, maxDpr = 1.5 } = {}) {
  const ref = useRef<HTMLCanvasElement>(null);
  useEffect(() => {
    const canvas = ref.current;
    if (!canvas) return;
    const gl = canvas.getContext("webgl2", { antialias: false, powerPreference: "low-power" });
    if (!gl) return;                                             // keep the CSS fallback background
    const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
    let draw = setup(gl);
    let raf = 0, last = 0, visible = true, lost = false;
    const interval = 1000 / fps;

    const resize = () => {
      const dpr = Math.min(devicePixelRatio || 1, maxDpr);
      const { width, height } = canvas.getBoundingClientRect(); // measured here, never inside the loop
      canvas.width = Math.round(width * dpr);
      canvas.height = Math.round(height * dpr);
      gl.viewport(0, 0, canvas.width, canvas.height);
      if (reduced) draw(gl, 0);                                  // redraw the static frame
    };
    const frame = (t: number) => {
      raf = requestAnimationFrame(frame);
      if (t - last < interval) return;
      last = t;
      draw(gl, t);
    };
    const start = () => { if (!raf && visible && !document.hidden && !lost && !reduced) raf = requestAnimationFrame(frame); };
    const stop = () => { cancelAnimationFrame(raf); raf = 0; };
    const onVisibility = () => (document.hidden ? stop() : start());
    const onLost = (e: Event) => { e.preventDefault(); lost = true; stop(); };
    const onRestored = () => { lost = false; draw = setup(gl); resize(); start(); };

    const ro = new ResizeObserver(resize);
    ro.observe(canvas);
    const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; visible ? start() : stop(); });
    io.observe(canvas);
    document.addEventListener("visibilitychange", onVisibility);
    canvas.addEventListener("webglcontextlost", onLost);
    canvas.addEventListener("webglcontextrestored", onRestored);
    resize();
    reduced ? draw(gl, 0) : start();

    return () => {
      stop();
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
      canvas.removeEventListener("webglcontextlost", onLost);
      canvas.removeEventListener("webglcontextrestored", onRestored);
      gl.getExtension("WEBGL_lose_context")?.loseContext();       // free the context slot
    };
  }, [setup, fps, maxDpr]);   // setup must be stable: define it at module scope or wrap it in useCallback
  return ref;
}

// Usage: decorative, fixed to the viewport, fallback color matches the shader average
export function ShaderBackdrop({ setup }: { setup: Setup }) {
  const ref = useWebGLLoop(setup, { fps: 30, maxDpr: 1 });
  return <canvas ref={ref} aria-hidden="true" className="pointer-events-none fixed inset-0 -z-10 size-full bg-bg-subtle" />;
}
```

Load it after first paint: `const ShaderBackdrop = dynamic(() => import("./shader-backdrop").then(m => m.ShaderBackdrop), { ssr: false })` from a client component, mounted in the **root layout** so navigation doesn't re-seed it.

More rules:
- No allocation inside the loop (`new Float32Array`, `Intl.NumberFormat`, color objects). Hoist them.
- Changing props updates uniforms through a ref. Don't put ref-backed values in effect deps (that destroys and rebuilds the context).
- Resolve CSS colors (`oklch()`, `var()`) to RGB **once**, in an effect, before passing them as uniforms.
- 2D canvas: use `ctx.setTransform(dpr, 0, 0, dpr, 0, 0)` on resize, never cumulative `ctx.scale`.
- Images drawn to a canvas must be same-origin (serve them via `/_next/image`) or the canvas is tainted.
- Status or loader canvases that convey meaning get `role="img"` + `aria-label`.
- Shader cost = pixels × ops × fps: 560 px at DPR 2 and 60 fps costs ~3.5× the same shader at DPR 1.5 and 30 fps.

## 9. Measuring smoothness

- `void smooth <url>`: scripted scroll (rAF frame times + Long Animation Frames), scripted interactions (INP), and `document.getAnimations()` for animated layout properties.
- Pass: 0 ms LoAF blocking time during scroll; ≤ 5% frames > 25 ms at 4× CPU; ≤ 2 `Layout` events/s while only ambient animations run.
- DevTools: Performance panel → "Layout shifts" and "Animations" tracks; Rendering → "Paint flashing" and "Layer borders".

## Sources

- https://web.dev/articles/animations-guide · https://developer.mozilla.org/en-US/docs/Web/CSS/will-change
- https://developer.chrome.com/docs/web-platform/view-transitions
- https://web.dev/articles/content-visibility
- https://developer.chrome.com/docs/web-platform/long-animation-frames
- https://api.webstatus.dev/v1/features/scroll-driven-animations · …/view-transitions · …/content-visibility · …/starting-style
- https://github.com/darkroomengineering/lenis (limitations section)


---

<!-- https://void-design.vercel.app/docs/seo · updated 2026-09-16 -->

# seo — findable by search engines, AI engines and agents

Classic SEO is the foundation of GEO. Nearly all measurable AI-search visibility comes from being indexed, snippet-eligible and having complete raw HTML.
Canonical implementations live in `templates/next/`: `src/site.config.ts` (facts), `src/content/pages.ts` (page registry), `src/lib/seo.ts` (`pageMetadata`), `src/lib/schema.ts` + `src/components/json-ld.tsx`, `src/app/{sitemap.ts,robots.ts,manifest.ts,opengraph-image.tsx}`, `src/app/llms.txt/route.ts`, `src/app/md/[...slug]/route.ts`, `next.config.ts` (`htmlLimitedBots`, `.md` rewrites). Copy from there. The references hold the same code for other projects.

## The 10 rules that matter most

1. **The raw HTML response is complete.** Main content in reading order, full `<head>`, JSON-LD and `<a href>` links, without running JavaScript. OpenAI, Anthropic, Perplexity and Meta crawlers don't execute JS.
2. **Exactly one `<title>`, `<meta name="description">` and absolute `<link rel="canonical">` in `<head>` of the raw HTML** on every indexable page. The canonical is self-referential and set per page, **never in the root layout**.
3. **Real HTTP status codes.** 200 for pages, 404 for unknown URLs (never a 200 "not found"), 301/308 for moves. In Next, call `notFound()` **before** any `<Suspense>`.
4. **Keep metadata static** (prerendered, or `'use cache'`). If a route must be request-time, add Googlebot and the AI bots to `htmlLimitedBots`, or Next streams title and canonical into `<body>`.
5. **One metadata helper** (`pageMetadata()`) for every page. Next merges metadata **shallowly**, so a page's `openGraph` silently drops the layout's `siteName`, `locale` and images.
6. **One `<h1>` per page** that matches the title's topic. Landmarks `<header>`, `<nav>`, `<main>`, `<footer>`; `<article>` for posts.
7. **Structured data describes only what is visible and true.** No invented ratings, reviews, prices or FAQs. One `@graph` with stable `@id`s. Escape `<` in JSON-LD.
8. **robots.txt separates training bots from retrieval bots.** Blocking `GPTBot` is a business choice. Blocking `OAI-SearchBot`, `Claude-SearchBot` or `PerplexityBot` removes you from AI answers. Previews send `Disallow: /`.
9. **Sitemap `lastModified` is the real content date**, never `new Date()` or the build time. Exclude noindex, auth and utility routes.
10. **Write to be quoted.** Answer first, then evidence: numbers with units and dates, links to primary sources, named quotes. Keep key facts visible, not hidden in tabs or images.

## The four readers (design for the weakest)

| Reader | Examples | Runs JS? | Needs |
|---|---|---|---|
| Rendering search crawler | Googlebot, Applebot | Yes, deferred | Raw `<head>` (canonical, robots) + rendered DOM |
| HTML-only index crawler | Bingbot (feeds Copilot and ChatGPT search), OAI-SearchBot, Claude-SearchBot, PerplexityBot | **No** | Everything in raw HTML |
| User-triggered fetcher | ChatGPT-User, Claude-User, Perplexity-User | No | Raw HTML; may ignore robots.txt |
| Agent / coding tool | Claude Code, Cursor, Codex | No | Clean HTML, `/llms.txt`, `.md` mirrors |

Test: `curl -sA "GPTBot/1.4" https://site/page | grep -c "<main"`. If the text isn't there, it doesn't exist for most AI search.

## Must-ship checklist per page type

| Page | Must emit |
|---|---|
| **Root layout** | `metadataBase`; `title: { default, template: "%s \| Brand" }`; description; `openGraph.siteName` + `locale`; `twitter.card: "summary_large_image"`; `robots.googleBot["max-image-preview"]: "large"`; `viewport.themeColor` (light/dark); `<html lang>`; landmarks; `<link rel="describedby" href="/llms.txt">`. **No canonical here.** |
| **Home** | `canonical: "/"`; Organization + WebSite in the `@graph` (on this page or emitted from the root layout); `opengraph-image`; one h1 naming what the product is and who it's for; first paragraph states it in 1–2 sentences |
| **Content page** (about, features, pricing) | `pageMetadata({ path, title, description })`; BreadcrumbList if nested; one h1; descriptive h2s; comparison `<table>` where relevant |
| **Article / blog post** | `pageMetadata({ type: "article", publishedTime, modifiedTime })`; BlogPosting + BreadcrumbList; visible byline + `<time dateTime>` "Updated" date matching JSON-LD; per-post `opengraph-image`; `.md` mirror; `generateStaticParams` + `dynamicParams = false` or `notFound()` before Suspense |
| **Product / app / pricing** | Product (buyable goods) or SoftwareApplication with the real, visible price. Add `aggregateRating` **only** when real ratings are shown on the page (without it SoftwareApplication is valid but not rich-result eligible). Prices in visible text |
| **Docs page** | TechArticle + BreadcrumbList; `.md` mirror advertised with `alternates.types["text/markdown"]`; listed in llms.txt; stable URLs; version in text |
| **Author page** | ProfilePage + Person with `sameAs`; linked from `Article.author.url` |
| **App / auth / search results** | `robots: { index: false, follow: true }`; not in sitemap, llms.txt or internal nav meant for crawlers; `/login` never canonicalizes to `/` |
| **Site-wide files** | `robots.ts` (preview guard + AI policy + `sitemap`), `sitemap.ts`, `llms.txt`, `manifest.ts`, `favicon.ico` **plus** `icon.png` (Google doesn't list SVG favicons), `apple-icon` 180×180, 404 page returning 404 |

## Metadata rules

- **Title:** unique, 10–60 chars, `Topic | Brand`. The brand name appears in every title; the home page uses an absolute tagline title (the helper does this for `path: "/"`). Don't put a plain string `title` in a nested `layout.tsx`: it resets the template for every child page.
- **Description:** unique, 50–160 chars, a summary sentence rather than a keyword list. Fill it on every page.
- **Canonical:** absolute (via `metadataBase`), no fragment, one URL form site-wide (https, one host, no trailing slash in Next's default). The same form in sitemap, internal links and hreflang. Never combine `noindex` with a cross-page canonical.
- **OG:** `og:title`, `og:type`, `og:image` (1200×630 PNG/JPEG, absolute, ≤ 8 MB, not SVG), `og:url` = canonical, `og:site_name`, `og:locale`. Use the file convention `opengraph-image.tsx` per section or post. It also fills `twitter:image`.
- **Viewport:** Next adds `width=device-width, initial-scale=1`. Never add `maximum-scale=1` or `user-scalable=no`.
- **`keywords` meta is useless.** Don't spend tokens on it.
- Don't set `nosnippet` or a low `max-snippet`; they also remove you from AI Overviews. Put `data-nosnippet` only on cookie banners and boilerplate.
- Uncompressed HTML < 1 MB (Googlebot stops at 2 MB, and the inlined RSC payload roughly doubles text weight).

Recipes: `references/next-metadata.md`.

## Next.js 16 traps (verified on 16.3.5)

| Trap | Symptom | Fix |
|---|---|---|
| **Streaming metadata.** A request-time `generateMetadata` (using `cookies()`, `headers()`, `connection()` or uncached fetches) streams `<title>`/canonical into `<body>` for every UA not in `htmlLimitedBots`. The default list has **no AI bots and no Googlebot**. Google only accepts canonical in `<head>`. Suspense content arrives in `<div hidden id="S:n">`, out of order | `curl -A GPTBot` shows `<link rel="canonical">` after `</head>` | Prefer static metadata (`generateStaticParams`, `'use cache'`). Otherwise extend `htmlLimitedBots` **and keep Next's default list in the regex** (setting it replaces the default). Recipe: `next-metadata.md` §3 |
| **`notFound()` inside Suspense → HTTP 200** + `<meta name="robots" content="noindex">` (soft 404) | Unknown slug returns 200 | Look up the entity at the top of `page.tsx`/`generateMetadata` and call `notFound()` before rendering any `<Suspense>`. Or `dynamicParams = false`. Same for `redirect()` |
| **Shallow merge.** A page's `openGraph` replaces the layout's whole `openGraph` (loses `siteName`, `locale`, `images`). A page's `alternates` drops the layout's `alternates.types` | `og:site_name` missing on subpages | Always build page metadata with `pageMetadata()`, which re-adds every field |
| **Async params.** `params`, `searchParams`, `generateSitemaps` `id` and `generateImageMetadata` `id` are Promises in 16 | Type errors or `undefined` | `const { slug } = await params` |
| **`middleware` → `proxy`.** The file is `proxy.ts` and exports `proxy` | Middleware silently not running | Rename |
| **`generateSitemaps` makes no index** | `/sitemap.xml` lists nothing for split sitemaps | Add a `sitemap-index.xml` route (`next-metadata.md` §6) |
| **`Vary: Accept` overwritten** on prerendered HTML | CDN serves Markdown to browsers when negotiating | Use explicit `.md` URLs, not `Accept` negotiation (`llms-txt-markdown.md`) |

## Structured data policy

- **Still yields Google rich results or knowledge features:** Organization, WebSite (site name), BreadcrumbList, Article/BlogPosting/NewsArticle, Product (snippet or merchant listing), SoftwareApplication (**requires `aggregateRating` or `review`**), ProfilePage, VideoObject, Event, QAPage/DiscussionForumPosting, Recipe, JobPosting, LocalBusiness.
- **Gone. Don't add for rich results:** FAQPage (not shown since 2026-05-07), HowTo (2023), sitelinks SearchAction (2024-11), plus ClaimReview, course info, estimated salary, vehicle listing, special announcement, practice problem.
- **Never fabricate.** Every `name`, `headline`, `price`, `ratingValue`, `author.name` must appear in the visible text. No self-serving reviews on your own Organization. "Only publish what exists."
- JSON-LD is server-rendered in raw HTML via a native `<script type="application/ld+json">` (not `next/script`), with `<` escaped as `\u003c`.
- Organization + WebSite: once per page graph, on the home page at minimum (the template emits them from the root layout). Other nodes reference `{"@id": "https://site/#organization"}`.
- Dates are ISO 8601 with a timezone. `dateModified ≥ datePublished`, matching the visible date.
- Types, required properties and a `@graph` builder: `references/structured-data.md`.

## robots.txt and AI policy

| Group | Tokens | Default |
|---|---|---|
| Search engines | `Googlebot`, `Bingbot`, `Applebot`, `DuckDuckBot` | Allow |
| AI **retrieval** (cites you in answers) | `OAI-SearchBot`, `Claude-SearchBot`, `PerplexityBot`, `DuckAssistBot`, `MistralAI-Index`, `Amzn-SearchBot`, `meta-webindexer` | **Allow** (blocking removes you from ChatGPT search, Claude and Perplexity answers) |
| AI **user fetchers** (a person asked) | `ChatGPT-User`, `Claude-User`, `Perplexity-User`, `MistralAI-User` | Allow (several ignore robots anyway) |
| AI **training** | `GPTBot`, `ClaudeBot`, `Google-Extended`, `Applebot-Extended`, `CCBot`, `meta-externalagent`, `Amazonbot`, `MistralAI-Training`, `Bytespider` | Owner's choice (`site.ai.training` in `site.config.ts`; template default: disallow). Blocking doesn't affect search or AI Overviews |

- A bot obeys **only its most specific group**. Repeat `Disallow: /api/` in every named group.
- Previews and staging: `Disallow: /` (keyed on `VERCEL_ENV` or equivalent). Production never.
- Don't disallow pages that carry `noindex`; crawlers must fetch them to see it. Don't block `/_next/`.
- `Google-Extended` doesn't remove you from AI Overviews. Only `noindex`/`nosnippet` does.
- Full crawler table + `robots.ts`: `references/ai-crawlers.md`.

## llms.txt and Markdown mirrors (cheap extras, `info` level)

- Ship `/llms.txt`: `# Brand`, a `> one-sentence summary`, curated `## Docs` / `## Blog` link lists pointing to `.md` URLs, and `## Optional`. It's under 100 KB and prerendered.
- It mainly serves **coding agents**, not answer engines: 97% of llms.txt files got zero requests in a 137K-domain study, and Google says it doesn't use them. Never claim it improves ranking. A 5xx is an error (Lighthouse fails it); a missing file is fine.
- Docs and posts: `page.md` mirror with `X-Robots-Tag: noindex` + `Link: <html-url>; rel="canonical"`, advertised with `<link rel="alternate" type="text/markdown">`. Not in the sitemap.
- Recipes: `references/llms-txt-markdown.md`.

## Content that gets cited

Evidence (KDD 2024 GEO study, NeurIPS 2025 C-SEO Bench, Microsoft and Google guidance): adding **citations, statistics and quotations** lifted AI visibility 30–40%, most for pages not ranked #1. Keyword stuffing and "authoritative tone" did nothing. AI engines favor third-party (earned) coverage.

1. **Answer first.** The first paragraph under the h1 and under each question-style h2 answers in 1–2 self-contained sentences (≤ 320 chars) that name the entity ("Acme is…", not "It is…").
2. **Evidence.** Numbers with units and dates ("cuts build time from 47 s to 4.5 s, measured Sept 2026"), links to primary sources, `<blockquote cite>` quotes from named people.
3. **Structure.** Descriptive h2/h3 (often questions users ask), `<ul>`/`<ol>` for steps, `<table>` for comparisons and specs, and paragraphs under ~150 words.
4. **Entity clarity.** The same brand and product name in title, h1, `og:site_name`, JSON-LD `name` and the llms.txt H1. A clear "what it is / who it's for / pricing / license" block.
5. **Freshness.** A visible "Updated" date that matches `dateModified` and sitemap `lastmod`. Update facts, not just dates.
6. **Visible.** Key facts are not only in images, video, PDFs, canvas, carousels or closed tabs. Use `<details open>` or plain sections.
7. **Non-commodity.** First-hand data, benchmarks, original examples, clear opinions. Near-duplicate pages get clustered and only one is used.

Don't: generate keyword-variant pages, rewrite content "for AI", chunk into micro-pages, add FAQPage for rich results, or fake mentions. Patterns and examples: `references/content-for-citation.md`.

## Verify

1. `void lint` (static): `lint/missing-metadata-base`, `lint/missing-metadata`, `lint/canonical-in-root-layout`, `lint/og-merge-drops-parent`, `lint/not-found-in-suspense`, `lint/dynamic-metadata-streaming`, `lint/page-level-use-client`, `lint/dangerously-set-jsonld-unescaped`, `lint/jsonld-deprecated-type`, `lint/sitemap-lastmod-now`, `lint/missing-sitemap`, `lint/missing-robots`, `lint/missing-og-image`, `lint/missing-icon`, `lint/missing-llms-txt`, `lint/missing-alt`, `lint/multiple-h1`.
2. `next build`, then `void seo --start "next start -p 3000" --port 3000 --format md`: status codes (`seo/http-status`, `seo/soft-404`), head placement per UA (`seo/metadata-in-body`), canonical (`seo/canonical-*`), titles/descriptions, h1, links, OG image fetch, icons, robots.txt, sitemap. Include an unknown URL in `--routes` (e.g. `/this-page-does-not-exist`) to prove 404s.
3. `void geo` (same flags): raw-vs-rendered content (`geo/js-dependency`, `geo/hidden-streamed-content`), JSON-LD (`geo/jsonld-parse-error`, `geo/jsonld-required`, `geo/jsonld-not-visible`, `geo/faq-deprecated`), AI robots (`geo/search-bots-blocked`, `geo/robots-ai-policy`), llms.txt (`geo/llms-txt-*`), Markdown mirrors (`geo/markdown-*`), content heuristics (`geo/answer-first`, `geo/no-citations`, info level).
4. **Gates must pass** (each caps the score at 49): `seo/http-status`, `seo/noindex`, `seo/canonical-missing`, `seo/canonical-invalid`, `seo/canonical-broken`, `seo/robots-blocks-all`, `seo/sitemap-url-status`, `geo/js-dependency`, `geo/jsonld-parse-error`. Targets: seo ≥ 95, geo ≥ 90. `info` GEO findings are review prompts, not failures.
5. Manual spot checks: `curl -sA "Googlebot/2.1" URL | head -c 3000`; Google Rich Results Test for new types; Search Console and Bing Webmaster Tools "AI Performance" after launch. Details: the `audit` skill.


---

<!-- https://void-design.vercel.app/docs/seo/ai-crawlers · updated 2026-09-16 -->

# AI crawlers, fetchers and robots.txt

As of 2026-09-16, from each operator's documentation. Re-check tokens yearly, since operators add and rename bots (e.g. `Google-NotebookLM` → `Google-GeminiNotebook` in 2026-08).

## 1. Crawler table

| Token (robots.txt) | Operator | Purpose | Obeys robots.txt | Runs JS |
|---|---|---|---|---|
| `Googlebot` | Google | Search, **including AI Overviews and AI Mode** | Yes | **Yes** (deferred) |
| `Google-Extended` | Google | *Control token, no crawler.* Gemini training + Gemini/Vertex grounding. Doesn't affect Search or AI Overviews | Yes | n/a |
| `Google-Agent`, `Google-GeminiNotebook`, `Google-Pinpoint` | Google | User-triggered fetchers/agents | Generally ignore | Chrome-based |
| `Bingbot` | Microsoft | Bing index → Copilot, Bing AI answers, third-party grounding (incl. ChatGPT's search provider) | Yes | Assume no |
| `OAI-SearchBot` | OpenAI | **ChatGPT search index.** Opted-out sites aren't shown in ChatGPT search answers (only as navigational links). ~24 h to apply | Yes | No |
| `ChatGPT-User` | OpenAI | User-triggered fetch in ChatGPT / GPTs | "may not apply" | No |
| `GPTBot` | OpenAI | **Training** | Yes | No |
| `Claude-SearchBot` | Anthropic | **Search** indexing for Claude | Yes (+ `Crawl-delay`) | No |
| `Claude-User` | Anthropic | User-triggered fetch | Yes | No |
| `ClaudeBot` | Anthropic | **Training** | Yes (+ `Crawl-delay`) | No |
| `Claude-Code` (UA only) | Anthropic | Coding-agent fetches (a top llms.txt requester) | – | No |
| `PerplexityBot` | Perplexity | **Search** index (not training) | Yes | No |
| `Perplexity-User` | Perplexity | User-triggered | Generally ignores | No |
| `Applebot` | Apple | Spotlight, Siri, Safari search | Yes | May render |
| `Applebot-Extended` | Apple | *Control token.* Training opt-out | Yes | n/a |
| `meta-webindexer` | Meta | Meta AI search | Yes | No |
| `meta-externalagent` | Meta | Training / product indexing | Yes | No |
| `meta-externalfetcher` | Meta | User-triggered | May bypass | No |
| `facebookexternalhit` | Meta | Link previews (reads OG tags) | May bypass | No |
| `Amzn-SearchBot` / `Amzn-User` | Amazon | Search (Alexa, Rufus) / user fetch; not training | Yes / – | – |
| `Amazonbot` | Amazon | Products and services, **may train** (honors `noarchive`) | Yes (no `Crawl-delay`) | – |
| `DuckAssistBot` | DuckDuckGo | Real-time AI answers; not training | Yes | – |
| `MistralAI-Index` / `MistralAI-User` / `MistralAI-Training` | Mistral | Search / user / training | Training: yes | – |
| `CCBot` | Common Crawl | Open corpus widely used for **training** | Yes | – |
| `Bytespider` | ByteDance | Training | Reportedly ignores | No |

Where answers come from:
- **Google AI Overviews / AI Mode:** the Google index (query fan-out). No special files or markup. Must be indexed and snippet-eligible.
- **ChatGPT search:** OAI-SearchBot + a third-party search provider (Bing). Allow OAI-SearchBot and be in Bing.
- **Claude web search:** Anthropic lists **Brave Search** as a web-search subprocessor (reported by secondary sources), so being indexed in Brave likely matters (check `site:` on search.brave.com).
- **Perplexity:** its own index (PerplexityBot) + live fetch (Perplexity-User).
- **Copilot:** Bing index. Bing uses IndexNow for freshness.

Measured behavior (Vercel network, 2024-12): AI crawlers fetch JS files **but don't execute them**. ChatGPT spent 34.8% and Claude 34.2% of fetches on 404s, and ChatGPT 14.4% on redirects. So fix broken internal links and redirect chains, and link straight to final URLs.

## 2. Policy presets

| Intent | `site.ai` in `site.config.ts` | Search engines | Retrieval + user fetchers | Training |
|---|---|---|---|---|
| Fully open | `{ retrieval: true, training: true }` | allow | allow | allow |
| Training opt-out (template default) | `{ retrieval: true, training: false }` | allow | allow | **disallow** |
| Block all AI | `{ retrieval: false, training: false }` | allow | disallow | disallow |

Blocking retrieval removes you from ChatGPT search, Claude and Perplexity answers. Only choose it deliberately. Google AI Overviews can't be opted out of via robots without leaving Search (use `nosnippet`/`noindex`).

## 3. `src/app/robots.ts`

Same as `templates/next/src/app/robots.ts`. The policy comes from `site.ai` and `site.privatePaths` in `site.config.ts`.

```ts
import type { MetadataRoute } from "next";
import { site } from "@/site.config";

/** Assistants fetching pages to answer users. Blocking these removes you from AI answers. */
const RETRIEVAL = ["OAI-SearchBot", "ChatGPT-User", "Claude-SearchBot", "Claude-User", "PerplexityBot", "Perplexity-User", "DuckAssistBot", "MistralAI-User"];
/** Model-training crawlers. Governed by site.ai.training. */
const TRAINING = ["GPTBot", "ClaudeBot", "Google-Extended", "Applebot-Extended", "CCBot", "meta-externalagent", "Amazonbot", "Bytespider", "MistralAI-Training"];

export default function robots(): MetadataRoute.Robots {
  if (process.env.VERCEL_ENV && process.env.VERCEL_ENV !== "production") {
    return { rules: [{ userAgent: "*", disallow: "/" }] };      // previews are never indexable
  }
  const disallow = [...site.privatePaths];                        // don't list noindex pages: crawlers must see the noindex
  return {
    rules: [
      { userAgent: "*", allow: "/", disallow },
      site.ai.retrieval ? { userAgent: RETRIEVAL, allow: "/", disallow } : { userAgent: RETRIEVAL, disallow: "/" },
      site.ai.training ? { userAgent: TRAINING, allow: "/", disallow } : { userAgent: TRAINING, disallow: "/" },
    ],
    sitemap: `${site.url}/sitemap.xml`,
  };
}
```

Not on Vercel? Replace the `VERCEL_ENV` check with your platform's variable (e.g. `CONTEXT !== "production"` on Netlify, or a custom `DEPLOY_ENV`).

Output (retrieval allowed, training disallowed):

```
User-Agent: *
Allow: /
Disallow: /api/

User-Agent: OAI-SearchBot
User-Agent: ChatGPT-User
…
Allow: /
Disallow: /api/

User-Agent: GPTBot
User-Agent: ClaudeBot
…
Disallow: /

Sitemap: https://acme.example/sitemap.xml
```

A crawler obeys **only the most specific group matching its token**. A bot named in a group ignores `*`, so repeat the private paths in every named group.

## 4. Variant with comments and `Content-Signal` (route handler)

`robots.ts` can't emit comments or non-standard lines. Use a route handler when you want Cloudflare's `Content-Signal` (`search`, `ai-input`, `ai-train`) or human-readable notes. Don't have both `app/robots.ts` and `app/robots.txt/route.ts`.

```ts
// app/robots.txt/route.ts
import { site } from "@/site.config";
export const dynamic = "force-static";

export function GET() {
  const preview = process.env.VERCEL_ENV && process.env.VERCEL_ENV !== "production";
  const body = preview
    ? "User-agent: *\nDisallow: /\n"
    : [
        `# ${site.name}: AI policy. Search and answer engines welcome; training ${site.ai.training ? "allowed" : "not allowed"}.`,
        "User-agent: *",
        `Content-Signal: search=yes, ai-input=yes, ai-train=${site.ai.training ? "yes" : "no"}`, // inside the group, as Cloudflare emits it
        "Allow: /",
        "Disallow: /api/",
        "",
        ...(site.ai.training ? [] : ["User-agent: GPTBot", "User-agent: ClaudeBot", "User-agent: Google-Extended",
          "User-agent: Applebot-Extended", "User-agent: CCBot", "User-agent: meta-externalagent", "User-agent: Amazonbot",
          "User-agent: Bytespider", "Disallow: /", ""]),
        `Sitemap: ${site.url}/sitemap.xml`,
        "",
      ].join("\n");
  return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
}
```

`Content-Signal` and IETF AIPREF `Content-Usage` are drafts or vendor conventions. Keep them consistent with your groups, and don't rely on them alone.

## 5. robots.txt hard rules

- `/robots.txt` returns 200 (or a deliberate 404). **Never 5xx or 429**: Google stops crawling for 12 h, then uses a cached copy for up to 30 days. 401/403 count as "no robots.txt".
- ≤ 500 KiB, UTF-8.
- Never disallow `/_next/` or other CSS/JS/image paths needed to render.
- `Crawl-delay`: ignored by Google and Amazon, honored by ClaudeBot/Claude-SearchBot.
- Blocking a URL doesn't de-index it if it's linked elsewhere. Use `noindex` (and let it be crawled).
- Spoofed bot UAs are often blocked by WAFs. If `void geo` reports `edge-blocked`, verify in server logs or the CDN's bot dashboard instead.

## Sources

- https://developers.openai.com/api/docs/bots
- https://support.claude.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler
- https://docs.perplexity.ai/guides/bots
- https://developers.google.com/search/docs/crawling-indexing/google-common-crawlers
- https://developers.google.com/crawling/docs/crawlers-fetchers/google-user-triggered-fetchers
- https://developers.google.com/crawling/docs/robots-txt/robots-txt-spec
- https://support.apple.com/en-us/119829 · https://developers.facebook.com/docs/sharing/webmasters/web-crawlers/
- https://developer.amazon.com/amazonbot · https://duckduckgo.com/duckduckgo-help-pages/results/duckassistbot · https://docs.mistral.ai/robots · https://commoncrawl.org/ccbot
- https://vercel.com/blog/the-rise-of-the-ai-crawler
- https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/ · https://datatracker.ietf.org/doc/draft-ietf-aipref-vocab/


---

<!-- https://void-design.vercel.app/docs/seo/content-for-citation · updated 2026-09-16 -->

# Content that gets cited (GEO writing patterns)

These are writing and layout patterns for pages that AI engines (Google AI Overviews/AI Mode, ChatGPT search, Perplexity, Copilot, Claude) quote and link to. Every pattern also helps human readers, and none replaces indexability (see the `seo` SKILL).

## 1. What the evidence supports

| Source | Finding | Implication |
|---|---|---|
| Aggarwal et al., *GEO*, KDD 2024 (10K queries; also tested on Perplexity) | **Cite sources, add quotations, add statistics** raised visibility 30–40%. At rank 5: citations +115%, quotations +100%, statistics +98%. At rank 1 the same edits *lost* 20–30%. Keyword stuffing and "authoritative" tone: no gain | Add evidence, especially on pages that aren't already #1 |
| Puerto et al., *C-SEO Bench*, NeurIPS 2025 | Most content-rewriting tricks are ineffective or harmful, and gains shrink as competitors adopt them. **Traditional SEO is significantly more effective** | Don't "rewrite for AI". Rank first |
| Chen et al., 2025 (arXiv 2509.08919) | AI search strongly favors **earned media** (third-party, authoritative) over brand-owned pages. Big-brand bias | Get covered, reviewed and cited elsewhere (off-site) |
| *Citation Selection to Citation Absorption*, 2026 (21K citations) | High-influence cited pages are **longer, more structured, and rich in definitions, numerical facts, comparisons and procedural steps** | Structure plus facts |
| Microsoft Ads, "Optimizing content for AI search answers" (2025-10) | Title/description/H1 alignment; descriptive H2/H3; Q&A pairs ("lifted word for word"); lists and tables; self-contained 1–2 sentence answers. Avoid walls of text, answers hidden in tabs, facts only in images or PDFs, decorative symbols | Snippable sections |
| Bing Webmaster (2025-12, 2026-02) | Near-duplicate URLs get clustered and one is chosen. Freshness via IndexNow. Evidence, examples and cited sources | Consolidate, stay fresh |
| Google, "Optimizing for generative AI features" (2026-05) | No special requirements. **Non-commodity content** and a unique point of view win. llms.txt, chunking, "AI rewriting" and special schema are unnecessary | Be genuinely useful and original |

## 2. Patterns

### 2.1 Answer first (every page and every question-style section)

The first paragraph after the h1 (and after each h2 phrased as a question) answers in **1–2 self-contained sentences, ≤ 320 characters**, naming the entity instead of using a pronoun.

```html
<!-- ❌ -->
<h1>Welcome to the future of builds</h1>
<p>We're passionate about helping teams move faster. It's built for you.</p>

<!-- ✅ -->
<h1>Acme: incremental TypeScript builds for monorepos</h1>
<p>Acme is an open-source build tool that caches TypeScript compilation per package, cutting CI builds in a
   200-package monorepo from 14 minutes to 90 seconds. It is MIT licensed and runs on Node 22+ and Bun.</p>
```

### 2.2 Numbers with units, dates and sources

```html
<p>Median mobile JavaScript is 646 KB per page
   (<a href="https://almanac.httparchive.org/en/2025/page-weight">HTTP Archive Web Almanac, 2025</a>).
   Acme's landing page ships 142 KB, measured on 2026-09-10 with Lighthouse 13.</p>
```

- Every claim that could be doubted gets a number, a unit, a date or a link to a primary source (docs, papers, standards, official statistics). No links to aggregator blogs.
- Roughly **1 outbound primary-source link per ~300 words** of factual prose.
- Own benchmarks: state the method (hardware, versions, runs) next to the number.

### 2.3 Quotations from named people

```html
<blockquote cite="https://example.com/interview">
  <p>"We replaced three CI jobs with one Acme cache and cut our monthly runner bill by 38%."</p>
  <footer>— <cite>Dana Li, Staff Engineer at Example Corp</cite></footer>
</blockquote>
```

Real, attributable and permissioned. No invented testimonials.

### 2.4 Question headings + Q&A blocks (plain HTML, no FAQPage schema)

```html
<section aria-labelledby="q-pricing">
  <h2 id="q-pricing">How much does Acme cost?</h2>
  <p>Acme is free for open-source projects. The Team plan costs $12 per seat per month billed annually, and includes remote caching.</p>
</section>
```

Use the questions real users ask (support tickets, sales calls, search console queries). Keep the answers **visible**, not collapsed. If you use `<details>`, set `open` for the key facts.

### 2.5 Comparison and spec tables

```html
<table>
  <caption>Acme vs. tsc --build (200-package monorepo, cold CI, 2026-09)</caption>
  <thead><tr><th scope="col">Metric</th><th scope="col">Acme 3.2</th><th scope="col">tsc 5.9</th></tr></thead>
  <tbody>
    <tr><th scope="row">Cold build</th><td>6 min 10 s</td><td>14 min 2 s</td></tr>
    <tr><th scope="row">Warm build (1 file changed)</th><td>1.5 s</td><td>38 s</td></tr>
    <tr><th scope="row">License</th><td>MIT</td><td>Apache-2.0</td></tr>
  </tbody>
</table>
```

Real `<table>` with `<th scope>`, never a grid of divs. Compare fairly and name what the competitor does better.

### 2.6 Definitions and steps

- "X is a Y that Z." definitions near the top of concept pages.
- Procedures as `<ol>` with one action per step and the expected result.

### 2.7 Entity clarity

The exact same name everywhere: `<title>`, h1, `og:site_name`, JSON-LD `Organization.name` and `WebSite.name`, the llms.txt H1, footer. Keep a "What it is / Who it's for / Pricing / License / Status" fact block on the home or about page. Link official profiles through `sameAs`.

### 2.8 Freshness

- A visible `<time dateTime="2026-09-01T09:00:00Z">Updated 1 Sep 2026</time>` that matches JSON-LD `dateModified` and sitemap `lastmod`.
- Change the date only when the facts change (a copyright-year bump doesn't count).
- Ping IndexNow on publish (see `next-metadata.md` §9).

### 2.9 Visible text, not pictures of text

- Key facts must not live only in images, canvas, video, carousels, PDFs, or tabs closed on load.
- Hero sections have ≥ 20 words of real text.
- Charts: a caption or table with the numbers next to the image. Videos: a transcript or summary.

### 2.10 Readable chunks

- Paragraphs ≤ ~150 words. Descriptive headings every 200–400 words.
- No decorative symbols as meaning (→ ★ !!!). No emoji bullets.
- Content pages ≥ ~150 words of main content (tools and app pages are exempt).

## 3. Page templates

**Product home:** h1 (what + for whom) → answer paragraph → primary CTA → 3–5 benefit sections, each an h2 claim + a measured proof → comparison table → pricing summary in text → named customer quotes → FAQ-style h2 questions → footer fact block.

**Docs page:** h1 = task or concept → one-sentence definition or outcome → prerequisites list → numbered steps with code → expected output → troubleshooting h2 questions → "Updated" date → `.md` mirror.

**Article:** h1 → byline + updated date → TL;DR answer (2–3 sentences) → sections with question or claim headings → data with sources → quotes → conclusion restating the answer → author bio linked to a ProfilePage.

## 4. Don't

- Generate keyword-variant or city-variant pages for query fan-out. Google treats this as scaled content abuse, and Bing clusters them.
- Rewrite in a "persuasive" or "authoritative" tone, or stuff keywords. It had no effect in controlled tests.
- Chunk content into many thin micro-pages "for LLMs".
- Add FAQPage/HowTo schema for rich results, or `speakable` spam.
- Fake mentions, reviews or ratings.
- Hide answers in accordions, or serve different content to bots.

## 5. Off-site (outside the codebase, tell the owner)

AI engines lean on third-party sources. Maintain accurate profiles (GitHub, LinkedIn, Crunchbase, Wikipedia where notable), get listed in credible comparisons and directories, publish benchmarks others can cite, and answer in communities.

## 6. Measure

- Google Search Console → "Generative AI performance" report.
- Bing Webmaster Tools → "AI Performance" (citations, cited pages, grounding queries).
- Analytics: referrals with `utm_source=chatgpt.com`, and from perplexity.ai, copilot.microsoft.com, claude.ai.
- `void geo <url>` reports these heuristics as `info` findings (answer-first, citation density, question headings, tables, entity consistency). They are review prompts, never CI failures.

## Sources

- https://arxiv.org/abs/2311.09735 (GEO, KDD 2024)
- https://arxiv.org/abs/2506.11097 (C-SEO Bench)
- https://arxiv.org/abs/2509.08919 (earned media bias)
- https://arxiv.org/abs/2604.25707 (citation absorption)
- https://about.ads.microsoft.com/en/blog/post/october-2025/optimizing-your-content-for-inclusion-in-ai-search-answers
- https://blogs.bing.com/webmaster/December-2025/Does-Duplicate-Content-Hurt-SEO-and-AI-Search-Visibility
- https://blogs.bing.com/webmaster/February-2026/Introducing-AI-Performance-in-Bing-Webmaster-Tools-Public-Preview
- https://developers.google.com/search/docs/fundamentals/ai-optimization-guide
- https://developers.google.com/search/docs/appearance/ai-features


---

<!-- https://void-design.vercel.app/docs/seo/llms-txt-markdown · updated 2026-09-16 -->

# llms.txt and Markdown mirrors

Status (2026-09): llmstxt.org spec **v2** (August 2026) adds `<link rel="describedby">` and per-page `rel="alternate" type="text/markdown"` discovery. Lighthouse 13 has an experimental `llms-txt` audit (a 5xx fails; a 404 is N/A).

**Evidence of value is thin. Ship it because it's cheap, and score it `info`.** In Ahrefs' 137K-domain log study (2026-06), 97% of llms.txt files got zero requests. The rest came mostly from SEO tools and coding agents (GPTBot, Claude-Code), and retrieval bots made 1.1%. Google's AI guide says llms.txt and Markdown are **not used** by Google Search. The main beneficiaries are coding agents reading your docs.

Canonical code: `templates/next/src/app/llms.txt/route.ts` and `src/app/md/[...slug]/route.ts`, both generated from the page registry `src/content/pages.ts` (path, title, description, lastModified, section, `markdown()`).

## 1. `/llms.txt`

```ts
// src/app/llms.txt/route.ts
import { docs, posts } from "@/lib/content";
import { site } from "@/site.config";

export const dynamic = "force-static";

export function GET() {
  const body = [
    `# ${site.name}`,                                    // required H1: the exact brand name (entity consistency)
    "",
    `> ${site.description}`,                             // one-sentence summary blockquote
    "",
    `${site.name} is open source (MIT). This file lists the latest stable docs. Pricing: free for OSS, Team $12/seat/month.`,
    "",
    "## Docs",
    "",
    ...docs.map((d) => `- [${d.title}](${site.url}${d.path}.md): ${d.description}`),
    "",
    "## Blog",
    "",
    ...posts.slice(0, 20).map((p) => `- [${p.title}](${site.url}/blog/${p.slug}.md): ${p.description}`),
    "",
    "## Optional",                                        // agents may skip this section
    "",
    `- [Changelog](${site.url}/changelog.md)`,
    "",
  ].join("\n");
  return new Response(body, { headers: { "Content-Type": "text/markdown; charset=utf-8" } });
}
```

Rules:
- Curated, not a sitemap dump. Under ~100 KB. It starts with `# H1`, then `> summary`, then `## Section` lists of `- [name](url): note`.
- Link targets are `.md` mirrors or clean server-rendered HTML, never login walls or client-only apps. Every link returns 200.
- Only list what exists. Facts come from `site.config.ts` so llms.txt, JSON-LD and page copy never disagree (a real site once claimed the wrong license in llms.txt).
- Leave out noindex, auth and app routes.
- Optional `llms-full.txt` (all Markdown concatenated) is a convention (nextjs.org ships one), not part of the spec.
- Advertise it site-wide from the root layout: `<link rel="describedby" type="text/markdown" href="/llms.txt" />` (React 19 hoists it into `<head>`).

## 2. Markdown mirrors: explicit `.md` URLs (preferred)

```ts
// src/app/md/[...slug]/route.ts
import { getPost, posts } from "@/lib/content";
import { site } from "@/site.config";

export const dynamic = "force-static";
export const dynamicParams = false;

export function generateStaticParams() {
  return posts.map((p) => ({ slug: ["blog", p.slug] }));
}

export async function GET(_req: Request, { params }: { params: Promise<{ slug: string[] }> }) {
  const { slug } = await params;
  const post = slug[0] === "blog" ? getPost(slug[1] ?? "") : undefined;
  if (!post) return new Response("Not found", { status: 404 });
  const html = `${site.url}/blog/${post.slug}`;
  const md = `---\ntitle: ${JSON.stringify(post.title)}\ncanonical: ${html}\ndateModified: ${post.updated}\n---\n\n# ${post.title}\n\n${post.markdown}\n`;
  return new Response(md, {
    headers: {
      "Content-Type": "text/markdown; charset=utf-8",
      "X-Robots-Tag": "noindex",                 // don't compete with the HTML page in search
      Link: `<${html}>; rel="canonical"`,
    },
  });
}
```

Route `.md` URLs to that handler with rewrites (the template's approach, no proxy needed):

```ts
// next.config.ts
async rewrites() {
  return [
    { source: "/index.md", destination: "/md/index" },
    { source: "/:path+.md", destination: "/md/:path+" },
  ];
},
```

Or with a proxy (needed anyway if you also negotiate on `Accept`, §3):

```ts
// src/proxy.ts (Next 16 renamed middleware.ts → proxy.ts, export name `proxy`)
import { NextResponse, type NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  if (pathname.endsWith(".md")) {
    return NextResponse.rewrite(new URL(`/md${pathname.slice(0, -3)}`, request.url)); // /blog/x.md → /md/blog/x
  }
  return NextResponse.next();
}
export const config = { matcher: ["/blog/:path*", "/docs/:path*"] };
```

Page metadata advertises the mirror with `pageMetadata({ …, markdown: true })`, which emits `<link rel="alternate" type="text/markdown" href="https://site/blog/x.md">`.

Verified: `GET /blog/hello-world.md` returns 200 `text/markdown` with `x-robots-tag: noindex` and a canonical `Link` header.

Mirror rules:
- The same facts as the HTML (main-text similarity ≥ 0.8). Materially different content for bots risks being treated as cloaking.
- Not in `sitemap.xml`.
- Strip nav, footer and cookie text. Keep headings, tables, code blocks and links (absolute URLs).

## 3. `Accept: text/markdown` negotiation (only behind a cache-aware edge)

```ts
// inside proxy(): same URL, Markdown for agents that ask
const accept = request.headers.get("accept") ?? "";
if (accept.includes("text/markdown")) {
  const res = NextResponse.rewrite(new URL(`/md${pathname}`, request.url));
  res.headers.set("Vary", "Accept");
  return res;
}
```

**Caveat (verified on 16.3.5):** on the *HTML* response for prerendered routes, Next overwrote a custom `Vary: Accept` (set via proxy or `headers()`). A shared CDN can then serve Markdown to browsers. Use negotiation only where the proxy runs before the cache (e.g. Vercel), or add `Vary: Accept` at the CDN. Otherwise use explicit `.md` URLs only.

Alternative: Cloudflare "Markdown for Agents" converts at the edge (paid zones). It adds `vary: accept` and a default `Content-Signal`.

## 4. Other stacks

- Astro: `src/pages/llms.txt.ts` exporting `GET` with `prerender = true`; `.md` mirrors via `src/pages/[...slug].md.ts` from content collections.
- Static hosts: generate `llms.txt` and `*.md` at build time into the output dir. Set `X-Robots-Tag: noindex` for `*.md` in host headers config (`_headers` on Netlify/Cloudflare Pages).

## Sources

- https://llmstxt.org/ · https://llmstxt.org/changes.md
- https://developer.chrome.com/docs/lighthouse/agentic-browsing/llms-txt
- https://ahrefs.com/blog/llmstxt-study/
- https://developers.google.com/search/docs/fundamentals/ai-optimization-guide
- https://nextjs.org/docs/app/guides/ai-agents · https://nextjs.org/docs/app/api-reference/file-conventions/proxy
- https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/


---

<!-- https://void-design.vercel.app/docs/seo/next-metadata · updated 2026-09-16 -->

# Next.js 16 metadata recipes (verified)

Every recipe was compiled with `next build` on **Next 16.3.5** (React 19.2) and checked with `next start` + curl. `params` is a Promise.
The canonical copies live in `templates/next/`. Paste these only into projects that didn't start from the template.

## 1. Facts file (single source of truth)

Template: `templates/next/src/site.config.ts` (with `@/*` → `./src/*` in tsconfig). Minimal shape for other projects:

```ts
// src/site.config.ts: metadata, JSON-LD, sitemap, robots, llms.txt and OG images all read from here.
// "Only publish what exists": leave a field empty rather than invent it.
export const site = {
  name: "Acme",
  url: (process.env.NEXT_PUBLIC_SITE_URL ?? "https://acme.example").replace(/\/$/, ""), // production origin, no trailing slash
  description: "Acme is an open-source toolkit that makes TypeScript builds 10x faster. MIT licensed.", // ≤ 160 chars
  tagline: "Acme: fast builds for TypeScript teams",   // home page <title>
  locale: "en_US",
  lang: "en",
  organization: { name: "Acme", logo: "/icon-512.png" },            // logo ≥ 112×112 PNG
  socials: [{ label: "GitHub", href: "https://github.com/acme" }],  // only profiles that exist → JSON-LD sameAs
  ai: { retrieval: true, training: false },                          // robots.txt policy (see ai-crawlers.md)
  privatePaths: ["/api/"],                                            // never crawled, not in sitemap or llms.txt
} as const;
```

Keep a page registry (template: `src/content/pages.ts`) with `path`, `title`, `description` and `lastModified` (the real content date) for every public page. Sitemap, llms.txt and Markdown mirrors are generated from it.

## 2. Root layout and the page helper

```tsx
// src/app/layout.tsx
import type { Metadata, Viewport } from "next";
import { site } from "@/site.config";

export const metadata: Metadata = {
  metadataBase: new URL(site.url),                  // relative canonical/OG URLs resolve against this
  title: { default: site.tagline, template: `%s | ${site.name}` },
  description: site.description,
  applicationName: site.name,
  openGraph: { type: "website", siteName: site.name, locale: site.locale, url: "/" },
  twitter: { card: "summary_large_image" },
  robots: { index: true, follow: true, googleBot: { index: true, follow: true, "max-image-preview": "large", "max-snippet": -1, "max-video-preview": -1 } },
  // NO alternates.canonical here: a root canonical makes every page without its own canonicalize to the home page
};

export const viewport: Viewport = {                  // theme-color lives in viewport, not metadata
  themeColor: [
    { media: "(prefers-color-scheme: light)", color: "#ffffff" },
    { media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
  ],
  colorScheme: "light dark",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang={site.lang} suppressHydrationWarning>
      <head>
        <link rel="describedby" type="text/markdown" href="/llms.txt" />  {/* llms.txt v2 discovery */}
      </head>
      <body>{children}</body>
    </html>
  );
}
```

```ts
// src/lib/seo.ts: every page's metadata goes through this (metadata merge is shallow).
// Same API as templates/next/src/lib/seo.ts, except `markdown` defaults to false here.
import type { Metadata } from "next";
import { site } from "@/site.config";

type PageMetadataInput = {
  path: `/${string}`;              // canonical path: leading slash, no trailing slash
  title: string;                   // "/" uses it as the absolute title; other pages get "title | Brand"
  description: string;
  image?: { url: string; width?: number; height?: number; alt?: string }; // omit when an opengraph-image file covers the route
  type?: "website" | "article";
  publishedTime?: string;          // ISO 8601 with timezone
  modifiedTime?: string;
  markdown?: boolean;              // advertise the .md mirror
  noindex?: boolean;
};

const markdownPath = (path: string) => (path === "/" ? "/index.md" : `${path}.md`);

export function pageMetadata(i: PageMetadataInput): Metadata {
  const images = i.image ? [i.image] : undefined;
  const type = i.type ?? "website";
  return {
    title: i.path === "/" ? { absolute: i.title } : i.title,
    description: i.description,
    alternates: {
      canonical: i.path,
      ...(i.markdown ? { types: { "text/markdown": markdownPath(i.path) } } : {}),
    },
    openGraph: {
      type, url: i.path, title: i.title, description: i.description,
      siteName: site.name, locale: site.locale,           // re-added: a page-level openGraph replaces the layout's
      ...(images ? { images } : {}),
      ...(type === "article" ? { publishedTime: i.publishedTime, modifiedTime: i.modifiedTime } : {}),
    },
    twitter: { card: "summary_large_image", title: i.title, description: i.description, ...(images ? { images } : {}) },
    ...(i.noindex ? { robots: { index: false, follow: true } } : {}),
  };
}

export const absoluteUrl = (path: string) => (path.startsWith("http") ? path : `${site.url}${path === "/" ? "/" : path}`);
```

```tsx
// src/app/page.tsx (home)
export const metadata = pageMetadata({ path: "/", title: site.tagline, description: site.description });

// src/app/pricing/page.tsx
export const metadata = pageMetadata({ path: "/pricing", title: "Pricing", description: "Acme is free for open source. Team plan is $12 per seat per month, billed annually." });

// src/app/login/page.tsx
export const metadata = pageMetadata({ path: "/login", title: "Log in", description: "Log in to Acme.", noindex: true });
```

Verified output for a page: `<link rel="canonical" href="https://acme.example/blog/hello-world"/>`. The home page with `canonical: "/"` emits `https://acme.example` (no trailing slash).

Rules:
- `metadata` and `generateMetadata` only work in Server Components (`page`/`layout`). You can't export both from one file.
- `metadataBase` missing means a build error for relative OG URLs.
- If `opengraph-image.tsx` exists for a route segment, it overrides `openGraph.images`. Don't pass `image` then.
- A section `layout.tsx` with a plain string `title` resets the template for its children. Set titles per page via the helper.

## 3. Request-time routes: deliver `<head>` to bots (`htmlLimitedBots`)

Only needed when `generateMetadata` or the page is dynamic (uses `cookies()`, `headers()`, `connection()`, `searchParams`, or uncached data). Prerendered routes already have metadata in `<head>` for every UA.

Verified on 16.3.5 for a dynamic route with Suspense:

| UA | title/canonical in raw `<head>` | Suspense content in order |
|---|---|---|
| Browser | no (streamed into `<body>`) | no (`<div hidden id="S:1">`) |
| GPTBot, ClaudeBot, Claude-User | **no** | **no** |
| Googlebot | **no**, still in `<body>` after rendering | no |
| Bingbot (in default list) | yes | yes |

```ts
// next.config.ts
import type { NextConfig } from "next";

// Copy of Next 16.3.5's default list. Setting htmlLimitedBots REPLACES the default, so keep it.
// Re-diff on every Next upgrade: packages/next/src/shared/lib/router/utils/html-bots.ts
const NEXT_DEFAULT_HTML_BOTS =
  "[\\w-]+-Google|Google-[\\w-]+|Chrome-Lighthouse|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|Yeti|googleweblight";

const EXTRA_BOTS = [
  "Googlebot",
  "GPTBot", "OAI-SearchBot", "ChatGPT-User",
  "ClaudeBot", "Claude-SearchBot", "Claude-User", "Claude-Code",
  "PerplexityBot", "Perplexity-User",
  "meta-externalagent", "meta-externalfetcher", "meta-webindexer",
  "Amazonbot", "Amzn-SearchBot", "Amzn-User",
  "DuckAssistBot", "MistralAI-User", "MistralAI-Index", "CCBot", "Bytespider",
].join("|");

const nextConfig: NextConfig = {
  htmlLimitedBots: new RegExp(`${NEXT_DEFAULT_HTML_BOTS}|${EXTRA_BOTS}`, "i"),
};
export default nextConfig;
```

The cost is that those bots wait for the full HTML (blocking TTFB). Browsers keep streaming. Never set `/.*/`.

Check it: `curl -sA "Mozilla/5.0 (compatible; GPTBot/1.4; +https://openai.com/gptbot)" URL | awk '/<\/head>/{exit} /rel="canonical"/{print "canonical in head"}'`.

## 4. Dynamic content page (blog post) with correct status codes

```tsx
// src/app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { JsonLd } from "@/components/json-ld";
import { pageMetadata } from "@/lib/seo";
import { getPost, posts } from "@/lib/content";
import { site } from "@/site.config";

type Props = { params: Promise<{ slug: string }> };

export function generateStaticParams() {
  return posts.map((p) => ({ slug: p.slug }));
}
export const dynamicParams = false;                  // unknown slugs → real 404

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = getPost(slug);
  if (!post) return {};
  return pageMetadata({
    path: `/blog/${post.slug}`, title: post.title, description: post.description,
    type: "article", publishedTime: post.published, modifiedTime: post.updated, markdown: true,
  });
}

export default async function Post({ params }: Props) {
  const { slug } = await params;
  const post = getPost(slug);
  if (!post) notFound();                               // BEFORE any <Suspense>: inside one, the status is 200
  const url = `${site.url}/blog/${post.slug}`;
  return (
    <article>
      <JsonLd data={{
        "@context": "https://schema.org",
        "@graph": [
          { "@type": "BlogPosting", "@id": `${url}#article`, headline: post.title, description: post.description,
            datePublished: post.published, dateModified: post.updated, mainEntityOfPage: url,
            author: { "@type": "Person", name: post.author.name, url: `${site.url}/authors/${post.author.slug}` },
            publisher: { "@id": `${site.url}/#organization` }, image: [`${url}/opengraph-image`] },
          { "@type": "BreadcrumbList", itemListElement: [
            { "@type": "ListItem", position: 1, name: "Blog", item: `${site.url}/blog` },
            { "@type": "ListItem", position: 2, name: post.title } ] },
        ],
      }} />
      <h1>{post.title}</h1>
      <p>By <a href={`/authors/${post.author.slug}`}>{post.author.name}</a> · <time dateTime={post.updated}>Updated {post.updated.slice(0, 10)}</time></p>
      {/* body: server-rendered; secondary widgets (comments, related) may sit in <Suspense> */}
    </article>
  );
}
```

| Pattern (verified) | HTTP status |
|---|---|
| `notFound()` in `generateMetadata` or top of page, before Suspense | **404** |
| `notFound()` inside a component under `<Suspense>` | **200** + `noindex` meta (soft 404) |
| `dynamicParams = false` + unknown slug | 404 |

Redirects: `redirect()` / `permanentRedirect()` (308) at the top of the page, or `redirects()` in `next.config.ts` with `permanent: true` for moved content. Never a client `router.push`.

## 5. Open Graph images

```tsx
// src/app/blog/[slug]/opengraph-image.tsx (per post). The site default src/app/opengraph-image.tsx (see the template) has no params.
import { ImageResponse } from "next/og";
import { getPost, posts } from "@/lib/content";
import { site } from "@/site.config";

export const alt = `${site.name} blog post`;
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export function generateStaticParams() {
  return posts.map((p) => ({ slug: p.slug }));      // prerender at build
}

export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = getPost(slug);
  return new ImageResponse(
    (
      <div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "flex-end",
                    padding: 80, background: "#0a0a0a", color: "#fafafa" }}>
        <div style={{ display: "flex", fontSize: 28, color: "#a3a3a3" }}>{site.name} · Blog</div>
        <div style={{ display: "flex", fontSize: 72, lineHeight: 1.1, marginTop: 16 }}>{post?.title ?? site.name}</div>
      </div>
    ),
    size,
  );
}
```

Verified: 200 `image/png`, 1200×630, ~31 KB, URL gets a content hash (`?38d3918f…`) so social caches refresh.
Constraints: flexbox and a CSS subset only (no grid), every multi-child element needs `display: "flex"`, fonts must be ttf/otf/woff read at build time, 500 KB bundle max. Colors come from hex mirrors of your tokens, since Satori doesn't read CSS variables or `oklch()`.

## 6. Sitemaps

```ts
// src/app/sitemap.ts
import type { MetadataRoute } from "next";
import { posts } from "@/lib/content";
import { site } from "@/site.config";

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    { url: `${site.url}/`, lastModified: "2026-09-01" },                  // real content date, NOT new Date()
    { url: `${site.url}/pricing`, lastModified: "2026-08-12" },          // template: generated from src/content/pages.ts
    ...posts.filter((p) => !p.draft && !p.noindex).map((p) => ({ url: `${site.url}/blog/${p.slug}`, lastModified: p.updated })),
  ]; // omit changeFrequency and priority: Google ignores them
}
```

- `lastModified` comes from frontmatter `updated ?? date` or hand-set constants. Git dates break in shallow CI clones and Docker builds without `.git`.
- Exclude drafts, noindex pages, `/api`, `/account`, `/login`, search and filter URLs.
- Each sitemap ≤ 50,000 URLs and ≤ 50 MB. Beyond that, split:

```ts
// src/app/products/sitemap.ts → /products/sitemap/0.xml, /1.xml …
import type { MetadataRoute } from "next";
import { site } from "@/site.config";
const PER_FILE = 50_000;
export async function generateSitemaps() {
  const total = await countProducts();
  return Array.from({ length: Math.ceil(total / PER_FILE) }, (_, id) => ({ id }));
}
export default async function sitemap({ id }: { id: Promise<string> }): Promise<MetadataRoute.Sitemap> {
  const n = Number(await id);                                          // Promise since 16.0
  const rows = await listProducts({ offset: n * PER_FILE, limit: PER_FILE });
  return rows.map((r) => ({ url: `${site.url}/products/${r.slug}`, lastModified: r.updatedAt }));
}
```

```ts
// src/app/sitemap-index.xml/route.ts: Next doesn't generate an index for generateSitemaps
import { generateSitemaps } from "@/app/products/sitemap";
import { site } from "@/site.config";
export const dynamic = "force-static";
export async function GET() {
  const ids = await generateSitemaps();
  const locs = [`${site.url}/sitemap.xml`, ...ids.map(({ id }) => `${site.url}/products/sitemap/${id}.xml`)];
  const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${locs.map((l) => `  <sitemap><loc>${l}</loc></sitemap>`).join("\n")}\n</sitemapindex>\n`;
  return new Response(xml, { headers: { "Content-Type": "application/xml; charset=utf-8" } });
}
```

Point robots `sitemap` at `/sitemap-index.xml` (or list every sitemap).

## 7. Icons and manifest

```
app/favicon.ico      → <link rel="icon" href="/favicon.ico" sizes="…">        required: Google doesn't list SVG favicons
app/icon.png         → <link rel="icon" type="image/png">                     square, ≥ 48×48 (use 512×512)
app/icon.svg         → optional extra, never the only icon
app/apple-icon.png   → <link rel="apple-touch-icon" sizes="180x180">          opaque PNG
app/manifest.ts      → <link rel="manifest" href="/manifest.webmanifest">
```

```ts
// src/app/manifest.ts
import type { MetadataRoute } from "next";
import { site } from "@/site.config";
export default function manifest(): MetadataRoute.Manifest {
  return {
    name: site.name, short_name: site.name, description: site.description,
    start_url: "/", display: "standalone", background_color: "#ffffff", theme_color: "#0a0a0a",
    icons: [
      { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
      { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
      { src: "/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
    ],
  };
}
```

## 8. URLs, previews and headers

- Next's default: `/about/` → 308 → `/about`. Keep it. If you set `trailingSlash: true`, canonicals, sitemap and links must all use the slash.
- http→https and www↔apex: one 301/308 hop at the host or CDN.
- Preview deployments send `X-Robots-Tag: noindex` or robots `Disallow: /` (see `ai-crawlers.md`), keyed on `VERCEL_ENV !== "production"` or your platform's equivalent.

```ts
// next.config.ts: noindex non-production at the header level too
async headers() {
  return process.env.VERCEL_ENV && process.env.VERCEL_ENV !== "production"
    ? [{ source: "/:path*", headers: [{ key: "X-Robots-Tag", value: "noindex" }] }]
    : [];
},
```

- Pagination: each `?page=n` has its own self-canonical (not page 1), linked with `<a href>`. Filter and sort variants are `noindex` and left out of the sitemap.
- hreflang (only for real translations): `alternates.languages: { "en-US": "/en", "de-DE": "/de", "x-default": "/" }` on **every** variant, reciprocal, and each variant self-canonical.

## 9. IndexNow (Bing, Yandex, Seznam, Naver) on publish

```ts
// scripts/indexnow.ts: run in CI after deploy with changed URLs
const KEY = process.env.INDEXNOW_KEY!;          // 8–128 chars [a-zA-Z0-9-]; also commit public/<KEY>.txt containing the key
const HOST = new URL(process.env.NEXT_PUBLIC_SITE_URL!).host;
export async function submit(urls: string[]) {
  for (let i = 0; i < urls.length; i += 10_000) {
    const res = await fetch("https://api.indexnow.org/indexnow", {
      method: "POST",
      headers: { "Content-Type": "application/json; charset=utf-8" },
      body: JSON.stringify({ host: HOST, key: KEY, keyLocation: `https://${HOST}/${KEY}.txt`, urlList: urls.slice(i, i + 10_000) }),
    });
    if (![200, 202].includes(res.status)) throw new Error(`IndexNow ${res.status}`); // 403 key invalid · 422 host mismatch · 429 slow down
  }
}
```

Google: submit the sitemap in Search Console once (the ping endpoint is deprecated). Bing ties AI-answer freshness to IndexNow.

## Sources

- https://nextjs.org/docs/app/api-reference/functions/generate-metadata
- https://nextjs.org/docs/app/api-reference/config/next-config-js/htmlLimitedBots
- https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/html-bots.ts
- https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image · …/sitemap · …/robots · …/app-icons · …/manifest
- https://nextjs.org/docs/app/api-reference/functions/generate-sitemaps · https://nextjs.org/docs/app/api-reference/functions/image-response
- https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls
- https://developers.google.com/search/docs/appearance/title-link · https://developers.google.com/search/docs/appearance/favicon-in-search
- https://developers.google.com/search/docs/crawling-indexing/googlebot (2 MB limit)
- https://www.indexnow.org/documentation


---

<!-- https://void-design.vercel.app/docs/seo/structured-data · updated 2026-09-16 -->

# Structured data (JSON-LD): what to emit, required properties, builder

Google Search gallery as of 2026-09 (gallery updated 2026-06-15; Software app page updated 2026-09-08).
Canonical code: `templates/next/src/components/json-ld.tsx` and `templates/next/src/lib/schema.ts`.

## 1. Policy (non-negotiable)

1. **Only mark up what is visible on the page and true.** `headline` ≈ h1, `name` ≈ visible name, `offers.price` appears in the text, `aggregateRating.ratingValue` and count appear in the text, `author.name` matches the byline.
2. **Never invent** ratings, reviews, prices, offers, authors or FAQs. No self-serving reviews on your own Organization or LocalBusiness. Invented ratings can bring a manual action.
3. **Server-render it** in the raw HTML. JS-injected markup is invisible to non-Google crawlers and makes Shopping crawls less reliable.
4. **One `@graph` per page with stable absolute `@id`s**: `https://site/#organization`, `https://site/#website`, `https://site/blog/x#article`. Other nodes reference them by `{ "@id": … }`. Define Organization and WebSite once per page, never as duplicates with different data.
5. **Escape `<`** in the serialized JSON. Use a native `<script>`, not `next/script`.
6. Dates are ISO 8601 **with timezone** (`2026-09-01T09:00:00Z`) and `dateModified ≥ datePublished`.
7. Mobile and desktop serve identical markup.

## 2. What still produces results

| Use | Type | Where |
|---|---|---|
| Site name + knowledge panel signals | `Organization` + `WebSite` | Home page (required there); emitting from the root layout on every page is fine |
| Breadcrumb trail in results | `BreadcrumbList` | Any nested page |
| Article/top stories enhancements | `Article` / `BlogPosting` / `NewsArticle` / `TechArticle` | Posts, docs |
| Software app rich result | `SoftwareApplication` / `WebApplication` / `MobileApplication` | **Only with real `aggregateRating` or `review`** |
| Product snippets (review/editorial pages) | `Product` with `review`/`aggregateRating`/`offers` | Review or comparison pages |
| Merchant listings (buyable) | `Product` with `Offer` | Pages where users buy |
| Author identity | `ProfilePage` + `Person` | Author pages |
| Video results | `VideoObject` | Pages whose main content is a video |
| Events | `Event` | Event pages |
| Forums / Q&A | `DiscussionForumPosting`, `QAPage` | Community content |

**Removed. Don't add for rich results:**

| Date | Removed |
|---|---|
| 2023-08/09 | HowTo |
| 2024-11-29 | Sitelinks search box (`WebSite.potentialAction` `SearchAction`) |
| 2025-06-12 | ClaimReview, course info, estimated salary, learning video, special announcement, vehicle listing |
| 2025-11-05 | Practice problem; Dataset limited to Dataset Search |
| 2026-05-07 | **FAQ rich results** (docs removed 2026-06-15) |

Existing FAQPage markup is harmless, but don't generate new FAQPage for rich results. Plain-HTML Q&A sections (h2 question + answer paragraph) still help AI answers. Don't add `speakable` for effect either.

## 3. Required and recommended properties

| Type | Required | Strongly recommended |
|---|---|---|
| `Article` / `BlogPosting` / `NewsArticle` / `TechArticle` | none | `headline`, `image` (≥ 50,000 px²; 16:9, 4:3, 1:1 variants), `datePublished`, `dateModified`, `author` (`Person`/`Organization` with `name` + `url` or `sameAs`), `publisher` |
| `BreadcrumbList` | `itemListElement[]` of `ListItem` with `position` (from 1, no gaps), `name`, `item` (absolute; last item may omit it) | ≥ 2 items |
| `Organization` | none | `name`, `url`, `logo` (≥ 112×112, crawlable), `sameAs` (official profiles), `description`; `address`/`contactPoint` only if real |
| `WebSite` | `name`, `url` (canonical home) | `alternateName`, `publisher` → `#organization`. One WebSite node; must be present on the home page |
| `SoftwareApplication` | `name`, `offers.price` (0 if free), **`aggregateRating` or `review`** | `applicationCategory` (e.g. `DeveloperApplication`), `operatingSystem`, `offers.priceCurrency` |
| `Product` (snippet) | `name` + one of `review` / `aggregateRating` / `offers` | `offers.price`, `priceCurrency`, `availability` |
| `Product` (merchant) | `name`, `image`, `offers` (`Offer`, not `AggregateOffer`, with `price` + `priceCurrency`) | `brand.name`, `gtin`/`mpn`, `shippingDetails`, `hasMerchantReturnPolicy`, `availability` |
| `ProfilePage` | `mainEntity` (`Person`/`Organization` with `name`) | `dateCreated`, `dateModified`, `mainEntity.image`, `mainEntity.sameAs` |
| `VideoObject` | `name`, `thumbnailUrl`, `uploadDate` | `description`, `duration` (ISO 8601), `contentUrl` or `embedUrl` |

Author best practice: `author` is `Person` or `Organization` (never `Thing`), one entity per author, `name` only (no "Posted by", no job title in `name`), plus `url` to a profile page.

## 4. Component (XSS-safe)

```tsx
// src/components/json-ld.tsx: Server Component (same as templates/next/src/components/json-ld.tsx)
import type { SchemaGraph, SchemaNode } from "@/lib/schema";

export function JsonLd({ data }: { data: SchemaGraph | (SchemaNode & { "@context": string }) }) {
  return (
    <script
      type="application/ld+json"
      // JSON.stringify doesn't escape "<"; a string containing </script> would break out of the tag
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\\u003c") }}
    />
  );
}
```

Verified: a description of `Probe </script><script>alert(1)</script>` is emitted as `\u003c/script>…`, which is valid JSON and inert.

## 5. `@graph` builder

The canonical copy is `templates/next/src/lib/schema.ts`. It exports `graph`, `organization`, `website`, `webpage`, `breadcrumb`, `person`, `article` and `softwareApplication`. The condensed version below uses the same names and `@id`s, plus `profilePage`:

```ts
// src/lib/schema.ts
import { absoluteUrl } from "@/lib/seo";
import { site } from "@/site.config";

type Ref = { "@id": string };
export type SchemaNode = { "@type": string; "@id"?: string; [key: string]: unknown };
export type SchemaGraph = { "@context": "https://schema.org"; "@graph": SchemaNode[] };

export const ids = {
  organization: `${site.url}/#organization`,
  website: `${site.url}/#website`,
  webpage: (path: string) => `${absoluteUrl(path)}#webpage`,
  person: (slug: string) => `${site.url}/#person-${slug}`,
};
const ref = (id: string): Ref => ({ "@id": id });

export const graph = (...nodes: SchemaNode[]): SchemaGraph => ({ "@context": "https://schema.org", "@graph": nodes });

export const organization = (): SchemaNode => ({
  "@type": "Organization", "@id": ids.organization, name: site.organization.name, url: `${site.url}/`,
  logo: absoluteUrl(site.organization.logo),
  ...(site.socials.length ? { sameAs: site.socials.map((s) => s.href) } : {}),
});

export const website = (): SchemaNode => ({
  "@type": "WebSite", "@id": ids.website, name: site.name, url: `${site.url}/`,
  description: site.description, inLanguage: site.lang, publisher: ref(ids.organization),
  // no potentialAction/SearchAction: sitelinks search box removed 2024-11
});

export const webpage = (i: { path: string; name: string; description: string; dateModified?: string }): SchemaNode => ({
  "@type": "WebPage", "@id": ids.webpage(i.path), url: absoluteUrl(i.path), name: i.name, description: i.description,
  isPartOf: ref(ids.website), inLanguage: site.lang, ...(i.dateModified ? { dateModified: i.dateModified } : {}),
});

export const breadcrumb = (items: { name: string; path?: string }[]): SchemaNode => ({
  "@type": "BreadcrumbList",
  itemListElement: items.map((item, i) => ({
    "@type": "ListItem", position: i + 1, name: item.name, ...(item.path ? { item: absoluteUrl(item.path) } : {}),
  })),
});

export const person = (i: { slug: string; name: string; url?: string; sameAs?: string[] }): SchemaNode => ({
  "@type": "Person", "@id": ids.person(i.slug), name: i.name,
  ...(i.url ? { url: i.url } : {}), ...(i.sameAs?.length ? { sameAs: i.sameAs } : {}),
});

export const article = (i: {
  path: string; headline: string; description: string; datePublished: string; dateModified: string;
  author: Ref | SchemaNode; image?: string; type?: "Article" | "BlogPosting" | "TechArticle";
}): SchemaNode => {
  const url = absoluteUrl(i.path);
  return {
    "@type": i.type ?? "BlogPosting", "@id": `${url}#article`, headline: i.headline, description: i.description,
    datePublished: i.datePublished, dateModified: i.dateModified, mainEntityOfPage: ref(ids.webpage(i.path)),
    author: i.author, publisher: ref(ids.organization), image: [i.image ?? `${url.replace(/\/$/, "")}/opengraph-image`],
  };
};

/** Valid without a rating, but only eligible for the Software app rich result WITH a real, visible rating. */
export const softwareApplication = (i: {
  name: string; category: string; operatingSystem: string;
  price?: { amount: number; currency: string }; rating?: { value: number; count: number };
}): SchemaNode => ({
  "@type": "SoftwareApplication", "@id": `${site.url}/#software`, name: i.name,
  applicationCategory: i.category, operatingSystem: i.operatingSystem, publisher: ref(ids.organization),
  ...(i.price ? { offers: { "@type": "Offer", price: i.price.amount, priceCurrency: i.price.currency } } : {}),
  ...(i.rating ? { aggregateRating: { "@type": "AggregateRating", ratingValue: i.rating.value, ratingCount: i.rating.count } } : {}),
});

export const profilePage = (i: { path: string; person: SchemaNode; dateModified?: string }): SchemaNode => ({
  "@type": "ProfilePage", "@id": `${absoluteUrl(i.path)}#profile`, url: absoluteUrl(i.path),
  mainEntity: i.person, ...(i.dateModified ? { dateModified: i.dateModified } : {}),
});
```

Usage:

```tsx
// Organization + WebSite: once site-wide. The template emits them from the root layout; home-page-only is also fine.
<JsonLd data={graph(organization(), website())} />

// src/app/page.tsx (home)
<JsonLd data={graph(webpage({ path: "/", name: site.tagline, description: site.description, dateModified: "2026-09-01" }))} />

// src/app/blog/[slug]/page.tsx
const author = person({ slug: post.author.slug, name: post.author.name, url: absoluteUrl(`/authors/${post.author.slug}`) });
<JsonLd data={graph(
  webpage({ path: `/blog/${post.slug}`, name: post.title, description: post.description, dateModified: post.updated }),
  article({ path: `/blog/${post.slug}`, headline: post.title, description: post.description,
            datePublished: post.published, dateModified: post.updated, author: { "@id": ids.person(post.author.slug) } }),
  author,
  breadcrumb([{ name: "Blog", path: "/blog" }, { name: post.title }]),
)} />

// src/app/docs/[...slug]/page.tsx
<JsonLd data={graph(article({ ...docArticle, type: "TechArticle" }), breadcrumb(doc.trail))} />
```

Product page with a real price but **no** reviews: emit `Product` with `offers` (snippet-eligible) or `softwareApplication` without `rating` (valid, but no rich result). Never add a rating to get stars.

## 6. Validate

- Offline (what `void geo` does): JSON parses; `@type`s exist in schema.org; the required props from §3 are present; `@id` references resolve in-page or to the home graph; visible-text match for name, price, rating and author; dates carry a timezone; BreadcrumbList positions are 1..n.
- Google Rich Results Test (manual, no API): https://search.google.com/test/rich-results. Schema Markup Validator: https://validator.schema.org.
- After launch: Search Console → Enhancements.

## Sources

- https://developers.google.com/search/docs/appearance/structured-data/search-gallery
- https://developers.google.com/search/docs/appearance/structured-data/sd-policies
- https://developers.google.com/search/docs/appearance/structured-data/software-app
- https://developers.google.com/search/docs/appearance/structured-data/article · …/breadcrumb · …/organization · …/product-snippet · …/merchant-listing · …/profile-page · …/video
- https://developers.google.com/search/docs/appearance/site-names
- https://developers.google.com/search/updates (deprecation changelog)
- https://nextjs.org/docs/app/guides/json-ld


---

<!-- https://void-design.vercel.app/docs/a11y · updated 2026-09-16 -->

# a11y — usable by keyboard, screen reader, zoom and reduced motion

The target is WCAG 2.2 AA as the hard gate and a Lighthouse/axe score of 100, with APCA as the perceptual quality bar. Accessible markup also makes pages readable to AI agents and crawlers (see the `seo` skill).
Paste-ready components: `references/patterns.md`. Visual tokens and focus ring styling: the `craft` skill.

## The 10 rules that matter most

1. **Use the native element.** `<a href>` navigates, `<button type="button">` acts, `<label>` labels. Never put `onClick` on a `div` or `span`.
2. **Every interactive element is reachable and operable by keyboard** in DOM order. No positive `tabindex`. No keyboard traps except inside modal dialogs.
3. **Visible focus everywhere:** `:focus-visible` ring 2px solid `--line-focus`, offset 2px, ≥ 3:1 against the surface. Never remove an outline without a replacement.
4. **One `<main>`, one `<h1>`, headings in order**, with landmarks `<header>`, `<nav aria-label>`, `<footer>`. A skip link to `#main` comes first in `<body>`.
5. **Contrast:** body text ≥ 4.5:1 (APCA Lc 75+), large text ≥ 3:1, UI borders, icons and focus rings ≥ 3:1. Hover and focus states increase contrast, never decrease it.
6. **Hit targets ≥ 24×24 CSS px** (WCAG 2.5.8), **≥ 44×44 on touch.** Grow the hit area with a pseudo-element, not by changing the layout.
7. **Every image has `alt`.** Informative images get a description of their content; decorative ones get `alt=""`. Icon-only buttons have an accessible name (`aria-label` or `sr-only` text).
8. **Forms:** a visible `<label>` per field (a placeholder is not a label), correct `type`/`autocomplete`, errors as text linked with `aria-describedby` + `aria-invalid`, and focus moves to the first invalid field on submit.
9. **Dialogs, menus, comboboxes and tabs use a proven primitive** (native `<dialog>`, Radix, Base UI, React Aria). Don't hand-roll focus traps.
10. **Respect `prefers-reduced-motion`.** Nothing flashes more than 3×/s, and auto-moving content over 5 s has a pause control. Content never depends on an animation finishing.

## Structure

```tsx
// app/layout.tsx (inside <body>)
<a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-surface-raised focus:px-4 focus:py-2 focus:text-fg">
  Skip to content
</a>
<header>…<nav aria-label="Primary">…</nav></header>
<main id="main">{children}</main>  {/* modern browsers move the focus start point to the target; no tabIndex needed */}
<footer>…</footer>
```

- **Exactly one `<main>`.** A layout `<main>` plus a page `<main>` gives two landmarks and a broken skip link. Pages render `<article>`/`<section>` inside it.
- Multiple `<nav>`s each get a distinct `aria-label` ("Primary", "Footer", "Breadcrumb").
- Headings describe their section. Don't skip levels going down (h2 → h4). Don't pick heading levels for font size; style with tokens instead.
- `<html lang="en">` matches the content. Mark phrases in another language with `lang`.
- `<title>` names the page and site ("Pricing | Acme"). Client route changes in Next update it automatically.
- Lists are `<ul>`/`<ol>`, tables are `<table>` with `<th scope>`, times are `<time dateTime>`.
- Links say where they go ("Read the pricing guide"), not "click here". Links that open a new tab say so (visible icon + `sr-only` "(opens in new tab)").

## Keyboard and focus

```css
/* app/globals.css: base layer (Tailwind v4) */
@layer base {
  :where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
    outline: 2px solid var(--line-focus);
    outline-offset: 2px;
  }
  :where(input, textarea, select):focus-visible { outline-offset: 0; }
  html { scroll-padding-top: calc(var(--header-h, 64px) + 16px); } /* sticky header never hides focused elements (2.4.11) */
}
```

- Tailwind v4: `outline-none` now literally sets `outline-style: none`. Use `outline-hidden` (which keeps a transparent outline for forced-colors mode) and always pair it with a `focus-visible:` ring. The utility form is `focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus`.
- On a colored or brand surface, use the double ring: `box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--line-focus)`.
- Focus rings appear instantly (no transition), because focus feedback must not lag.
- Composite widgets (tabs, menus, radio groups, toolbars, listboxes, grids) use **one tab stop + arrow keys** (roving tabindex). Page navigation uses plain links, each a tab stop.
- `Escape` closes the topmost overlay and returns focus to its trigger.
- Global shortcuts ignore keypresses inside inputs and editors. Single-key shortcuts must be remappable or disableable (2.1.4).
- Hide inert content with `inert` (off-canvas menus, content behind non-dialog overlays), not just visually.
- `display:none`/`hidden` removes from both the a11y tree and tab order. `aria-hidden="true"` removes only from the a11y tree, so never put it on focusable content.

## Hit targets and pointer

```css
.hit { position: relative; }
.hit::before { content: ""; position: absolute; inset: -8px; }            /* 24px icon → 40px target */
@media (pointer: coarse) { .hit::before { inset: -12px; } }               /* ≥ 44px on touch */
```

- Checkbox/radio + label are one hit area (the whole `<label>` row).
- Adjacent small targets have ≥ 8 px gaps.
- Dragging (sliders, reorder, swipe) always has a click/tap alternative (2.5.7).
- Hover-only UI is forbidden. Anything revealed on hover also appears on focus and is reachable by touch.
- `touch-action: manipulation` on controls removes the double-tap zoom delay.

## Color and contrast (tie to tokens)

| Content | WCAG 2.2 (hard gate) | APCA Lc (quality target) | Token pairs to check |
|---|---|---|---|
| Body text ≤ 18px | ≥ 4.5:1 | ≥ 75, **90 preferred** | `--fg` on `--bg`, `--surface`, `--surface-raised` |
| Secondary text, UI labels | ≥ 4.5:1 | ≥ 60 | `--fg-muted` on every surface |
| Large text (≥ 24px, or ≥ 18.66px bold) | ≥ 3:1 | ≥ 45 | display sizes on `--bg` |
| Meta, captions, placeholders | ≥ 4.5:1 | ≥ 60 | `--fg-subtle` (AA on `--bg`, `--bg-subtle`, `--surface`, `--surface-raised`) |
| Disabled controls, `aria-hidden` decoration | 3:1 (disabled is exempt from 1.4.3) | ≥ 30 | `--fg-faint` — never text someone must read |
| Input borders, focus ring, meaningful icons, chart marks | ≥ 3:1 (1.4.11) | ≥ 45–60 for icons | `--line-strong`, `--line-focus` vs adjacent surface |
| Text on brand | ≥ 4.5:1 | ≥ 60 | `--fg-on-brand` on `--brand` and `--brand-hover`; `--brand-text` on `--bg` |
| Status text | ≥ 4.5:1 | ≥ 60 | `--danger`/`--success`/`--warning` text on `--bg` and their `-subtle` fills |

- Check both **light and dark** themes. WCAG 2 overstates light-on-dark contrast, so APCA is stricter there.
- Never convey meaning by color alone. Errors get an icon + text, status gets a label, links in body text get an underline.
- Don't put `--fg-muted` gray text on colored fills. Use the fill's own text token.
- Support `forced-colors: active` (Windows High Contrast): don't rely on `background-image` or `box-shadow` alone for borders or focus. Outlines survive; shadows don't.

## Forms

- `<label for>` or a wrapping `<label>`, visible and above the field. Required fields: `required` + visible "(required)" text or a legend note.
- `type="email|tel|url|number"`, `inputMode`, `autoComplete="email|name|current-password|one-time-code"` (1.3.5). `spellCheck={false}` for codes and emails.
- Validate on blur and on submit, not on every keystroke. Once an error shows, re-validate on input.
- Error: text below the field with an icon; `aria-invalid="true"`; `aria-describedby` → error id; focus the first invalid field on submit. For long forms, add an error summary with links.
- Don't disable the submit button before the user tries. Let submit reveal what's missing. While submitting, keep the label ("Saving…") and set `aria-busy`/`disabled` on the button.
- Group radios and checkboxes in `<fieldset>` + `<legend>`.
- Never block paste. Allow password managers (3.3.8): no CAPTCHA-only logins, no copy-blocking on OTP fields.
- Field height ≥ 44 px on touch. Font size ≥ 16 px on inputs so iOS doesn't zoom.

## Motion

- Every animation is wrapped by `@media (prefers-reduced-motion: no-preference)`, or neutralized under `reduce`. See `speed/references/rendering-smoothness.md` §2 and the `motion` skill.
- Under reduced motion, replace movement with ≤ 150 ms opacity changes or nothing. Keep essential feedback (progress, state change).
- No parallax, zoom-on-scroll, large translations or auto-advancing carousels under `reduce`.
- Auto-playing video, marquees and carousels > 5 s get a visible pause button (2.2.2).
- Nothing flashes more than 3 times per second (2.3.1).
- Decorative animated text (scramble, typewriter) keeps the real text in `sr-only` and marks the animated copy `aria-hidden`.

## Overlays and widgets: choose, don't hand-roll

| Need | First choice | Weight (gzip) | Notes |
|---|---|---|---|
| Disclosure / accordion | `<details>`/`<summary>` (use `name` for exclusive groups) | 0 KB | Style `summary`; don't hide key SEO content in closed panels |
| Modal dialog | native `<dialog>` + `showModal()` | 0 KB (tiny client leaf) | Background becomes inert, Esc closes, focus returns to the opener. `closedby="any"` is Chromium/Firefox only, so add a backdrop click handler |
| Non-modal popover / simple menu of links | `popover` attribute + `popovertarget` | 0 KB | Light-dismiss and Esc built in; Baseline 2025 |
| Button opens dialog/popover without JS | `command`/`commandfor` invokers | 0 KB | Baseline newly available (Safari 26.2+); keep a JS fallback for older browsers |
| Dropdown menu, context menu, select, combobox, date picker | Radix (`radix-ui`), Base UI (`@base-ui/react`), or React Aria Components | Radix Dialog ~12.6 KB, Tabs ~8.4 KB, Popover ~22.6 KB (includes Floating UI) | Import per component. The full packages are large if not tree-shaken (Base UI ~147 KB, React Aria Components ~274 KB). Measure with `void perf` |
| Toasts | `sonner` | ~9.4 KB | Announces via a live region; toasts only for mutations |
| Tabs | Radix Tabs or the pattern in `patterns.md` | 0–8 KB | Arrow keys, `aria-selected`, `aria-controls` |

Use one primitive library per app. Vendored registry components (shadcn blocks, Magic UI, Aceternity, React Bits) often ship with no ARIA and no reduced-motion handling, so audit them before use.

## Images, media, icons

- `alt` describes purpose and content in ≤ 125 chars. Don't start with "image of". Logos: the company name. Linked images: the link destination.
- Charts: a text summary or data table next to the chart. Complex SVG: `role="img"` + `<title>`/`aria-label`.
- Decorative SVG icons: `aria-hidden="true"` (lucide does this by default). Icon-only buttons: `<button aria-label="Close">`. `title` alone isn't enough.
- Video: captions (`<track kind="captions">`), no autoplay with sound, controls available. Audio-only content gets a transcript.
- Canvas that conveys status: `role="img"` + `aria-label`. Decorative canvas: `aria-hidden="true"`.

## Live regions and status

- Toast and async results: `role="status"` (polite) for success and info; `role="alert"` only for errors that need immediate attention.
- The live region must **exist in the DOM before** its text changes. Mount it empty at startup and update its text later.
- Loading: `aria-busy="true"` on the region being updated. Spinners get `sr-only` "Loading…" text.
- Don't announce on every keystroke. Debounce search result counts ("12 results").
- Status is never color-only: pair the dot with a text label (`role="status"` where it updates).

## Zoom, reflow, text spacing

- Never set `maximum-scale=1` or `user-scalable=no`.
- **Reflow at 320 CSS px width** (≈ 400% zoom) with no horizontal scroll except for tables, code and maps (1.4.10). Test at 320px and at 200% browser zoom.
- Sizes in `rem` for type. Display type uses `clamp()` so the h1 fits at 320px.
- Tolerate user text spacing (1.4.12): no fixed heights on text containers. Use `min-height`, and `line-clamp` only with a way to read the rest.
- `overflow-x: clip` (not `hidden`) on `html`/`body`, so sticky elements and focus scrolling keep working.
- `100dvh`/`svh` instead of `100vh` for full-height layouts on mobile.

## Verify

1. `void lint` (static): `lint/div-button`, `lint/missing-alt`, `lint/outline-none-no-replacement`, `lint/no-reduced-motion`, `lint/multiple-h1`, `lint/missing-lang`.
2. `void a11y --start "next start -p 3000" --port 3000 --format md` on a production build: axe-core (WCAG 2.2 AA + best practices) on mobile and desktop, plus void's own checks `a11y/focus-not-visible` (keyboard focus walk), `a11y/target-size` (mobile), `a11y/reflow-overflow` (320px). Common axe ids: `a11y/color-contrast`, `a11y/button-name`, `a11y/link-name`, `a11y/label`, `a11y/image-alt`, `a11y/heading-order`, `a11y/landmark-one-main`, `a11y/bypass`, `a11y/meta-viewport`, `a11y/aria-hidden-focus`, `a11y/nested-interactive`, `a11y/link-in-text-block`. Unmapped axe rules arrive as `a11y/axe-other` with the axe id in the message.
3. **Stop when:** zero axe violations, the focus walk passes, a11y score 100. Then do a 2-minute manual pass: Tab through the page, open and close every overlay with the keyboard, and use VoiceOver (macOS: Cmd+F5) or NVDA on the main flow.
4. Automated tools catch only part of real issues. The manual pass is not optional for dialogs, menus and forms.


---

<!-- https://void-design.vercel.app/docs/a11y/patterns · updated 2026-09-16 -->

# Accessible patterns (paste-ready)

React 19 + Tailwind v4 with void tokens (`bg-surface-raised`, `text-fg`, `border-line`, `outline-line-focus`…). Each pattern follows the WAI-ARIA Authoring Practices keyboard model. **Prefer a primitive library (Radix, Base UI, React Aria) for menus, comboboxes, selects and date pickers.** These snippets are for zero-dependency cases and for understanding what a primitive must do.

Contents: 1 utilities · 2 skip link · 3 icon button · 4 disclosure (details, nav menu) · 5 dialog · 6 tabs · 7 actions menu · 8 toast / live region · 9 form field · 10 checklist per widget

## 1. Utilities

```css
/* globals.css: Tailwind v4 ships sr-only / not-sr-only; these are for non-Tailwind stacks */
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }

@layer base {
  :where(a, button, input, select, textarea, summary, [tabindex]):focus-visible { outline: 2px solid var(--line-focus); outline-offset: 2px; }
  html:has(dialog:modal) { overflow: hidden; }       /* scroll lock while a modal is open */
  html { scrollbar-gutter: stable; }                  /* no layout shift when scroll locks */
  @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 1ms !important; animation-duration: 1ms !important; animation-delay: 0ms !important; } }
}
```

## 2. Skip link

```tsx
// first child of <body>
<a href="#main"
   className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-surface-raised focus:px-4 focus:py-2 focus:text-fg focus:shadow-2">
  Skip to content
</a>
// …
<main id="main">{children}</main>
```

## 3. Icon button (name is type-required)

```tsx
import type { ButtonHTMLAttributes, ReactNode } from "react";

type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-label" | "children"> & {
  label: string;           // required: becomes the accessible name
  icon: ReactNode;         // e.g. <X aria-hidden />
};

export function IconButton({ label, icon, className = "", type = "button", ...rest }: IconButtonProps) {
  return (
    <button type={type} aria-label={label} className={`relative inline-grid size-9 place-items-center rounded-md text-fg-muted hover:bg-surface-hover hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus before:absolute before:-inset-1 before:content-[''] pointer-coarse:before:-inset-1.5 ${className}`} {...rest}>
      {icon}
    </button>
  );
}
```

`size-9` (36px) plus a `-inset-1` pseudo-element gives a 44px hit area; `pointer-coarse:` widens it further on touch. Tooltips supplement the label, they don't replace it.

## 4. Disclosure

### 4a. Zero-JS: `<details>`

```tsx
<details className="group rounded-lg border border-line" name="faq">   {/* same name = only one open at a time */}
  <summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-4 py-3 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus [&::-webkit-details-marker]:hidden">
    How much does Acme cost?
    <ChevronDown aria-hidden className="size-4 transition-transform duration-(--duration-fast) group-open:rotate-180" />
  </summary>
  <div className="px-4 pb-4 text-fg-muted">Free for open source. Team is $12 per seat per month.</div>
</details>
```

Key facts that search and AI should read: render them `open` or as plain sections (see the `seo` skill).

### 4b. Mobile navigation menu (disclosure, not `role="menu"`)

Site navigation is a list of links behind a toggle. **Don't** use `role="menu"` for it (menu roles are for app-style action menus).

```tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useId, useRef, useState } from "react";

export function MobileNav({ links }: { links: { href: string; label: string }[] }) {
  const [open, setOpen] = useState(false);
  const id = useId();
  const pathname = usePathname();
  const button = useRef<HTMLButtonElement>(null);

  useEffect(() => setOpen(false), [pathname]);                 // close on navigation

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { setOpen(false); button.current?.focus(); } };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [open]);

  return (
    <nav aria-label="Primary" className="md:hidden">
      <button ref={button} type="button" aria-expanded={open} aria-controls={id} onClick={() => setOpen((o) => !o)}
              className="inline-flex min-h-11 items-center gap-2 rounded-md px-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus">
        <span aria-hidden>{open ? "✕" : "☰"}</span> Menu
      </button>
      <ul id={id} hidden={!open} className="absolute inset-x-0 top-full border-b border-line bg-surface-raised p-2">
        {links.map((l) => (
          <li key={l.href}>
            <Link href={l.href} aria-current={pathname === l.href ? "page" : undefined}
                  className="block min-h-11 rounded-md px-3 py-2.5 hover:bg-surface-hover aria-[current=page]:text-brand-text">
              {l.label}
            </Link>
          </li>
        ))}
      </ul>
    </nav>
  );
}
```

The desktop nav renders the same links as a plain visible list. Don't ship two full copies of a large nav; hide with CSS breakpoints on one list where possible.

## 5. Modal dialog (native `<dialog>`)

`showModal()` makes the rest of the page inert, traps Tab inside, closes on Esc (`cancel` event), focuses the first focusable element (or `initialFocus`), and returns focus to the opener on close. Support: all browsers since 2022. `closedby="any"` (light dismiss) is Chromium 134+ and Firefox 141+ only, so the component below handles backdrop clicks itself.

```tsx
"use client";
import { useEffect, useId, useRef, type ReactNode, type RefObject } from "react";

type ModalProps = {
  open: boolean;
  onClose: () => void;                          // parent sets open=false
  title: string;
  description?: string;
  initialFocus?: RefObject<HTMLElement | null>; // e.g. the least destructive button
  children: ReactNode;
};

export function Modal({ open, onClose, title, description, initialFocus, children }: ModalProps) {
  const ref = useRef<HTMLDialogElement>(null);
  const downOnBackdrop = useRef(false);
  const titleId = useId();
  const descId = useId();

  useEffect(() => {
    const d = ref.current;
    if (!d) return;
    if (open && !d.open) { d.showModal(); initialFocus?.current?.focus(); }
    if (!open && d.open) d.close();
  }, [open, initialFocus]);

  return (
    <dialog
      ref={ref}
      aria-labelledby={titleId}
      aria-describedby={description ? descId : undefined}
      onClose={onClose}                                              // fires for Esc, form[method=dialog], and d.close()
      onPointerDown={(e) => { downOnBackdrop.current = e.target === e.currentTarget; }}
      onClick={(e) => { if (downOnBackdrop.current && e.target === e.currentTarget) ref.current?.close(); }} // backdrop click
      className="m-auto w-[min(32rem,calc(100vw-2rem))] rounded-xl border border-line bg-surface-raised p-0 text-fg shadow-3 backdrop:bg-black/50"
    >
      <div className="p-6">                                          {/* fills the dialog so clicks inside never hit the backdrop test */}
        <h2 id={titleId} className="text-lg font-semibold">{title}</h2>
        {description && <p id={descId} className="mt-1 text-fg-muted">{description}</p>}
        <div className="mt-4">{children}</div>
        <form method="dialog" className="mt-6 flex justify-end gap-2">
          <button className="min-h-10 rounded-md px-4 hover:bg-surface-hover">Cancel</button>
        </form>
      </div>
    </dialog>
  );
}
```

```tsx
// usage
const [open, setOpen] = useState(false);
const cancelRef = useRef<HTMLButtonElement>(null);
<button type="button" onClick={() => setOpen(true)}>Delete project</button>
<Modal open={open} onClose={() => setOpen(false)} title="Delete project?" description="This removes 12 deployments. You can't undo it.">
  <button type="button" className="bg-danger text-fg-on-brand …" onClick={() => { deleteProject(); setOpen(false); }}>Delete</button>
</Modal>
```

Rules:
- The title is required (`aria-labelledby`). Destructive dialogs initially focus Cancel.
- Portal isn't needed: `showModal()` renders in the top layer above everything, including `transform` ancestors.
- Entry animation with `@starting-style` (see `speed/references/rendering-smoothness.md` §6). No animation under reduced motion.
- Drawers and sheets: the same element, positioned at an edge (`m-0 ml-auto h-dvh max-h-none`).
- Non-modal overlays (tooltips, simple link popovers): use the `popover` attribute instead of `show()`.

Zero-JS open with invoker commands (Baseline newly available, Safari 26.2+; keep the React version for older browsers):

```html
<button type="button" commandfor="confirm" command="show-modal">Delete project</button>
<dialog id="confirm" aria-labelledby="confirm-title">
  <h2 id="confirm-title">Delete project?</h2>
  <button type="button" commandfor="confirm" command="close">Cancel</button>
</dialog>
```

## 6. Tabs (roving tabindex, automatic activation)

```tsx
"use client";
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";

type TabItem = { id: string; label: string; panel: ReactNode };

export function Tabs({ items, label, defaultId }: { items: TabItem[]; label: string; defaultId?: string }) {
  const base = useId();
  const [active, setActive] = useState(defaultId ?? items[0]?.id);
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);

  function onKeyDown(e: KeyboardEvent<HTMLButtonElement>, i: number) {
    const last = items.length - 1;
    const next =
      e.key === "ArrowRight" ? (i === last ? 0 : i + 1) :
      e.key === "ArrowLeft" ? (i === 0 ? last : i - 1) :
      e.key === "Home" ? 0 :
      e.key === "End" ? last : -1;
    if (next === -1) return;
    e.preventDefault();
    setActive(items[next].id);
    tabs.current[next]?.focus();
  }

  return (
    <div>
      <div role="tablist" aria-label={label} className="flex gap-1 border-b border-line">
        {items.map((t, i) => {
          const selected = t.id === active;
          return (
            <button
              key={t.id}
              ref={(el) => { tabs.current[i] = el; }}
              type="button"
              role="tab"
              id={`${base}-tab-${t.id}`}
              aria-selected={selected}
              aria-controls={`${base}-panel-${t.id}`}
              tabIndex={selected ? 0 : -1}
              onClick={() => setActive(t.id)}
              onKeyDown={(e) => onKeyDown(e, i)}
              className="-mb-px min-h-11 border-b-2 border-transparent px-3 text-fg-muted aria-selected:border-brand aria-selected:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus"
            >
              {t.label}
            </button>
          );
        })}
      </div>
      {items.map((t) => (
        <div key={t.id} role="tabpanel" id={`${base}-panel-${t.id}`} aria-labelledby={`${base}-tab-${t.id}`}
             hidden={t.id !== active} tabIndex={0} className="py-4 focus-visible:outline-2 focus-visible:outline-line-focus">
          {t.panel}
        </div>
      ))}
    </div>
  );
}
```

- Tab moves into the tablist, arrow keys move between tabs, and Tab again moves into the panel.
- Don't animate tab switches triggered by the keyboard.
- If the tab state should survive reload or sharing, put it in the URL (`?tab=`).
- Content in hidden panels is still in the HTML but hidden. Don't put the page's key facts only in non-default tabs.

## 7. Actions menu (menu button)

For app-style action lists ("Rename, Duplicate, Delete"). Use Radix `DropdownMenu` / Base UI `Menu` / React Aria `Menu` when you also need submenus, typeahead, checkbox items or collision-aware positioning.

```tsx
"use client";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";

type Item = { label: string; onSelect: () => void; destructive?: boolean };

export function ActionsMenu({ label, items }: { label: string; items: Item[] }) {
  const [open, setOpen] = useState(false);
  const id = useId();
  const root = useRef<HTMLDivElement>(null);
  const trigger = useRef<HTMLButtonElement>(null);
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const focusAt = (i: number) => refs.current[(i + items.length) % items.length]?.focus();

  const openAt = (i: number) => { setOpen(true); requestAnimationFrame(() => focusAt(i)); };
  const close = (restore = true) => { setOpen(false); if (restore) trigger.current?.focus(); };

  useEffect(() => {
    if (!open) return;
    const onPointerDown = (e: PointerEvent) => { if (!root.current?.contains(e.target as Node)) setOpen(false); };
    document.addEventListener("pointerdown", onPointerDown);
    return () => document.removeEventListener("pointerdown", onPointerDown);
  }, [open]);

  function onTriggerKey(e: KeyboardEvent) {
    if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") { e.preventDefault(); openAt(0); }
    if (e.key === "ArrowUp") { e.preventDefault(); openAt(items.length - 1); }
  }
  function onItemKey(e: KeyboardEvent, i: number) {
    const map: Record<string, () => void> = {
      ArrowDown: () => focusAt(i + 1), ArrowUp: () => focusAt(i - 1),
      Home: () => focusAt(0), End: () => focusAt(items.length - 1),
      Escape: () => close(), Tab: () => close(false),
    };
    const fn = map[e.key];
    if (!fn) return;
    if (e.key !== "Tab") e.preventDefault();
    fn();
  }

  return (
    <div ref={root} className="relative inline-block">
      <button ref={trigger} type="button" aria-haspopup="menu" aria-expanded={open} aria-controls={id}
              onClick={() => (open ? close() : openAt(0))} onKeyDown={onTriggerKey}
              className="min-h-10 rounded-md border border-line px-3 hover:bg-surface-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus">
        {label}
      </button>
      <div id={id} role="menu" aria-label={label} hidden={!open}
           className="absolute right-0 z-20 mt-1 min-w-44 rounded-lg border border-line bg-surface-raised p-1 shadow-2">
        {items.map((it, i) => (
          <button key={it.label} ref={(el) => { refs.current[i] = el; }} type="button" role="menuitem" tabIndex={-1}
                  onClick={() => { close(); it.onSelect(); }} onKeyDown={(e) => onItemKey(e, i)}
                  className={`block w-full min-h-9 rounded-md px-3 text-left hover:bg-surface-hover focus:bg-surface-hover focus:outline-hidden ${it.destructive ? "text-danger" : ""}`}>
            {it.label}
          </button>
        ))}
      </div>
    </div>
  );
}
```

- Menu items get `tabIndex={-1}` and are reached with arrows. Focus shows as a background highlight (`focus:bg-surface-hover`), which must reach ≥ 3:1 against the menu surface or be paired with a ring.
- Right-click context menus appear instantly, with no animation.

## 8. Toasts and live regions

Recommended: `sonner` (~9.4 KB gzip), mounted once in the root layout, wired to tokens:

```tsx
// app/layout.tsx
import { Toaster } from "sonner";
<Toaster position="bottom-right" toastOptions={{ className: "bg-surface-raised text-fg border border-line" }} />
// anywhere in a client component
import { toast } from "sonner";
toast.success("Project renamed");
toast.error("Couldn't save. Check your connection and try again.");
```

Zero-dependency version. The regions exist from first render and only their text changes:

```tsx
"use client";
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";

type Toast = { id: number; text: string; kind: "status" | "alert" };
const Ctx = createContext<(text: string, kind?: Toast["kind"]) => void>(() => {});
export const useToast = () => useContext(Ctx);

export function ToastProvider({ children }: { children: ReactNode }) {
  const [toasts, setToasts] = useState<Toast[]>([]);
  const dismiss = (id: number) => setToasts((t) => t.filter((x) => x.id !== id));
  const notify = useCallback((text: string, kind: Toast["kind"] = "status") => {
    const id = Date.now() + Math.random();
    setToasts((t) => [...t, { id, text, kind }]);
    if (kind === "status") setTimeout(() => dismiss(id), 6000);   // errors stay until dismissed
  }, []);

  const list = (kind: Toast["kind"]) =>
    toasts.filter((t) => t.kind === kind).map((t) => (
      <div key={t.id} className="pointer-events-auto flex items-start gap-3 rounded-lg border border-line bg-surface-raised px-4 py-3 text-fg shadow-2">
        <p className="flex-1">{t.text}</p>
        <button type="button" onClick={() => dismiss(t.id)} aria-label="Dismiss notification" className="text-fg-muted hover:text-fg">✕</button>
      </div>
    ));

  return (
    <Ctx.Provider value={notify}>
      {children}
      <div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-[min(24rem,calc(100vw-2rem))] flex-col gap-2">
        <div role="status" aria-live="polite" className="flex flex-col gap-2">{list("status")}</div>
        <div role="alert" aria-live="assertive" className="flex flex-col gap-2">{list("alert")}</div>
      </div>
    </Ctx.Provider>
  );
}
```

- Toasts are for the results of mutations, never for navigation or selection.
- Position them `fixed` so they don't cause layout shift. Keep them visible ≥ 6 s, and errors until dismissed. Never put the only copy of important information in a toast.
- An action inside a toast (Undo) must also be reachable another way, or the toast persists until handled.

## 9. Form field with error

```tsx
"use client";
import { useId } from "react";

export function EmailField({ error }: { error?: string }) {
  const id = useId();
  const errId = `${id}-error`;
  const hintId = `${id}-hint`;
  return (
    <div className="grid gap-1.5">
      <label htmlFor={id} className="text-sm font-medium text-fg">Work email</label>
      <input
        id={id} name="email" type="email" autoComplete="email" spellCheck={false} required
        aria-invalid={error ? true : undefined}
        aria-describedby={`${hintId}${error ? ` ${errId}` : ""}`}
        className="min-h-11 rounded-md border border-line-strong bg-bg px-3 text-base text-fg placeholder:text-fg-subtle aria-[invalid=true]:border-danger focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-line-focus"
        placeholder="dana@company.com"
      />
      <p id={hintId} className="text-sm text-fg-muted">We'll send the invite here.</p>
      {error && <p id={errId} className="text-sm text-danger">⚠ {error}</p>}
    </div>
  );
}
```

On submit with errors: `form.querySelector<HTMLElement>("[aria-invalid=true]")?.focus()`. Server Actions: return field errors and render them the same way. `useActionState` keeps the typed values.

## 10. Checklist per widget

| Widget | Role / element | Keyboard | State attributes |
|---|---|---|---|
| Button | `<button type="button">` | Enter, Space | `aria-pressed` (toggle), `aria-expanded` (disclosure), `disabled`/`aria-disabled` |
| Link | `<a href>` | Enter | `aria-current="page"` for the current nav item |
| Disclosure | `<details>` or button + region | Enter, Space | `aria-expanded`, `aria-controls` |
| Modal dialog | `<dialog>` + `showModal()` | Tab trapped, Esc closes | `aria-labelledby`, `aria-describedby` |
| Tabs | `tablist` / `tab` / `tabpanel` | ←/→, Home/End | `aria-selected`, `aria-controls`, roving `tabIndex` |
| Menu button | button + `menu` / `menuitem` | ↓/↑ open, arrows move, Esc closes and returns focus | `aria-haspopup="menu"`, `aria-expanded` |
| Combobox / select | Use Radix/Base UI/React Aria, or native `<select>` | Arrows, typeahead, Enter, Esc | `aria-expanded`, `aria-activedescendant` |
| Switch | `<button role="switch">` or `<input type="checkbox" role="switch">` | Space | `aria-checked` |
| Toast | `role="status"` / `role="alert"` region | Dismiss button reachable | — |
| Carousel | `region` + `aria-roledescription="carousel"`, slides as groups | Prev/next buttons, pause button if autoplay | `aria-live="off"` while autoplaying, `polite` when manual |
| Tooltip | `popover` or primitive; supplements, never replaces, a label | Shows on focus, Esc hides | `aria-describedby` |

## Sources

- https://www.w3.org/WAI/ARIA/apg/patterns/ (dialog-modal, tabs, menu-button, disclosure, carousel, switch)
- https://www.w3.org/TR/WCAG22/ · https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum
- https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element
- https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog · https://developer.mozilla.org/en-US/docs/Web/API/Popover_API
- https://api.webstatus.dev/v1/features/dialog-closedby · …/invoker-commands · …/popover · …/details-name
- https://tailwindcss.com/docs/outline-style
- https://www.radix-ui.com/primitives · https://base-ui.com · https://react-spectrum.adobe.com/react-aria/ · https://sonner.emilkowal.ski
- https://bundlephobia.com (sizes checked 2026-09-16: @radix-ui/react-dialog 1.1.23, react-tabs, react-popover; @base-ui/react 1.8.0; react-aria-components 1.21.1; sonner 2.0.8)


---

<!-- https://void-design.vercel.app/docs/audit · updated 2026-09-16 -->

# audit — measure, fix, re-measure, stop

`void` is the CLI in this plugin (`packages/cli`, binary `void`, runs on Bun ≥ 1.2 with a local Chromium). Every finding has a stable rule id (`perf/lcp-slow`, `seo/metadata-in-body`, `lint/tw-v3-arbitrary-var`). The fixes live in the `speed`, `seo`, `a11y`, `craft` and `motion` skills. This skill is about the loop.
Full flag reference: `references/commands.md`. CI: `references/ci.md`.

## The 10 rules that matter most

1. **Run `void lint` after every significant edit.** It's static, needs no browser, and finishes in seconds. Fix its errors before moving on.
2. **Run `void audit` only against a production build** (`next build` + `next start`, or a deployed preview). **Never against `next dev`**: dev ships unminified JS, HMR and no prefetch, so the numbers are meaningless.
3. **Use `--start "cmd" --port N`** so void starts the server, waits for it, audits, and stops it. Don't leave background servers running, and don't `pkill -f "next start"` (it can kill your own shell).
4. **Read `--format md`** (compact, grouped by rule, top 5 targets each). Use `--json` only when parsing programmatically.
5. **Fix in order: gates → errors → budgets → warnings.** `info` findings are review prompts and never fail a run.
6. **Group by rule id, not by occurrence.** One root cause (a layout component, a token, a helper) usually explains every instance. Fix it once.
7. **Look up unfamiliar ids with `void rules <id>`** (why, fix, example) before editing. Don't guess.
8. **Re-run only the category you touched** (`void a11y …`, `void seo …`), then one full `void audit` at the end.
9. **Fix the user experience, not the metric.** Never hide content from the lab, special-case headless browsers or user agents, delay loading until after measurement, or delete a feature to make a finding disappear.
10. **Stop when the stopping criteria below are met.** Don't chase the last point of a noisy perf score with risky refactors. Report remaining warnings to the user instead.

## When to run what

| Moment | Command | Time | Needs |
|---|---|---|---|
| After editing components, CSS, metadata, config | `void lint` (or `void lint src`) | 1–5 s | nothing |
| Unsure what a finding means | `void rules <id>` · `void rules perf` (list a category) | instant | nothing |
| Finished a page or feature | `void <category> --start "<start cmd>" --port <N>` for the categories you touched | 20–90 s | production build |
| Before saying "done" | `void audit --start "<start cmd>" --port <N> --format md` | 1–4 min | production build |
| Deployed preview / production | `void audit https://preview-url.example --format md` | 1–4 min | URL |
| Setup problems (no Chromium, launch failure, exit code 4) | `void doctor` | 5 s | nothing |
| Compare before/after | `--out before.json`, fix, `--out after.json`, then `void report after.json --format md` | — | saved reports |
| Borrow a reference site's design system | `void extract <url> --out ./dna` (see the `inspire` skill) | 30–60 s | URL |

Next.js recipe:

```bash
bun run build                                                        # or: npx next build
void audit --start "bun run start -p 3100" --port 3100 --format md    # or: npx next start -p 3100
```

If `void.config.ts` defines `start`, `routes` and `src` (the template's does), just run `void audit --format md`.

## Categories

| Category | What it measures | Typical first fixes |
|---|---|---|
| `perf` | LCP, CLS, TBT, FCP, TTFB, JS/CSS/font/image bytes, requests, third parties, DOM size, bfcache, cache headers, compression (median of `--runs`, mobile + desktop) | `speed` skill: LCP image, client JS, fonts |
| `smooth` | Scripted scroll (dropped frames, long animation frames), scripted clicks (INP), layout shifts after input or scroll, animated layout properties, reduced-motion, idle rAF loops, WebGL contexts | `speed` references `rendering-smoothness.md`, `react-pitfalls.md` |
| `a11y` | axe-core WCAG 2.2 AA + best practices, keyboard focus visibility, target size on mobile, 320px reflow, zoom disabled | `a11y` skill |
| `seo` | Status codes, redirects, soft 404, title/description/canonical in `<head>`, h1, links, OG and icons, robots.txt, sitemap, hreflang, HTML size | `seo` skill + `next-metadata.md` |
| `geo` | Raw-HTML vs rendered content (bot UAs), streamed/hidden content, JSON-LD validity/required props/visibility, AI bot robots policy, llms.txt, Markdown mirrors, answer-first/citation heuristics | `seo` skill + `structured-data.md`, `ai-crawlers.md` |
| `design` | Font families, type scale, line length, contrast of body text, palette and accent sprawl, spacing grid, radius sprawl, dark mode, AI-slop tells, horizontal overflow | `craft` skill |
| `lint` | Static source: Tailwind v4 correctness, Next 16 APIs, React render-body bugs, a11y markup, SEO files, motion hygiene | the id's skill |

## Reading a report

```
**FAIL** (fail-on error) · overall 78 · perf 71 · smooth 88 · a11y 92 · seo 49 · geo 80 · design 85
3 errors, 9 warnings, 4 info · mobile+desktop

Budgets exceeded: jsKb@mobile 212 KB > 170 KB

**ERROR** `seo/canonical-missing` ×4: Canonical link is missing
- No <link rel="canonical"> in <head> of raw HTML · `http://localhost:3100/pricing`
- Fix: Add alternates.canonical per page via pageMetadata().
- Docs: https://void-design.vercel.app/rules/seo/canonical-missing
```

- **Severity:** `error` = broken for users or crawlers (fails with the default `--fail-on error`); `warn` = measurable loss; `info` = heuristic or low-evidence advice.
- **Gates:** a few failures cap the category score at **49** no matter what else passes. SEO gates: `seo/http-status`, `seo/noindex`, `seo/canonical-missing`, `seo/canonical-invalid`, `seo/canonical-broken`, `seo/robots-blocks-all`, `seo/sitemap-url-status`. GEO gates: `geo/js-dependency` (main content not in raw HTML), `geo/jsonld-parse-error`. Perf: poor LCP (> 4 s mobile, > 2.5 s desktop) or CLS > 0.25. Fix these first.
- **Budgets:** any metric over its budget fails the run (exit 1) even with zero error findings.
- **Scores (0–100):** per category, then weighted overall (perf 25%, a11y 20%, smooth 15%, seo 15%, design 15%, geo 10%). Perf and smooth blend metric curves with findings; other categories subtract per-finding penalties (each rule counted at most twice).
- **Exit codes:** `0` pass · `1` findings ≥ `--fail-on` or budget exceeded · `2` usage/config error · `3` URL unreachable or server didn't start · `4` browser failed (run `void doctor`).
- **"Could not complete"** lists audits that crashed. Treat that category as unmeasured, not as passing.

## Fixing efficiently

1. Take the top group. Run `void rules <id>` if the fix isn't obvious.
2. Find the source: `target.file:line` for lint; `selector` or `html` for runtime findings. Search the codebase for the selector's class names or the text.
3. Fix the **shared** cause: the layout, the `pageMetadata` helper, the Button component, the token, the font loader.
4. `void lint`, rebuild, then re-run **that category only**: `void seo --start "…" --port N --format md`.
5. Repeat until the category passes, then move to the next. Finish with one full `void audit`.

Tips:
- `--routes /,/pricing,/blog/hello` audits representative templates (one per route type), not every page.
- `--profile mobile` halves perf/smooth time while iterating. The final run uses both profiles.
- `--runs 5` when perf numbers are near a budget. Lab variance of ±10–20% between runs is normal. Trust medians, not a single run.
- `--max-per-rule 2` keeps the markdown output short in big reports.
- Rebuild after every code change before re-auditing. `next start` serves the old build.
- A finding that you've verified is a false positive, or an intentional choice: turn it off in `void.config.ts` → `rules: { "design/no-dark-mode": "off" }` with a comment explaining why. Never do this for gates.

## `void.config.ts`

```ts
// void.config.ts (project root; auto-detected). Plain object: no import needed.
/** @type {import('@void/cli/config').VoidConfig} */
const config = {
  kind: "marketing",                   // default budgets: "marketing" (JS ≤170 KB) or "app" (JS ≤300 KB)
  src: "src",                          // also used by `void lint` with no args, and linted during `void audit`
  start: { command: "bun run start -p 3100", port: 3100, readyPath: "/", timeoutMs: 30000 },
  routes: ["/", "/pricing", "/blog/hello-world", "/this-page-does-not-exist"], // include a 404 probe
  profiles: ["mobile", "desktop"],
  runs: 3,
  budgets: {                           // override defaults per profile; ms, KB (transfer), CLS unitless
    mobile: { lcp: 2500, cls: 0.1, inp: 200, tbt: 200, ttfb: 800, jsKb: 170, cssKb: 25, fontKb: 120, imageKb: 1000, htmlKb: 30 },
    desktop: { lcp: 1500, cls: 0.05, inp: 100, tbt: 100, ttfb: 600 },
  },
  rules: { "geo/no-statistics": "off" }, // severity override: "error" | "warn" | "info" | "off"
  failOn: "error",                     // "error" | "warn" | "info" | "never"
  lighthouse: false,                   // true = also run Lighthouse (optional peer dep: bun add -d lighthouse)
};
export default config;
```

CLI flags override the file (`--kind app`, `--fail-on warn`, `--routes`, `--runs`, `--profile`).

## Stopping criteria (declare done only when all hold)

| Check | Target |
|---|---|
| `void lint` | 0 errors |
| `void audit` exit code | 0 (no errors, all budgets pass) against a production build, both profiles |
| Gates | none failed in any category |
| perf score | ≥ 90 mobile on marketing routes (≥ 80 app routes) |
| a11y score | 100 (0 axe violations), plus a manual keyboard pass over dialogs, menus and forms |
| seo score | ≥ 95 |
| geo score | ≥ 90. `info` heuristics reviewed, not necessarily fixed |
| smooth | no `smooth/*` errors; scroll jank and INP warnings fixed or explained |
| design | no errors; warnings reviewed against the chosen direction (`craft` skill) |

When a target can't be met for a real reason (a required third-party script, a client-mandated video hero), say so explicitly in your summary with the measured number and the cause. Don't silence the rule.

## Anti-patterns: fixing the metric instead of the experience

| Don't | Why it's wrong | Do instead |
|---|---|---|
| Detect headless/Lighthouse/bot UAs and serve a lighter page | Cloaking. Real users stay slow; search engines penalize it | Make the real page light |
| Delay all JS with `setTimeout(…, 5000)` so TBT drops | INP and real interactions get worse | Ship less JS, `lazyOnload` third parties, facades |
| Start the hero at `opacity: 0` to push LCP onto something else | Users see a blank hero | Visible hero, animate secondary elements |
| Add `aria-label` everywhere to silence axe | Wrong or duplicated names hurt screen-reader users | Visible text, correct native elements |
| `aria-hidden` or `display:none` on offending content | Removes content for users | Fix contrast, names or structure |
| Add FAQPage or fake `aggregateRating` to "complete" JSON-LD | Policy violation; no rich result | Only mark up visible, true facts |
| Set `failOn: "never"`, raise budgets or turn off rules to go green | The gate stops protecting users | Fix, or document an explicit exception |
| Audit `next dev`, or a page with a warm cache only | Wrong numbers | Production build, default cold runs |
| Delete a feature to clear a warning (e.g. remove all motion) | Loses the design intent | Fix the implementation (compositor-only, gated, reduced-motion aware) |

## Output to the user

Finish with a short summary: the command run, pass/fail, scores per category, budgets that were close (within 10%), what you fixed (grouped by rule id), and what remains (with ids and reasons). Include the report path if you used `--out`.


---

<!-- https://void-design.vercel.app/docs/audit/ci · updated 2026-09-16 -->

# Running void in CI

Two gates:
1. **`void lint`** on every push. It's fast, needs no browser or build, and catches Tailwind v4, Next 16, React and a11y markup bugs.
2. **`void audit`** on pull requests against a **production build** (started by void) or the **preview deployment URL**.

Run perf measurements on a quiet runner, one job at a time. Parallel Chromium instances on one machine distort CPU-throttled numbers. Use `runs: 3` or more and trust medians.

## 1. GitHub Actions: lint + audit a local production build

```yaml
# .github/workflows/void.yml
name: void
on:
  pull_request:
  push:
    branches: [main]

concurrency:
  group: void-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install --frozen-lockfile
      - run: bunx void lint --format md --fail-on error

  audit:
    runs-on: ubuntu-latest
    needs: lint
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install --frozen-lockfile
      - name: Install Chromium
        run: bunx playwright-core install --with-deps chromium   # or: sudo apt-get install -y chromium
      - run: bunx void doctor
      - name: Build
        run: bun run build
        env:
          NEXT_PUBLIC_SITE_URL: https://www.example.com          # canonical/OG URLs use the production origin
      - name: Audit
        run: |
          bunx void audit \
            --start "bun run start -p 3100" --port 3100 \
            --routes /,/pricing,/blog/hello-world,/this-page-does-not-exist \
            --format md --out void-report.json > void-report.md
      - name: Job summary
        if: always()
        run: cat void-report.md >> "$GITHUB_STEP_SUMMARY"
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: void-report
          path: |
            void-report.json
            void-report.md
```

Notes:
- The audit step fails the job with exit 1 on errors or exceeded budgets. That's the gate. Exit 3 (server didn't start) and 4 (browser) mean infrastructure problems: read the printed server log tail or `void doctor`.
- When `void.config.ts` already defines `start`, `routes` and `budgets` (the template does), the step is just `bunx void audit --format md --out void-report.json > void-report.md`.
- `bunx void` assumes `@void/cli` is a dev dependency. In this monorepo use `bun run void …` (root `package.json` script).
- Don't set `VERCEL_ENV` in CI builds you audit, or the template's `robots.ts` serves `Disallow: /` and `seo/robots-blocks-all` fires. That's correct behavior for previews, wrong for this job.

## 2. Audit the Vercel preview deployment

```yaml
# .github/workflows/void-preview.yml
name: void preview
on: deployment_status
jobs:
  audit:
    if: github.event.deployment_status.state == 'success'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install --frozen-lockfile
      - run: bunx playwright-core install --with-deps chromium
      - name: Audit preview (perf, smooth, a11y, design)
        run: bunx void audit "${{ github.event.deployment_status.environment_url }}" --only perf,smooth,a11y,design --format md > void-report.md
      - if: always()
        run: cat void-report.md >> "$GITHUB_STEP_SUMMARY"
```

Previews correctly send `Disallow: /` / `noindex`, so run the `seo` and `geo` gates against the local production build (§1) or against production after deploy. Vercel preview protection must allow the runner, e.g. with a protection-bypass header or a public preview.

## 3. Baselines and regressions

```bash
# main branch: save a baseline artifact
bunx void audit --start "bun run start -p 3100" --port 3100 --json > baseline.json
# PR: compare
bunx void audit --start "bun run start -p 3100" --port 3100 --json > pr.json
jq -n --slurpfile a baseline.json --slurpfile b pr.json \
  '[$a[0].scores, $b[0].scores] | {before: .[0], after: .[1]}'
jq -n --slurpfile a baseline.json --slurpfile b pr.json \
  '($a[0].findings | map(.fingerprint)) as $old | $b[0].findings | map(select(.fingerprint as $f | $old | index($f) | not)) | map({id, severity, message})'
```

The second `jq` lists findings that are new in the PR (by `fingerprint` = rule id + target).

## 4. Other CI systems

Any runner with Bun and a Chromium works:

```bash
curl -fsSL https://bun.sh/install | bash
apt-get update && apt-get install -y chromium        # Debian/Ubuntu images
export VOID_CHROMIUM=/usr/bin/chromium
bun install && bun run build
bunx void audit --start "bun run start -p 3100" --port 3100 --format md
```

Containers: Chromium runs with `--no-sandbox` inside void. Give the job ≥ 2 vCPU and don't run other heavy jobs on the same machine during perf audits.

## 5. Recommended gate policy

| Branch / event | Command | `failOn` |
|---|---|---|
| Every push | `void lint` | error |
| Pull request | `void audit` (local production build) | error + budgets |
| Preview deploy | `void audit <preview> --only perf,smooth,a11y,design` | error |
| Nightly on production | `void audit https://www.example.com --runs 5 --out nightly.json` | warn (report only, notify on regressions) |

Loosen severities in `void.config.ts` `rules` with a comment and an owner. Never turn off gate rules (`seo/http-status`, `seo/noindex`, `seo/canonical-*`, `seo/robots-blocks-all`, `seo/sitemap-url-status`, `geo/js-dependency`, `geo/jsonld-parse-error`) or set `failOn: "never"` on PR checks.


---

<!-- https://void-design.vercel.app/docs/audit/commands · updated 2026-09-16 -->

# `void` command reference

Written from `packages/cli/src/cli/*.ts` (void 0.1.0, 2026-09-16). If `void --help` disagrees with this file, trust `void --help` and report the drift.

Install and run: the plugin ships the CLI in `packages/cli` (Bun ≥ 1.2). Run `bunx void …` or `bun packages/cli/src/index.ts …` from a checkout, or `void …` when it's linked or installed globally. It needs a Chromium: resolved from `VOID_CHROMIUM` → `CHROME_PATH` → system paths (`/usr/bin/chromium`, Chrome.app…) → the Playwright cache. `void doctor` shows what it found.

## Commands

| Command | Purpose | Exit |
|---|---|---|
| `void audit [url]` | Run every page category: perf, smooth, a11y, seo, geo, design (+ lint when `src` is set) | 0/1/2/3/4 |
| `void perf [url]` | Only performance (web-vitals + resources [+ Lighthouse]) | same |
| `void smooth [url]` | Only smoothness (scripted scroll, interactions, animations, reduced motion, rAF, WebGL) | same |
| `void a11y [url]` | Only accessibility (axe WCAG 2.2 AA, focus visibility, target size, reflow) | same |
| `void seo [url]` | Only SEO (status, head, canonical, links, OG, icons, robots, sitemap) | same |
| `void geo [url]` | Only AI search (raw vs rendered content, JSON-LD, AI robots, llms.txt, Markdown mirrors) | same |
| `void design [url]` | Only design heuristics (type, color, spacing, overflow, slop tells) | same |
| `void lint [dir]` | Static source checks, no browser. `dir` defaults to config `src`, else `.` | 0/1/2 |
| `void rules` | List all rules | 0 |
| `void rules <id>` | Show one rule: title, why, fix, example, refs | 0 / 2 if unknown (suggests near matches) |
| `void rules <category\|prefix>` | List a category (`void rules perf`) or prefix (`void rules seo/canonical`) | 0 |
| `void report <report.json>` | Re-render a saved JSON report (`--format md\|pretty\|json`) | 0 if report passed, else 1 |
| `void extract <url>` | Extract a site's design DNA (tokens, type, color) to `dna.json`, `brief.md`, `direction.css`. *(Module in progress: may print "not available in this build")* | 0/2/4 |
| `void doctor` | Check Bun, Chromium launch, playwright-core, axe-core, web-vitals, optional Lighthouse, config | 0/2/4 |

`url` may be omitted when `--start` or config `url`/`start` provides it. Bare hosts get a scheme: `localhost:3000` → `http://`, `example.com` → `https://`.

## Audit flags (audit and the category shorthands)

| Flag | Values / default | Notes |
|---|---|---|
| `--only` | `perf,smooth,a11y,seo,geo,design,lint` | Comma list. The shorthands set this for you |
| `--profile` | `mobile` \| `desktop` \| `both` (default both) | mobile = 412×823 @2.625x, 4× CPU, 150 ms RTT, 1.6/0.75 Mbps; desktop = 1350×940, unthrottled |
| `--routes` | `/,/pricing` | Paths resolved against the URL. Default: the URL's own path |
| `--runs` | 1–20 (default 3) | Perf runs per profile; the median is reported. Use 5 near budgets, 1 for quick iteration |
| `--lighthouse` | boolean | Also run Lighthouse 13 (optional peer: `bun add -d lighthouse`) |
| `--start` | `"bun run start -p 3100"` | Spawns the command (with `PORT` and `NODE_ENV=production` set), waits for 2xx on `readyPath`, audits, then kills the process group. **Requires `--port`** |
| `--port` | 1–65535 | Port the started server listens on. The URL becomes `http://localhost:<port>` |
| `--src` | `./src` | Also lint this directory as part of the audit |
| `--kind` | `marketing` \| `app` | Selects default budgets (JS 170 vs 300 KB, etc.) |
| `--screenshots` | dir | Save fold + full-page screenshots (design audit) |
| `--format`, `-f` | `pretty` (TTY default) \| `md` \| `json` | `md` = compact for agents, grouped by rule |
| `--json` | boolean | Same as `--format json`; only JSON on stdout, progress on stderr |
| `--out`, `-o` | `report.json` \| `report.md` | Also write the report to a file (format from extension) |
| `--max-per-rule` | number (md default 5) | Occurrences listed per rule |
| `--fail-on` | `error` (default) \| `warn` \| `info` \| `never` | Exit 1 threshold. Budgets also fail unless `never` |
| `--config`, `-c` | path | Default: `void.config.ts` / `.mts` / `.js` / `.mjs` in cwd |
| `--quiet`, `-q` | boolean | No progress output |
| `--no-color` | boolean | Plain output |
| `--help`, `-h` / `--version`, `-v` | | |

Lint uses `--config`, `--fail-on`, `--format`/`--json`, `--out`, `--no-color`.
Extract uses `--out <dir>`, `--screenshots <dir>`, `--pages /,/pricing`, `--max-pages N`, `--format md|css|json`, `--quiet`.

Environment: `VOID_CHROMIUM` / `CHROME_PATH` (browser binary), `VOID_DEBUG=1` (stack traces), `NO_COLOR`, `CI` (plain progress lines).

## Exit codes

| Code | Meaning | What to do |
|---|---|---|
| 0 | Pass: no findings at or above `--fail-on`, all budgets pass | Done (check stopping criteria) |
| 1 | Findings ≥ `--fail-on`, or a budget exceeded | Fix (see the `audit` SKILL loop) |
| 2 | Usage or config error (bad flag, invalid `void.config.ts`, unknown rule id) | Read the message and hint |
| 3 | Target unreachable, or `--start` server exited / timed out | Check the build, port and `readyPath`; the last 10 server log lines are printed |
| 4 | Browser launch failure | `void doctor`; install Chromium or set `VOID_CHROMIUM` |

## `void.config.ts` schema (`VoidConfig`)

| Key | Type | Default | Meaning |
|---|---|---|---|
| `url` | string (absolute) | — | Base URL when none is passed |
| `routes` | string[] | URL path | Paths to audit |
| `start` | `{ command, port, cwd?, readyPath?, timeoutMs? }` | — | Server to spawn (`readyPath` "/", timeout 120 s) |
| `src` | string | — | Source dir for lint (used by `void lint` and added to `void audit`) |
| `profiles` | `("mobile" \| "desktop")[]` | both | |
| `runs` | 1–20 | 3 | Perf runs per profile |
| `kind` | `"marketing" \| "app"` | marketing | Default budget set |
| `budgets` | `{ mobile?: Budgets, desktop?: Budgets }` | see below | Merged over the defaults per key |
| `rules` | `Record<ruleId, "error" \| "warn" \| "info" \| "off">` | — | Severity overrides |
| `failOn` | `"error" \| "warn" \| "info" \| "never"` | error | |
| `lighthouse` | boolean | false | |
| `screenshots` | string | — | Directory |
| `only` | Category[] | all page categories | |

`Budgets` keys: `lcp`, `inp`, `tbt`, `ttfb` (ms) · `cls` · `jsKb`, `cssKb`, `fontKb`, `imageKb`, `htmlKb` (KB transferred at load) · `requests` · `droppedFramePct` (%).

Default budgets:

| | lcp | cls | inp | tbt | ttfb | jsKb | cssKb | fontKb | imageKb | htmlKb | droppedFramePct |
|---|---|---|---|---|---|---|---|---|---|---|---|
| marketing mobile | 2500 | 0.1 | 200 | 200 | 800 | 170 | 25 | 120 | 1000 | 30 | 5 |
| marketing desktop | 1500 | 0.1 | 200 | 200 | 800 | 170 | 25 | 120 | 1500 | 30 | 5 |
| app mobile | 2500 | 0.1 | 200 | 300 | 800 | 300 | 40 | 120 | — | 60 | 5 |
| app desktop | 1500 | 0.1 | 200 | 200 | 800 | 300 | 40 | 120 | — | 60 | 5 |

The config file may `export default` a plain object (as the template does, with a JSDoc `@type {import('@void/cli/config').VoidConfig}`) or use `defineConfig` from `@void/cli/config` when the package is installed.

## Report JSON (`--json`), abridged

```ts
{
  schemaVersion: 1,
  tool: { name: "void", version: "0.1.0" },
  targets: [{ url, profile, status }],
  scores: { perf, smooth, a11y, seo, geo, design, lint },   // 0–100; gates cap a category at 49
  overall: number,                                          // weighted: perf .25 a11y .2 smooth .15 seo .15 design .15 geo .1
  metrics: [{ name: "lcp", value: 1830, unit: "ms", profile: "mobile", url, runs: [..], budget: 2500 }],
  budgets: [{ name, limit, actual, pass, profile, url }],
  findings: [{
    id: "perf/lcp-image-lazy", category: "perf", severity: "error",
    message: "LCP image /hero.avif has loading=lazy …",
    source: "web-vitals" | "axe" | "lighthouse" | "loaf" | "trace" | "cdp" | "dom" | "ast" | "css" | "http" | "heuristic",
    target: { url, profile, selector, html, file, line, column, snippet },
    evidence: { metric: { name, value, unit, threshold }, data: {…} },
    fix: { hint, example }, docs: "https://void-design.vercel.app/rules/perf/lcp-image-lazy", fingerprint
  }],
  errors: [{ category, message, url, profile }],            // audits that could not complete
  failOn: "error",
  summary: { errors, warnings, infos, pass }
}
```

Useful `jq`:

```bash
void audit --json --start "bun run start -p 3100" --port 3100 > report.json
jq -r '.findings | group_by(.id)[] | "\(.[0].severity)\t\(length)\t\(.[0].id)"' report.json | sort -r   # counts per rule
jq '.budgets[] | select(.pass == false)' report.json
jq '.scores' report.json
```

## Rule id prefixes

| Prefix | Examples |
|---|---|
| `perf/` | `lcp-slow`, `cls-high`, `tbt-high`, `ttfb-slow`, `js-budget`, `lcp-image-lazy`, `lcp-image-no-priority`, `lcp-background-image`, `images-missing-dimensions`, `third-party-heavy`, `bfcache-blocked`, `cache-headers`, `no-text-compression` |
| `smooth/` | `scroll-jank`, `long-frames-during-scroll`, `inp-slow`, `layout-shift-after-input`, `animate-layout-property`, `reduced-motion-ignored`, `raf-loop-idle`, `multiple-webgl-contexts` |
| `a11y/` | axe ids (`color-contrast`, `button-name`, `label`, `link-name`, `image-alt`, `heading-order`, `landmark-one-main`, `meta-viewport`, `bypass`…), plus `target-size`, `focus-not-visible`, `reflow-overflow`, `axe-other` |
| `seo/` | `http-status`, `soft-404`, `metadata-in-body`, `canonical-missing`, `canonical-not-self`, `title-length`, `meta-description-missing`, `h1-count`, `og-image-invalid`, `favicon`, `robots-blocks-all`, `sitemap-lastmod`, `broken-links` |
| `geo/` | `js-dependency`, `hidden-streamed-content`, `jsonld-parse-error`, `jsonld-required`, `jsonld-not-visible`, `faq-deprecated`, `search-bots-blocked`, `llms-txt-missing`, `markdown-mirror-indexable`, `answer-first`, `no-citations` |
| `design/` | `too-many-font-families`, `no-type-scale`, `line-length-long`, `palette-sprawl`, `multiple-accents`, `gradient-text`, `purple-gradient`, `emoji-icons`, `spacing-off-grid`, `horizontal-overflow` |
| `lint/` | `tw-unknown-class`, `tw-v3-arbitrary-var`, `tw-v3-renamed`, `page-level-use-client`, `image-priority-deprecated`, `heavy-import`, `browser-global-in-render`, `timer-in-render`, `outline-none-no-replacement`, `canonical-in-root-layout`, `og-merge-drops-parent`, `not-found-in-suspense`, `dynamic-metadata-streaming`, `sitemap-lastmod-now`, `route-js-budget` |

`void rules --json` prints the full registry (the source of truth; the website renders `/rules/<id>` from it).


---

<!-- https://void-design.vercel.app/cli · updated 2026-09-16 -->

# void CLI reference

void is a Bun CLI. It audits a running website in real Chromium on a throttled mobile and a desktop profile, lints source without a browser, extracts design systems from sites you admire, and explains every rule it can report.

## Commands

| Command | What it does | Exit codes |
| --- | --- | --- |
| `void audit [url]` | Run every page category: perf, smooth, a11y, seo, geo, design, plus lint when a source directory is configured. | 0 · 1 · 2 · 3 · 4 |
| `void perf [url]` | Core Web Vitals and bytes from web-vitals and resource timing, median of --runs, optional Lighthouse. | 0 · 1 · 2 · 3 · 4 |
| `void smooth [url]` | Scripted scroll and interactions: dropped frames, long animation frames, INP, shifts after input, reduced motion, idle rAF, WebGL contexts. | 0 · 1 · 2 · 3 · 4 |
| `void a11y [url]` | axe-core WCAG 2.2 AA, keyboard focus visibility, target size on mobile, 320px reflow. | 0 · 1 · 2 · 3 · 4 |
| `void seo [url]` | Status codes, head placement per user agent, canonical, titles, h1, links, Open Graph, icons, robots.txt, sitemap. | 0 · 1 · 2 · 3 · 4 |
| `void geo [url]` | Raw HTML versus rendered content for AI crawlers, JSON-LD, AI robots policy, llms.txt, Markdown mirrors. | 0 · 1 · 2 · 3 · 4 |
| `void design [url]` | Fonts, type scale, line length, palette and accent sprawl, spacing grid, radius sprawl, generated-UI tells, overflow. | 0 · 1 · 2 · 3 · 4 |
| `void lint [dir]` | Static source checks, no browser. Defaults to the config's src, else the current directory. | 0 · 1 · 2 |
| `void extract <url>` | Measure a site's design DNA and write dna.json, brief.md and direction.css. | 0 · 2 · 4 |
| `void rules [id\|category]` | List all rules, a category or prefix, or explain one rule: why, fix, example, refs. | 0 · 2 unknown id |
| `void report <report.json>` | Re-render a saved JSON report as pretty, md or json. | 0 if it passed, else 1 |
| `void doctor` | Check Bun, Chromium launch, playwright-core, axe-core, web-vitals, optional Lighthouse and the config file. | 0 · 2 · 4 |

## Audit flags

| Flag | Values | Notes |
| --- | --- | --- |
| `--only` | perf,smooth,a11y,seo,geo,design,lint | Categories to run. The shorthand commands set this. |
| `--profile` | mobile \| desktop \| both | Default both. Mobile is 412×823 at 2.625x with 4× CPU and 150 ms RTT; desktop is 1350×940 unthrottled. |
| `--routes` | /,/pricing | Paths resolved against the URL. Default: the URL's own path. |
| `--runs` | 1–20 (3) | Perf runs per profile; the median is reported. |
| `--start` | "bun run start -p 3100" | Start a server, wait for a 2xx on readyPath, audit, stop the process group. Requires --port. |
| `--port` | 1–65535 | Port of the started server; the URL becomes http://localhost:<port>. |
| `--site-url` | https://example.com | Production origin, so canonical and og:url checks pass while auditing localhost. |
| `--src` | ./src | Also lint this directory as part of the audit. |
| `--kind` | marketing \| app | Selects the default budget set (JS 170 KB vs 300 KB). |
| `--lighthouse` | boolean | Also run Lighthouse 13 (optional peer dependency). |
| `--screenshots` | dir | Save fold and full-page screenshots. |
| `--format, -f` | pretty \| md \| json | md is compact and grouped by rule, for agents. |
| `--json` | boolean | JSON only on stdout; progress goes to stderr. |
| `--out, -o` | report.json \| report.md | Also write the report to a file; format from the extension. |
| `--fail-on` | error \| warn \| info \| never | Exit 1 threshold, default error. Budgets also fail unless never. |
| `--max-per-rule` | number | Occurrences listed per rule (md default 5). |
| `--config, -c` | path | Default: void.config.ts, .mts, .js or .mjs in the current directory. |
| `--quiet, -q` | boolean | No progress output. |
| `--no-color` | boolean | Plain output. NO_COLOR is honoured too. |

## Exit codes

| Code | Meaning |
| --- | --- |
| 0 | Pass: no findings at or above --fail-on and every budget holds. |
| 1 | Findings at or above --fail-on, or a budget exceeded. |
| 2 | Usage or config error: a bad flag, an invalid void.config.ts, an unknown rule id. |
| 3 | Target unreachable, or the --start server exited or timed out. The last server log lines are printed. |
| 4 | Chromium failed to launch. Run void doctor, install Chromium or set VOID_CHROMIUM. |

## void.config.ts

| Key | Type | Notes |
| --- | --- | --- |
| `url` | string | Base URL when none is passed. |
| `routes` | string[] | Paths to audit. |
| `start` | { command, port, cwd?, readyPath?, timeoutMs? } | Server to spawn. readyPath defaults to /, timeout to 120 s. |
| `src` | string | Source directory for lint; also added to audit. |
| `profiles` | ("mobile" \| "desktop")[] | Default both. |
| `runs` | 1–20 | Perf runs per profile, default 3. |
| `kind` | "marketing" \| "app" | Default budget set. |
| `budgets` | { mobile?: Budgets, desktop?: Budgets } | lcp, inp, tbt, ttfb (ms), cls, jsKb, cssKb, fontKb, imageKb, htmlKb, requests, droppedFramePct. Merged over the defaults. |
| `rules` | Record<ruleId, "error" \| "warn" \| "info" \| "off"> | Severity overrides per rule id. |
| `failOn` | "error" \| "warn" \| "info" \| "never" | Default error. |
| `lighthouse` | boolean | Default false. |
| `only` | Category[] | Default: all page categories. |

```ts
/** @type {import('@void/cli/config').VoidConfig} */
const config = {
  kind: "marketing",
  src: "src",
  start: { command: "bun run start -p 3100", port: 3100, readyPath: "/" },
  routes: ["/", "/pricing", "/this-page-does-not-exist"],
  profiles: ["mobile", "desktop"],
  runs: 3,
  budgets: { mobile: { lcp: 2000, jsKb: 150 } },
  rules: { "geo/answer-first": "off" },
  failOn: "error",
};

export default config;
```

## Default budgets

| Profile | LCP | CLS | INP | TBT | TTFB | JS | CSS | Fonts | HTML |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| marketing · mobile | 2.5 s | 0.1 | 200 ms | 200 ms | 800 ms | 170 KB | 25 KB | 120 KB | 30 KB |
| marketing · desktop | 1.5 s | 0.1 | 200 ms | 200 ms | 800 ms | 170 KB | 25 KB | 120 KB | 30 KB |
| app · mobile | 2.5 s | 0.1 | 200 ms | 300 ms | 800 ms | 300 KB | 40 KB | 120 KB | 60 KB |
| app · desktop | 1.5 s | 0.1 | 200 ms | 200 ms | 800 ms | 300 KB | 40 KB | 120 KB | 60 KB |

## JSON report (abridged)

```json
{
  "schemaVersion": 1,
  "tool": { "name": "void", "version": "0.1.0" },
  "scores": { "perf": 97, "smooth": 100, "a11y": 100, "seo": 100, "geo": 96, "design": 94 },
  "overall": 98,
  "metrics": [{ "name": "lcp", "value": 1180, "unit": "ms", "profile": "mobile", "budget": 2500 }],
  "findings": [{
    "id": "perf/lcp-image-lazy",
    "category": "perf",
    "severity": "error",
    "message": "LCP image /hero.avif has loading=lazy",
    "source": "web-vitals",
    "target": { "url": "http://localhost:3100/", "profile": "mobile", "selector": "main img" },
    "fix": { "hint": "Remove loading=\"lazy\" from the LCP image." },
    "docs": "https://void.parthkapoor.me/rules/perf/lcp-image-lazy"
  }],
  "summary": { "errors": 1, "warnings": 0, "infos": 2, "pass": false }
}
```


---

<!-- https://void-design.vercel.app/rules/perf · updated 2026-09-16 -->

# Performance rules

Core Web Vitals and bytes: LCP, CLS, TBT, TTFB, JavaScript, CSS, fonts, images, caching and compression, measured as the median of several runs on a throttled mobile and a desktop profile. Run `void perf` or `void audit`.

| Rule | Severity | Problem |
| --- | --- | --- |
| [`perf/lcp-slow`](https://void-design.vercel.app/rules/perf/lcp-slow.md) | error | Largest Contentful Paint is slow |
| [`perf/cls-high`](https://void-design.vercel.app/rules/perf/cls-high.md) | error | Cumulative Layout Shift is high |
| [`perf/ttfb-slow`](https://void-design.vercel.app/rules/perf/ttfb-slow.md) | warn | Server response (TTFB) is slow |
| [`perf/fcp-slow`](https://void-design.vercel.app/rules/perf/fcp-slow.md) | warn | First Contentful Paint is slow |
| [`perf/tbt-high`](https://void-design.vercel.app/rules/perf/tbt-high.md) | warn | Main thread is blocked by long tasks during load |
| [`perf/js-budget`](https://void-design.vercel.app/rules/perf/js-budget.md) | warn | First-load JavaScript exceeds the budget |
| [`perf/css-budget`](https://void-design.vercel.app/rules/perf/css-budget.md) | warn | CSS transfer exceeds the budget |
| [`perf/font-budget`](https://void-design.vercel.app/rules/perf/font-budget.md) | warn | Web font transfer exceeds the budget |
| [`perf/too-many-fonts`](https://void-design.vercel.app/rules/perf/too-many-fonts.md) | warn | Too many font files are downloaded |
| [`perf/image-budget`](https://void-design.vercel.app/rules/perf/image-budget.md) | warn | Image transfer exceeds the budget |
| [`perf/too-many-requests`](https://void-design.vercel.app/rules/perf/too-many-requests.md) | info | Page makes many requests |
| [`perf/lcp-image-lazy`](https://void-design.vercel.app/rules/perf/lcp-image-lazy.md) | error | LCP image is lazy-loaded |
| [`perf/lcp-image-no-priority`](https://void-design.vercel.app/rules/perf/lcp-image-no-priority.md) | warn | LCP image has no fetchpriority="high" |
| [`perf/lcp-background-image`](https://void-design.vercel.app/rules/perf/lcp-background-image.md) | warn | LCP element is a CSS background image |
| [`perf/image-oversized`](https://void-design.vercel.app/rules/perf/image-oversized.md) | warn | Image is much larger than its rendered size |
| [`perf/image-legacy-format`](https://void-design.vercel.app/rules/perf/image-legacy-format.md) | warn | Image served in a legacy format |
| [`perf/images-missing-dimensions`](https://void-design.vercel.app/rules/perf/images-missing-dimensions.md) | warn | Images without width/height or aspect-ratio |
| [`perf/render-blocking`](https://void-design.vercel.app/rules/perf/render-blocking.md) | warn | Render-blocking resources delay first paint |
| [`perf/no-text-compression`](https://void-design.vercel.app/rules/perf/no-text-compression.md) | warn | Text resources are served uncompressed |
| [`perf/cache-headers`](https://void-design.vercel.app/rules/perf/cache-headers.md) | warn | Hashed static assets are not cached long-term |
| [`perf/third-party-heavy`](https://void-design.vercel.app/rules/perf/third-party-heavy.md) | warn | Heavy third-party footprint |
| [`perf/dom-size`](https://void-design.vercel.app/rules/perf/dom-size.md) | warn | DOM is very large |
| [`perf/bfcache-blocked`](https://void-design.vercel.app/rules/perf/bfcache-blocked.md) | warn | Page is not eligible for the back/forward cache |
| [`perf/lighthouse-score-low`](https://void-design.vercel.app/rules/perf/lighthouse-score-low.md) | warn | Lighthouse performance score is low |
| [`perf/lighthouse-audit`](https://void-design.vercel.app/rules/perf/lighthouse-audit.md) | info | Lighthouse flagged an opportunity |


---

<!-- https://void-design.vercel.app/rules/smooth · updated 2026-09-16 -->

# Smoothness rules

How the page feels after it loads: dropped frames during a scripted scroll, long animation frames, INP of real clicks, shifts after input, animated layout properties, idle rAF loops and reduced-motion handling. Run `void smooth` or `void audit`.

| Rule | Severity | Problem |
| --- | --- | --- |
| [`smooth/scroll-jank`](https://void-design.vercel.app/rules/smooth/scroll-jank.md) | warn | Frames are dropped while scrolling |
| [`smooth/long-frames-during-scroll`](https://void-design.vercel.app/rules/smooth/long-frames-during-scroll.md) | warn | Long animation frames block scrolling |
| [`smooth/scroll-listener-nonpassive`](https://void-design.vercel.app/rules/smooth/scroll-listener-nonpassive.md) | warn | Non-passive wheel/touch listener |
| [`smooth/inp-slow`](https://void-design.vercel.app/rules/smooth/inp-slow.md) | warn | Interactions are slow to respond (INP) |
| [`smooth/layout-shift-after-input`](https://void-design.vercel.app/rules/smooth/layout-shift-after-input.md) | warn | Layout shifts long after an interaction |
| [`smooth/layout-shift-on-scroll`](https://void-design.vercel.app/rules/smooth/layout-shift-on-scroll.md) | warn | Layout shifts while scrolling |
| [`smooth/animate-layout-property`](https://void-design.vercel.app/rules/smooth/animate-layout-property.md) | warn | Animation of a layout-triggering property |
| [`smooth/transition-all`](https://void-design.vercel.app/rules/smooth/transition-all.md) | warn | transition: all |
| [`smooth/will-change-overuse`](https://void-design.vercel.app/rules/smooth/will-change-overuse.md) | info | will-change is applied too broadly |
| [`smooth/reduced-motion-ignored`](https://void-design.vercel.app/rules/smooth/reduced-motion-ignored.md) | warn | Animations keep running with prefers-reduced-motion |
| [`smooth/raf-loop-idle`](https://void-design.vercel.app/rules/smooth/raf-loop-idle.md) | warn | requestAnimationFrame loop runs while idle |
| [`smooth/scroll-timeline-no-fallback`](https://void-design.vercel.app/rules/smooth/scroll-timeline-no-fallback.md) | error | Content is invisible without scroll-driven-animation support |
| [`smooth/multiple-webgl-contexts`](https://void-design.vercel.app/rules/smooth/multiple-webgl-contexts.md) | warn | Multiple WebGL contexts |


---

<!-- https://void-design.vercel.app/rules/a11y · updated 2026-09-16 -->

# Accessibility rules

WCAG 2.2 AA through axe-core, plus checks axe can't do alone: visible keyboard focus, 24/44px target size on mobile, 320px reflow and disabled zoom. Run `void a11y` or `void audit`.

| Rule | Severity | Problem |
| --- | --- | --- |
| [`a11y/color-contrast`](https://void-design.vercel.app/rules/a11y/color-contrast.md) | error | Text has insufficient color contrast |
| [`a11y/image-alt`](https://void-design.vercel.app/rules/a11y/image-alt.md) | error | Image has no text alternative |
| [`a11y/button-name`](https://void-design.vercel.app/rules/a11y/button-name.md) | error | Button has no accessible name |
| [`a11y/link-name`](https://void-design.vercel.app/rules/a11y/link-name.md) | error | Link has no accessible name |
| [`a11y/label`](https://void-design.vercel.app/rules/a11y/label.md) | error | Form field has no label |
| [`a11y/select-name`](https://void-design.vercel.app/rules/a11y/select-name.md) | error | Select element has no accessible name |
| [`a11y/input-button-name`](https://void-design.vercel.app/rules/a11y/input-button-name.md) | error | Input button has no accessible name |
| [`a11y/input-image-alt`](https://void-design.vercel.app/rules/a11y/input-image-alt.md) | error | Image button has no alt text |
| [`a11y/html-has-lang`](https://void-design.vercel.app/rules/a11y/html-has-lang.md) | error | <html> has no lang attribute |
| [`a11y/html-lang-valid`](https://void-design.vercel.app/rules/a11y/html-lang-valid.md) | error | <html lang> is not a valid language tag |
| [`a11y/document-title`](https://void-design.vercel.app/rules/a11y/document-title.md) | error | Page has no <title> |
| [`a11y/landmark-one-main`](https://void-design.vercel.app/rules/a11y/landmark-one-main.md) | warn | Page has no <main> landmark |
| [`a11y/region`](https://void-design.vercel.app/rules/a11y/region.md) | warn | Content is outside landmark regions |
| [`a11y/landmark-unique`](https://void-design.vercel.app/rules/a11y/landmark-unique.md) | info | Landmarks are not uniquely identifiable |
| [`a11y/page-has-heading-one`](https://void-design.vercel.app/rules/a11y/page-has-heading-one.md) | warn | Page has no level-one heading |
| [`a11y/heading-order`](https://void-design.vercel.app/rules/a11y/heading-order.md) | warn | Heading levels skip |
| [`a11y/empty-heading`](https://void-design.vercel.app/rules/a11y/empty-heading.md) | warn | Heading is empty |
| [`a11y/list`](https://void-design.vercel.app/rules/a11y/list.md) | warn | List contains invalid children |
| [`a11y/listitem`](https://void-design.vercel.app/rules/a11y/listitem.md) | warn | <li> is not inside a list |
| [`a11y/aria-allowed-attr`](https://void-design.vercel.app/rules/a11y/aria-allowed-attr.md) | error | ARIA attribute is not allowed on this role |
| [`a11y/aria-required-attr`](https://void-design.vercel.app/rules/a11y/aria-required-attr.md) | error | Required ARIA attribute is missing |
| [`a11y/aria-required-children`](https://void-design.vercel.app/rules/a11y/aria-required-children.md) | error | ARIA role is missing required children |
| [`a11y/aria-required-parent`](https://void-design.vercel.app/rules/a11y/aria-required-parent.md) | error | ARIA role is missing its required parent |
| [`a11y/aria-roles`](https://void-design.vercel.app/rules/a11y/aria-roles.md) | error | Invalid ARIA role |
| [`a11y/aria-valid-attr`](https://void-design.vercel.app/rules/a11y/aria-valid-attr.md) | error | Invalid ARIA attribute name |
| [`a11y/aria-valid-attr-value`](https://void-design.vercel.app/rules/a11y/aria-valid-attr-value.md) | error | Invalid ARIA attribute value |
| [`a11y/aria-hidden-focus`](https://void-design.vercel.app/rules/a11y/aria-hidden-focus.md) | error | Focusable element inside aria-hidden |
| [`a11y/aria-hidden-body`](https://void-design.vercel.app/rules/a11y/aria-hidden-body.md) | error | aria-hidden on <body> |
| [`a11y/aria-command-name`](https://void-design.vercel.app/rules/a11y/aria-command-name.md) | error | ARIA button/link/menuitem has no accessible name |
| [`a11y/aria-input-field-name`](https://void-design.vercel.app/rules/a11y/aria-input-field-name.md) | error | ARIA input field has no accessible name |
| [`a11y/aria-toggle-field-name`](https://void-design.vercel.app/rules/a11y/aria-toggle-field-name.md) | error | ARIA toggle has no accessible name |
| [`a11y/aria-prohibited-attr`](https://void-design.vercel.app/rules/a11y/aria-prohibited-attr.md) | warn | ARIA attribute is prohibited for this role |
| [`a11y/nested-interactive`](https://void-design.vercel.app/rules/a11y/nested-interactive.md) | error | Interactive controls are nested |
| [`a11y/duplicate-id-aria`](https://void-design.vercel.app/rules/a11y/duplicate-id-aria.md) | error | Duplicate id referenced by ARIA |
| [`a11y/frame-title`](https://void-design.vercel.app/rules/a11y/frame-title.md) | warn | <iframe> has no title |
| [`a11y/svg-img-alt`](https://void-design.vercel.app/rules/a11y/svg-img-alt.md) | error | SVG image has no text alternative |
| [`a11y/role-img-alt`](https://void-design.vercel.app/rules/a11y/role-img-alt.md) | error | role="img" element has no text alternative |
| [`a11y/tabindex`](https://void-design.vercel.app/rules/a11y/tabindex.md) | warn | Positive tabindex |
| [`a11y/scrollable-region-focusable`](https://void-design.vercel.app/rules/a11y/scrollable-region-focusable.md) | warn | Scrollable region is not keyboard accessible |
| [`a11y/link-in-text-block`](https://void-design.vercel.app/rules/a11y/link-in-text-block.md) | warn | Links in text are distinguished only by color |
| [`a11y/autocomplete-valid`](https://void-design.vercel.app/rules/a11y/autocomplete-valid.md) | warn | Invalid autocomplete value |
| [`a11y/meta-viewport`](https://void-design.vercel.app/rules/a11y/meta-viewport.md) | error | Zooming is disabled |
| [`a11y/bypass`](https://void-design.vercel.app/rules/a11y/bypass.md) | warn | No way to skip repeated content |
| [`a11y/video-caption`](https://void-design.vercel.app/rules/a11y/video-caption.md) | error | Video has no captions |
| [`a11y/axe-other`](https://void-design.vercel.app/rules/a11y/axe-other.md) | warn | Other axe-core accessibility violation |
| [`a11y/target-size`](https://void-design.vercel.app/rules/a11y/target-size.md) | warn | Touch target is smaller than 24×24 px |
| [`a11y/focus-not-visible`](https://void-design.vercel.app/rules/a11y/focus-not-visible.md) | error | Keyboard focus is not visible |
| [`a11y/reflow-overflow`](https://void-design.vercel.app/rules/a11y/reflow-overflow.md) | warn | Content overflows horizontally at 320px |


---

<!-- https://void-design.vercel.app/rules/seo · updated 2026-09-16 -->

# SEO rules

What search engines need from the raw response: status codes, titles and descriptions in <head>, self-referencing canonicals, one h1, crawlable links, Open Graph, icons, robots.txt and a sitemap with real dates. Run `void seo` or `void audit`.

| Rule | Severity | Problem |
| --- | --- | --- |
| [`seo/http-status`](https://void-design.vercel.app/rules/seo/http-status.md) | error | Page does not return HTTP 200 |
| [`seo/redirect-chain`](https://void-design.vercel.app/rules/seo/redirect-chain.md) | warn | URL reaches its content through more than one redirect |
| [`seo/redirect-temporary`](https://void-design.vercel.app/rules/seo/redirect-temporary.md) | warn | Temporary redirect (302/303/307) used |
| [`seo/client-redirect`](https://void-design.vercel.app/rules/seo/client-redirect.md) | warn | Redirect done with meta refresh or JavaScript |
| [`seo/noindex`](https://void-design.vercel.app/rules/seo/noindex.md) | error | Indexable page carries noindex |
| [`seo/robots-snippet-restricted`](https://void-design.vercel.app/rules/seo/robots-snippet-restricted.md) | info | nosnippet or a very low max-snippet limits previews |
| [`seo/soft-404`](https://void-design.vercel.app/rules/seo/soft-404.md) | error | Missing page returns HTTP 200 (soft 404) |
| [`seo/html-too-large`](https://void-design.vercel.app/rules/seo/html-too-large.md) | error | Uncompressed HTML is over Googlebot's 2 MB fetch limit |
| [`seo/rsc-payload-ratio`](https://void-design.vercel.app/rules/seo/rsc-payload-ratio.md) | info | Inline RSC flight payload dominates the HTML |
| [`seo/not-https`](https://void-design.vercel.app/rules/seo/not-https.md) | warn | Page served over plain HTTP |
| [`seo/origin-redirects`](https://void-design.vercel.app/rules/seo/origin-redirects.md) | error | http:// or www/apex variant doesn't redirect to the canonical origin in one hop |
| [`seo/html-lang`](https://void-design.vercel.app/rules/seo/html-lang.md) | error | <html lang> missing or not a valid BCP 47 tag |
| [`seo/viewport`](https://void-design.vercel.app/rules/seo/viewport.md) | error | Viewport meta missing, not device-width, or blocks zoom |
| [`seo/charset`](https://void-design.vercel.app/rules/seo/charset.md) | warn | Character encoding not declared early |
| [`seo/title-missing`](https://void-design.vercel.app/rules/seo/title-missing.md) | error | No single non-empty <title> in <head> |
| [`seo/title-length`](https://void-design.vercel.app/rules/seo/title-length.md) | warn | Title length outside 10–60 characters, or keyword-stuffed |
| [`seo/meta-description-missing`](https://void-design.vercel.app/rules/seo/meta-description-missing.md) | error | No meta description in <head> |
| [`seo/meta-description-length`](https://void-design.vercel.app/rules/seo/meta-description-length.md) | warn | Meta description length outside 50–160 characters |
| [`seo/metadata-in-body`](https://void-design.vercel.app/rules/seo/metadata-in-body.md) | error | Crawler receives <title>/canonical/description inside <body> (Next.js streaming metadata) |
| [`seo/canonical-missing`](https://void-design.vercel.app/rules/seo/canonical-missing.md) | error | No single rel=canonical in <head> |
| [`seo/canonical-invalid`](https://void-design.vercel.app/rules/seo/canonical-invalid.md) | error | Canonical URL is relative or contains a fragment |
| [`seo/canonical-broken`](https://void-design.vercel.app/rules/seo/canonical-broken.md) | error | Canonical points to a URL that returns 4xx/5xx |
| [`seo/canonical-conflict`](https://void-design.vercel.app/rules/seo/canonical-conflict.md) | error | Canonical target redirects, is noindex, or canonicalises elsewhere |
| [`seo/canonical-not-self`](https://void-design.vercel.app/rules/seo/canonical-not-self.md) | info | Canonical points to a different URL |
| [`seo/head-js-mutation`](https://void-design.vercel.app/rules/seo/head-js-mutation.md) | error | JavaScript changes canonical, robots, title or description after load |
| [`seo/h1-count`](https://void-design.vercel.app/rules/seo/h1-count.md) | error | Page doesn't have exactly one visible <h1> in the server HTML |
| [`seo/h1-title-mismatch`](https://void-design.vercel.app/rules/seo/h1-title-mismatch.md) | info | <h1> and <title> share little wording |
| [`seo/heading-skip`](https://void-design.vercel.app/rules/seo/heading-skip.md) | warn | Heading levels skip (h2 → h4) or headings are empty |
| [`seo/img-alt-missing`](https://void-design.vercel.app/rules/seo/img-alt-missing.md) | error | <img> without an alt attribute |
| [`seo/img-alt-quality`](https://void-design.vercel.app/rules/seo/img-alt-quality.md) | warn | Alt text is a filename, a generic word, or overly long |
| [`seo/links-not-crawlable`](https://void-design.vercel.app/rules/seo/links-not-crawlable.md) | error | Navigation uses elements crawlers can't follow |
| [`seo/link-text`](https://void-design.vercel.app/rules/seo/link-text.md) | warn | Links with generic or empty text |
| [`seo/broken-links`](https://void-design.vercel.app/rules/seo/broken-links.md) | warn | Internal links return 4xx/5xx (or go through redirects) |
| [`seo/trailing-slash`](https://void-design.vercel.app/rules/seo/trailing-slash.md) | error | Both trailing-slash and non-slash URLs serve 200 |
| [`seo/hreflang-invalid`](https://void-design.vercel.app/rules/seo/hreflang-invalid.md) | error | hreflang uses invalid codes or relative URLs |
| [`seo/hreflang-not-reciprocal`](https://void-design.vercel.app/rules/seo/hreflang-not-reciprocal.md) | error | hreflang set isn't self-referencing and reciprocal |
| [`seo/og-missing`](https://void-design.vercel.app/rules/seo/og-missing.md) | error | Required Open Graph tags missing (og:title, og:type, og:image, og:url) |
| [`seo/og-incomplete`](https://void-design.vercel.app/rules/seo/og-incomplete.md) | warn | og:description / og:site_name missing, or og:url ≠ canonical |
| [`seo/og-image-invalid`](https://void-design.vercel.app/rules/seo/og-image-invalid.md) | error | og:image isn't a reachable, absolute, raster image within size limits |
| [`seo/og-image-size`](https://void-design.vercel.app/rules/seo/og-image-size.md) | warn | og:image is smaller than 1200×630 or far from 1.91:1 |
| [`seo/og-image-meta`](https://void-design.vercel.app/rules/seo/og-image-meta.md) | warn | og:image:width/height missing or wrong, or og:image:alt missing |
| [`seo/twitter-card`](https://void-design.vercel.app/rules/seo/twitter-card.md) | warn | twitter:card missing |
| [`seo/favicon`](https://void-design.vercel.app/rules/seo/favicon.md) | error | No crawlable favicon in a Google-supported format |
| [`seo/apple-touch-icon`](https://void-design.vercel.app/rules/seo/apple-touch-icon.md) | warn | apple-touch-icon missing or not a 180×180 PNG |
| [`seo/manifest`](https://void-design.vercel.app/rules/seo/manifest.md) | info | Web app manifest missing or incomplete |
| [`seo/theme-color`](https://void-design.vercel.app/rules/seo/theme-color.md) | info | No theme-color meta |
| [`seo/robots-txt-unreachable`](https://void-design.vercel.app/rules/seo/robots-txt-unreachable.md) | error | robots.txt returns 5xx/429 or times out |
| [`seo/robots-txt-missing`](https://void-design.vercel.app/rules/seo/robots-txt-missing.md) | warn | No robots.txt |
| [`seo/robots-txt-invalid`](https://void-design.vercel.app/rules/seo/robots-txt-invalid.md) | warn | robots.txt serves HTML, exceeds 500 KiB, or has unparseable lines |
| [`seo/robots-blocks-all`](https://void-design.vercel.app/rules/seo/robots-blocks-all.md) | error | robots.txt disallows the site for * / Googlebot / Bingbot |
| [`seo/robots-blocks-resources`](https://void-design.vercel.app/rules/seo/robots-blocks-resources.md) | error | robots.txt blocks JS/CSS needed to render |
| [`seo/robots-sitemap-directive`](https://void-design.vercel.app/rules/seo/robots-sitemap-directive.md) | warn | robots.txt has no absolute Sitemap: line |
| [`seo/sitemap-missing`](https://void-design.vercel.app/rules/seo/sitemap-missing.md) | error | No reachable sitemap |
| [`seo/sitemap-invalid`](https://void-design.vercel.app/rules/seo/sitemap-invalid.md) | error | Sitemap isn't valid sitemap XML or exceeds protocol limits |
| [`seo/sitemap-url-invalid`](https://void-design.vercel.app/rules/seo/sitemap-url-invalid.md) | error | Sitemap <loc> is relative or on another host |
| [`seo/sitemap-lastmod`](https://void-design.vercel.app/rules/seo/sitemap-lastmod.md) | warn | <lastmod> invalid, in the future, or identical across entries |
| [`seo/sitemap-url-status`](https://void-design.vercel.app/rules/seo/sitemap-url-status.md) | error | Sitemap lists URLs that don't return 200 directly |
| [`seo/sitemap-url-not-indexable`](https://void-design.vercel.app/rules/seo/sitemap-url-not-indexable.md) | error | Sitemap lists noindex, robots-blocked or non-canonical URLs |
| [`seo/duplicate-title`](https://void-design.vercel.app/rules/seo/duplicate-title.md) | warn | Multiple pages share the same <title> |
| [`seo/duplicate-description`](https://void-design.vercel.app/rules/seo/duplicate-description.md) | warn | Multiple pages share the same meta description |


---

<!-- https://void-design.vercel.app/rules/geo · updated 2026-09-16 -->

# AI search rules

Generative-engine optimisation: whether AI crawlers that don't run JavaScript see the same content, valid and visible JSON-LD, an explicit AI robots policy, llms.txt, Markdown mirrors and answer-first writing. Run `void geo` or `void audit`.

| Rule | Severity | Problem |
| --- | --- | --- |
| [`geo/js-dependency`](https://void-design.vercel.app/rules/geo/js-dependency.md) | error | Main content only appears after JavaScript runs |
| [`geo/hidden-streamed-content`](https://void-design.vercel.app/rules/geo/hidden-streamed-content.md) | warn | Crawlers receive main content only inside hidden streaming containers |
| [`geo/ai-bot-blocked`](https://void-design.vercel.app/rules/geo/ai-bot-blocked.md) | error | AI crawler user agents get 401/403/429/503 or a challenge page |
| [`geo/ai-content-differs`](https://void-design.vercel.app/rules/geo/ai-content-differs.md) | warn | AI crawlers get materially different HTML than browsers |
| [`geo/jsonld-parse-error`](https://void-design.vercel.app/rules/geo/jsonld-parse-error.md) | error | JSON-LD block is not valid JSON |
| [`geo/jsonld-unsafe`](https://void-design.vercel.app/rules/geo/jsonld-unsafe.md) | error | JSON-LD contains a literal </script or <!-- |
| [`geo/jsonld-missing`](https://void-design.vercel.app/rules/geo/jsonld-missing.md) | info | Page has no JSON-LD |
| [`geo/jsonld-not-in-raw`](https://void-design.vercel.app/rules/geo/jsonld-not-in-raw.md) | error | JSON-LD is injected by JavaScript |
| [`geo/home-entity-missing`](https://void-design.vercel.app/rules/geo/home-entity-missing.md) | warn | Home page lacks Organization + a single WebSite node |
| [`geo/jsonld-schema`](https://void-design.vercel.app/rules/geo/jsonld-schema.md) | error | JSON-LD @context isn't schema.org or @type isn't a schema.org type |
| [`geo/jsonld-required`](https://void-design.vercel.app/rules/geo/jsonld-required.md) | error | Structured data type is missing Google-required properties |
| [`geo/jsonld-recommended`](https://void-design.vercel.app/rules/geo/jsonld-recommended.md) | info | Structured data missing recommended properties |
| [`geo/jsonld-dates`](https://void-design.vercel.app/rules/geo/jsonld-dates.md) | warn | Structured data dates are invalid, lack a timezone, or modified < published |
| [`geo/breadcrumb-invalid`](https://void-design.vercel.app/rules/geo/breadcrumb-invalid.md) | warn | BreadcrumbList positions, names or item URLs are invalid |
| [`geo/faq-deprecated`](https://void-design.vercel.app/rules/geo/faq-deprecated.md) | info | FAQPage/HowTo/SearchAction markup no longer produces rich results |
| [`geo/jsonld-id-unresolved`](https://void-design.vercel.app/rules/geo/jsonld-id-unresolved.md) | warn | JSON-LD @id references don't resolve |
| [`geo/jsonld-not-visible`](https://void-design.vercel.app/rules/geo/jsonld-not-visible.md) | warn | Structured data values don't appear on the page |
| [`geo/entity-name-mismatch`](https://void-design.vercel.app/rules/geo/entity-name-mismatch.md) | info | Brand name differs across og:site_name, JSON-LD and title |
| [`geo/article-date-missing`](https://void-design.vercel.app/rules/geo/article-date-missing.md) | warn | Article has no machine-readable or visible date |
| [`geo/article-author-missing`](https://void-design.vercel.app/rules/geo/article-author-missing.md) | warn | Article author missing or malformed |
| [`geo/landmarks`](https://void-design.vercel.app/rules/geo/landmarks.md) | warn | Missing <main>, <nav> or <article> landmarks |
| [`geo/thin-content`](https://void-design.vercel.app/rules/geo/thin-content.md) | info | Article main content under 150 words |
| [`geo/answer-first`](https://void-design.vercel.app/rules/geo/answer-first.md) | info | Opening paragraph doesn't state the answer concisely |
| [`geo/no-citations`](https://void-design.vercel.app/rules/geo/no-citations.md) | info | Long content with no outbound links to sources |
| [`geo/no-statistics`](https://void-design.vercel.app/rules/geo/no-statistics.md) | info | Long content with no quantified facts |
| [`geo/unstructured-content`](https://void-design.vercel.app/rules/geo/unstructured-content.md) | info | Long content with no lists, tables or subheadings |
| [`geo/wall-of-text`](https://void-design.vercel.app/rules/geo/wall-of-text.md) | info | Paragraphs longer than 150 words |
| [`geo/vague-headings`](https://void-design.vercel.app/rules/geo/vague-headings.md) | info | Generic section headings ("Overview", "More", "Details") |
| [`geo/markdown-alternate`](https://void-design.vercel.app/rules/geo/markdown-alternate.md) | info | No working Markdown alternate for this content page |
| [`geo/markdown-mirror-indexable`](https://void-design.vercel.app/rules/geo/markdown-mirror-indexable.md) | warn | Markdown mirror can compete with the HTML page, or diverges from it |
| [`geo/markdown-negotiation-vary`](https://void-design.vercel.app/rules/geo/markdown-negotiation-vary.md) | warn | Accept: text/markdown negotiation without Vary: Accept |
| [`geo/markdown-negotiation`](https://void-design.vercel.app/rules/geo/markdown-negotiation.md) | info | No Markdown response for Accept: text/markdown |
| [`geo/llms-txt-missing`](https://void-design.vercel.app/rules/geo/llms-txt-missing.md) | info | No /llms.txt |
| [`geo/llms-txt-error`](https://void-design.vercel.app/rules/geo/llms-txt-error.md) | error | /llms.txt returns a server error |
| [`geo/llms-txt-invalid`](https://void-design.vercel.app/rules/geo/llms-txt-invalid.md) | warn | /llms.txt doesn't follow the llms.txt format |
| [`geo/llms-txt-broken-links`](https://void-design.vercel.app/rules/geo/llms-txt-broken-links.md) | warn | /llms.txt links to URLs that fail |
| [`geo/llms-txt-not-linked`](https://void-design.vercel.app/rules/geo/llms-txt-not-linked.md) | info | llms.txt exists but isn't linked from the home page |
| [`geo/robots-ai-policy`](https://void-design.vercel.app/rules/geo/robots-ai-policy.md) | info | AI crawler policy (allow/deny matrix from robots.txt) |
| [`geo/search-bots-blocked`](https://void-design.vercel.app/rules/geo/search-bots-blocked.md) | error | robots.txt blocks AI search or user-triggered fetchers |
| [`geo/training-bots-blocked`](https://void-design.vercel.app/rules/geo/training-bots-blocked.md) | info | robots.txt blocks AI training crawlers |
| [`geo/content-signal`](https://void-design.vercel.app/rules/geo/content-signal.md) | info | No Content-Signal usage preferences |


---

<!-- https://void-design.vercel.app/rules/design · updated 2026-09-16 -->

# Design rules

Measured design heuristics from computed styles: font families, type scale, line length, palette and accent sprawl, spacing grid, radius sprawl, dark mode and the tells of generated UI. Run `void design` or `void audit`.

| Rule | Severity | Problem |
| --- | --- | --- |
| [`design/too-many-font-families`](https://void-design.vercel.app/rules/design/too-many-font-families.md) | warn | Too many font families |
| [`design/no-type-scale`](https://void-design.vercel.app/rules/design/no-type-scale.md) | warn | Too many distinct font sizes (no type scale) |
| [`design/line-length-long`](https://void-design.vercel.app/rules/design/line-length-long.md) | warn | Body text lines are too long |
| [`design/line-length-short`](https://void-design.vercel.app/rules/design/line-length-short.md) | info | Body text lines are very short on desktop |
| [`design/body-text-small`](https://void-design.vercel.app/rules/design/body-text-small.md) | warn | Body text is smaller than 15px on mobile |
| [`design/body-line-height-tight`](https://void-design.vercel.app/rules/design/body-line-height-tight.md) | warn | Body line-height is too tight |
| [`design/heading-tracking`](https://void-design.vercel.app/rules/design/heading-tracking.md) | info | Large headings use default letter-spacing |
| [`design/heading-no-balance`](https://void-design.vercel.app/rules/design/heading-no-balance.md) | info | Headings do not use text-wrap: balance |
| [`design/palette-sprawl`](https://void-design.vercel.app/rules/design/palette-sprawl.md) | warn | Too many distinct colors |
| [`design/multiple-accents`](https://void-design.vercel.app/rules/design/multiple-accents.md) | warn | More than one accent hue |
| [`design/gradient-text`](https://void-design.vercel.app/rules/design/gradient-text.md) | info | Gradient-filled heading text |
| [`design/purple-gradient`](https://void-design.vercel.app/rules/design/purple-gradient.md) | info | Purple/indigo gradient background |
| [`design/emoji-icons`](https://void-design.vercel.app/rules/design/emoji-icons.md) | warn | Emoji used as icons |
| [`design/spacing-off-grid`](https://void-design.vercel.app/rules/design/spacing-off-grid.md) | info | Spacing values are off the 4px grid |
| [`design/radius-sprawl`](https://void-design.vercel.app/rules/design/radius-sprawl.md) | info | Too many different corner radii |
| [`design/no-dark-mode`](https://void-design.vercel.app/rules/design/no-dark-mode.md) | info | No dark mode support |
| [`design/color-scheme-missing`](https://void-design.vercel.app/rules/design/color-scheme-missing.md) | info | color-scheme is not declared |
| [`design/selection-unstyled`](https://void-design.vercel.app/rules/design/selection-unstyled.md) | info | ::selection is not styled |
| [`design/horizontal-overflow`](https://void-design.vercel.app/rules/design/horizontal-overflow.md) | warn | Page scrolls horizontally at 375px |


---

<!-- https://void-design.vercel.app/rules/lint · updated 2026-09-16 -->

# Lint rules

Static source checks with no browser: Tailwind v4 silent failures, Next.js 16 API traps, React render-body bugs, accessibility markup, SEO files and motion hygiene. Run `void lint` or `void audit`.

| Rule | Severity | Problem |
| --- | --- | --- |
| [`lint/tw-unknown-class`](https://void-design.vercel.app/rules/lint/tw-unknown-class.md) | error | Class looks like a Tailwind utility but compiles to no CSS |
| [`lint/tw-v3-arbitrary-var`](https://void-design.vercel.app/rules/lint/tw-v3-arbitrary-var.md) | error | `utility-[--var]` is Tailwind v3 syntax; v4 needs `utility-(--var)` |
| [`lint/tw-js-config-ignored`](https://void-design.vercel.app/rules/lint/tw-js-config-ignored.md) | warn | `tailwind.config.*` exists but the CSS entry has no `@config` |
| [`lint/tw-dark-variant-os`](https://void-design.vercel.app/rules/lint/tw-dark-variant-os.md) | warn | `dark:` follows the OS, but the app toggles themes with a class/attribute |
| [`lint/tw-v3-renamed`](https://void-design.vercel.app/rules/lint/tw-v3-renamed.md) | info | Utility whose meaning changed between Tailwind v3 and v4 |
| [`lint/tw-important-soup`](https://void-design.vercel.app/rules/lint/tw-important-soup.md) | warn | Many `!important` utilities in one file |
| [`lint/tw-arbitrary-sprawl`](https://void-design.vercel.app/rules/lint/tw-arbitrary-sprawl.md) | info | Many arbitrary colors / pixel values bypass the design tokens |
| [`lint/page-level-use-client`](https://void-design.vercel.app/rules/lint/page-level-use-client.md) | warn | `"use client"` at the top of a page or layout |
| [`lint/heavy-import`](https://void-design.vercel.app/rules/lint/heavy-import.md) | warn | Heavy dependency imported into client code |
| [`lint/img-raw`](https://void-design.vercel.app/rules/lint/img-raw.md) | warn | Raw `<img>` in a Next.js app |
| [`lint/img-missing-dimensions`](https://void-design.vercel.app/rules/lint/img-missing-dimensions.md) | warn | Image without width/height (or fill / aspect-ratio) |
| [`lint/image-priority-deprecated`](https://void-design.vercel.app/rules/lint/image-priority-deprecated.md) | warn | `priority` on `next/image` is deprecated in Next 16 |
| [`lint/missing-alt`](https://void-design.vercel.app/rules/lint/missing-alt.md) | error | Image without `alt` |
| [`lint/google-fonts-link`](https://void-design.vercel.app/rules/lint/google-fonts-link.md) | warn | Google Fonts loaded via `<link>` / `@import` |
| [`lint/font-display`](https://void-design.vercel.app/rules/lint/font-display.md) | warn | `@font-face` without `font-display` |
| [`lint/too-many-font-families`](https://void-design.vercel.app/rules/lint/too-many-font-families.md) | warn | More than 3 font families loaded |
| [`lint/script-strategy`](https://void-design.vercel.app/rules/lint/script-strategy.md) | warn | Third-party script loads too early |
| [`lint/browser-global-in-render`](https://void-design.vercel.app/rules/lint/browser-global-in-render.md) | error | Browser global (`window`, `document`, `navigator`, `localStorage`) at module scope or during render |
| [`lint/timer-in-render`](https://void-design.vercel.app/rules/lint/timer-in-render.md) | error | `setTimeout` / `setInterval` called in a component body |
| [`lint/motionvalue-subscribe-in-render`](https://void-design.vercel.app/rules/lint/motionvalue-subscribe-in-render.md) | error | MotionValue `.on("change")` subscription in a component body |
| [`lint/webgl-in-map`](https://void-design.vercel.app/rules/lint/webgl-in-map.md) | error | WebGL canvas rendered inside `.map()` |
| [`lint/raf-without-cancel`](https://void-design.vercel.app/rules/lint/raf-without-cancel.md) | warn | `requestAnimationFrame` loop in an effect without `cancelAnimationFrame` |
| [`lint/listener-without-cleanup`](https://void-design.vercel.app/rules/lint/listener-without-cleanup.md) | warn | Event listener or interval added in an effect without cleanup |
| [`lint/scroll-listener-nonpassive`](https://void-design.vercel.app/rules/lint/scroll-listener-nonpassive.md) | warn | `wheel` / `touchstart` / `touchmove` listener without `{ passive: true }` |
| [`lint/unload-listener`](https://void-design.vercel.app/rules/lint/unload-listener.md) | warn | `unload` event listener |
| [`lint/useeffect-fetch`](https://void-design.vercel.app/rules/lint/useeffect-fetch.md) | warn | Page fetches its data in `useEffect` |
| [`lint/div-button`](https://void-design.vercel.app/rules/lint/div-button.md) | warn | Clickable `<div>`/`<span>` without button semantics |
| [`lint/dangerously-set-jsonld-unescaped`](https://void-design.vercel.app/rules/lint/dangerously-set-jsonld-unescaped.md) | error | JSON-LD injected with `JSON.stringify` without escaping `<` |
| [`lint/transition-all`](https://void-design.vercel.app/rules/lint/transition-all.md) | warn | `transition: all` / `transition-all` |
| [`lint/animate-layout-prop`](https://void-design.vercel.app/rules/lint/animate-layout-prop.md) | warn | Animation of layout properties (width/height/top/left/margin/padding) |
| [`lint/outline-none-no-replacement`](https://void-design.vercel.app/rules/lint/outline-none-no-replacement.md) | warn | Focus outline removed without a visible replacement |
| [`lint/scale-zero-entry`](https://void-design.vercel.app/rules/lint/scale-zero-entry.md) | warn | Element enters from `scale(0)` |
| [`lint/scroll-timeline-no-fallback`](https://void-design.vercel.app/rules/lint/scroll-timeline-no-fallback.md) | warn | Base rule hides an element; only an `@supports (animation-timeline: …)` block undoes it |
| [`lint/ease-in-enter`](https://void-design.vercel.app/rules/lint/ease-in-enter.md) | info | `ease-in` used for an entrance or hover |
| [`lint/long-ui-duration`](https://void-design.vercel.app/rules/lint/long-ui-duration.md) | info | UI feedback transition longer than 500ms |
| [`lint/no-reduced-motion`](https://void-design.vercel.app/rules/lint/no-reduced-motion.md) | warn | Animations present but no `prefers-reduced-motion` handling anywhere |
| [`lint/overflow-x-hidden-sticky`](https://void-design.vercel.app/rules/lint/overflow-x-hidden-sticky.md) | warn | `overflow-x: hidden` on html/body/root wrapper breaks `position: sticky` |
| [`lint/vh-hero`](https://void-design.vercel.app/rules/lint/vh-hero.md) | info | Hero sized with `100vh` / `h-screen` |
| [`lint/will-change-static`](https://void-design.vercel.app/rules/lint/will-change-static.md) | warn | `will-change` applied statically to many elements |
| [`lint/z-index-soup`](https://void-design.vercel.app/rules/lint/z-index-soup.md) | info | Many distinct z-index values |
| [`lint/hardcoded-colors`](https://void-design.vercel.app/rules/lint/hardcoded-colors.md) | warn | Hard-coded colors in components instead of tokens |
| [`lint/missing-metadata`](https://void-design.vercel.app/rules/lint/missing-metadata.md) | warn | Page has no `metadata` / `generateMetadata` and inherits only the root title |
| [`lint/missing-metadata-base`](https://void-design.vercel.app/rules/lint/missing-metadata-base.md) | error | Root layout metadata has no `metadataBase` |
| [`lint/canonical-in-root-layout`](https://void-design.vercel.app/rules/lint/canonical-in-root-layout.md) | error | Canonical URL set in the root layout |
| [`lint/og-merge-drops-parent`](https://void-design.vercel.app/rules/lint/og-merge-drops-parent.md) | warn | Page `openGraph` replaces the layout's (shallow merge drops images/siteName) |
| [`lint/not-found-in-suspense`](https://void-design.vercel.app/rules/lint/not-found-in-suspense.md) | warn | `notFound()`/`redirect()` inside a component rendered under `<Suspense>` |
| [`lint/missing-sitemap`](https://void-design.vercel.app/rules/lint/missing-sitemap.md) | warn | No sitemap |
| [`lint/missing-robots`](https://void-design.vercel.app/rules/lint/missing-robots.md) | warn | No robots.txt |
| [`lint/missing-og-image`](https://void-design.vercel.app/rules/lint/missing-og-image.md) | warn | No Open Graph image anywhere |
| [`lint/missing-icon`](https://void-design.vercel.app/rules/lint/missing-icon.md) | warn | No favicon / app icon |
| [`lint/missing-llms-txt`](https://void-design.vercel.app/rules/lint/missing-llms-txt.md) | info | No `/llms.txt` |
| [`lint/dynamic-metadata-streaming`](https://void-design.vercel.app/rules/lint/dynamic-metadata-streaming.md) | warn | Request-time `generateMetadata` without `htmlLimitedBots` |
| [`lint/missing-lang`](https://void-design.vercel.app/rules/lint/missing-lang.md) | error | Root `<html>` without `lang` |
| [`lint/jsonld-missing`](https://void-design.vercel.app/rules/lint/jsonld-missing.md) | info | No JSON-LD structured data |
| [`lint/sitemap-lastmod-now`](https://void-design.vercel.app/rules/lint/sitemap-lastmod-now.md) | warn | Sitemap uses `new Date()` for `lastModified` |
| [`lint/multiple-h1`](https://void-design.vercel.app/rules/lint/multiple-h1.md) | warn | More than one `<h1>` in a route's page + layouts |
| [`lint/jsonld-deprecated-type`](https://void-design.vercel.app/rules/lint/jsonld-deprecated-type.md) | info | JSON-LD type that no longer produces rich results |
| [`lint/route-js-budget`](https://void-design.vercel.app/rules/lint/route-js-budget.md) | warn | Route first-load JS over budget (from `.next` build output) |
| [`lint/animate-undefined`](https://void-design.vercel.app/rules/lint/animate-undefined.md) | error | `animate-*` class with no matching `--animate-*` theme variable or `@keyframes` |
| [`lint/random-in-render`](https://void-design.vercel.app/rules/lint/random-in-render.md) | error | `Math.random()` / `Date.now()` / `new Date()` evaluated in a component render body |
| [`lint/duplicate-children-not-hidden`](https://void-design.vercel.app/rules/lint/duplicate-children-not-hidden.md) | warn | Children repeated for a marquee/loop without `aria-hidden` or `inert` on the copies |
| [`lint/conditional-hook`](https://void-design.vercel.app/rules/lint/conditional-hook.md) | error | Hook called inside a JSX expression, `&&`, ternary or callback |
| [`lint/svg-global-id`](https://void-design.vercel.app/rules/lint/svg-global-id.md) | warn | Hardcoded `id` on an SVG `<filter>`, gradient, `<clipPath>` or `<mask>` inside a reusable component |
| [`lint/allocation-per-frame`](https://void-design.vercel.app/rules/lint/allocation-per-frame.md) | warn | `new Intl.NumberFormat` / `Intl.NumberFormat(` / `new Color(` inside an animation-frame callback |
| [`lint/setstate-per-pointer-move`](https://void-design.vercel.app/rules/lint/setstate-per-pointer-move.md) | warn | React state set inside a `mousemove` / `pointermove` / `scroll` handler |
| [`lint/hardcoded-device-pixel-ratio`](https://void-design.vercel.app/rules/lint/hardcoded-device-pixel-ratio.md) | info | Canvas/WebGL renders with a literal `devicePixelRatio: 2` (or `width * 2`) |
| [`lint/button-missing-type`](https://void-design.vercel.app/rules/lint/button-missing-type.md) | info | `<button>` (or `as="button"` default) without an explicit `type` |


---

<!-- https://void-design.vercel.app/directions/precision · updated 2026-09-16 -->

# Precision

Calm, exact, fast. Engineered near-black UI, hairlines, one electric indigo signal.

direction 01/06 · dark-first · Geist · Geist Mono · radius 0.375rem

Everything below the page header is rendered in Precision: its fonts, radius, motion and palette, with a switch between its light and dark schemes.

## Type

Display Geist 500, text Geist, mono Geist Mono (SIL OFL 1.1, ~51KB latin woff2 total). The scale is closed: text-display-lg, text-display-sm, text-2xl, text-md, text-sm, label-mono, font-mono.

## Palette

Semantic tokens in OKLCH, a separate scale per scheme. Contrast gate: 66 checks passed, 0 failed. Tightest: light --fg on --surface-hover 15.83:1 Lc96.

| Token | Light | Dark |
| --- | --- | --- |
| --bg | oklch(99.2% 0.002 275) | oklch(14.5% 0.003 275) |
| --bg-subtle | oklch(97.4% 0.003 275) | oklch(16.8% 0.004 275) |
| --surface | oklch(100% 0 0) | oklch(18.2% 0.005 275) |
| --surface-raised | oklch(100% 0 0) | oklch(21.5% 0.006 275) |
| --surface-hover | oklch(95.4% 0.005 275) | oklch(23.5% 0.007 275) |
| --surface-active | oklch(92.8% 0.007 275) | oklch(26.5% 0.008 275) |
| --line | oklch(22% 0.01 275 / 0.11) | oklch(100% 0 0 / 0.09) |
| --line-subtle | oklch(22% 0.01 275 / 0.07) | oklch(100% 0 0 / 0.06) |
| --line-strong | oklch(22% 0.01 275 / 0.2) | oklch(100% 0 0 / 0.17) |
| --line-focus | oklch(55% 0.19 272) | oklch(68% 0.15 273) |
| --fg | oklch(20% 0.008 275) | oklch(96% 0.003 275) |
| --fg-muted | oklch(47% 0.012 275) | oklch(78.5% 0.009 275) |
| --fg-subtle | oklch(54.5% 0.012 275) | oklch(61% 0.01 275) |
| --fg-faint | oklch(64% 0.012 275) | oklch(49.5% 0.01 275) |
| --fg-on-brand | oklch(100% 0 0) | oklch(100% 0 0) |
| --brand | oklch(54.5% 0.19 272) | oklch(56% 0.19 272) |
| --brand-hover | oklch(50.5% 0.18 272) | oklch(52% 0.18 272) |
| --brand-subtle | oklch(95.5% 0.021 272) | oklch(25% 0.06 272) |
| --brand-line | oklch(80% 0.09 272) | oklch(44% 0.12 272) |
| --brand-text | oklch(50% 0.17 272) | oklch(80% 0.1 274) |
| --success | oklch(50% 0.12 152) | oklch(79% 0.14 152) |
| --success-subtle | oklch(96% 0.03 152) | oklch(24% 0.045 152) |
| --warning | oklch(52% 0.11 70) | oklch(82% 0.13 80) |
| --warning-subtle | oklch(96.5% 0.036 85) | oklch(25% 0.045 75) |
| --danger | oklch(52% 0.18 27) | oklch(79% 0.121 27) |
| --danger-subtle | oklch(96% 0.019 27) | oklch(25% 0.06 27) |
| --grain-opacity | 0 | 0.035 |

## Depth, radius, motion

Elevation shadow-1, shadow-2, shadow-3. Radius knob 0.375rem: rounded-sm, rounded-lg, rounded-xl, rounded-2xl. Motion cubic-bezier(0.23, 1, 0.32, 1); fast 100ms · base 150ms · slow 200ms · slower 450ms.

## When to use it

- **Use for:** dev tools, AI/infra products, pro SaaS, technical launch pages
- **Avoid:** playful consumer brands, long-form reading
- **Token file:** `packages/tokens/css/directions/precision.css`

## Signature

display weight 500 at -0.04em; 11px uppercase mono labels (label-mono) as the UI voice; 1px white-alpha hairlines; depth from lighter surfaces + inset top highlight, not drop shadows; 6px radius; kbd chips; brand on <=2% of pixels (CTA, focus, active, links).

## Don't

gradients on buttons, rounded-full buttons, serif, illustrations, glass, second accent.

## Install

```css
@import "tailwindcss";
@import "@void/tokens/base.css";
@import "@void/tokens/directions/precision.css";
```

```ts
import { Geist, Geist_Mono } from "next/font/google";
const sans = Geist({ subsets: ["latin"], variable: "--font-geist-sans", display: "swap" });
const mono = Geist_Mono({ subsets: ["latin"], variable: "--font-geist-mono", display: "swap" });
```


---

<!-- https://void-design.vercel.app/directions/terminal · updated 2026-09-16 -->

# Terminal

Raw, fast, honest. Graphite black, monospace everything, one phosphor-amber signal, hard edges.

direction 02/06 · dark-first · Geist Mono · radius 0rem

Everything below the page header is rendered in Terminal: its fonts, radius, motion and palette, with a switch between its light and dark schemes.

## Type

Display Geist Mono 600, text Geist Mono, mono Geist Mono (SIL OFL 1.1, ~23KB latin woff2). The scale is closed: text-display-lg, text-display-sm, text-2xl, text-md, text-sm, label-mono, font-mono.

## Palette

Semantic tokens in OKLCH, a separate scale per scheme. Contrast gate: 66 checks passed, 0 failed. Tightest: light --fg on --surface-hover 15.50:1 Lc93.

| Token | Light | Dark |
| --- | --- | --- |
| --bg | oklch(98.6% 0.004 90) | oklch(13.5% 0 0) |
| --bg-subtle | oklch(96.4% 0.005 90) | oklch(15.5% 0 0) |
| --surface | oklch(100% 0 0) | oklch(17% 0 0) |
| --surface-raised | oklch(100% 0 0) | oklch(20.5% 0 0) |
| --surface-hover | oklch(94% 0.006 90) | oklch(22.5% 0 0) |
| --surface-active | oklch(91% 0.008 90) | oklch(26% 0 0) |
| --line | oklch(80% 0.006 90) | oklch(32% 0 0) |
| --line-subtle | oklch(90% 0.005 90) | oklch(24% 0 0) |
| --line-strong | oklch(20% 0.01 80) | oklch(52% 0 0) |
| --line-focus | oklch(52% 0.12 62) | oklch(80% 0.165 72) |
| --fg | oklch(19% 0.01 80) | oklch(94% 0.008 85) |
| --fg-muted | oklch(44% 0.01 80) | oklch(78.5% 0.008 85) |
| --fg-subtle | oklch(53.5% 0.01 80) | oklch(60.5% 0.006 85) |
| --fg-faint | oklch(63% 0.01 80) | oklch(49% 0.006 85) |
| --fg-on-brand | oklch(18% 0.02 60) | oklch(18% 0.02 60) |
| --brand | oklch(80% 0.165 72) | oklch(80% 0.165 72) |
| --brand-hover | oklch(84% 0.148 78) | oklch(85% 0.144 80) |
| --brand-subtle | oklch(95.5% 0.045 85) | oklch(24% 0.045 70) |
| --brand-line | oklch(72% 0.14 70) | oklch(50% 0.11 70) |
| --brand-text | oklch(49% 0.11 62) | oklch(82% 0.15 75) |
| --success | oklch(48% 0.12 150) | oklch(78% 0.17 150) |
| --success-subtle | oklch(95.5% 0.04 150) | oklch(24% 0.05 150) |
| --warning | oklch(50% 0.1 98) | oklch(88% 0.16 98) |
| --warning-subtle | oklch(96% 0.06 100) | oklch(26% 0.05 98) |
| --danger | oklch(51% 0.19 28) | oklch(79% 0.121 28) |
| --danger-subtle | oklch(95.5% 0.022 28) | oklch(25% 0.07 28) |
| --grain-opacity | 0 | 0.04 |

## Depth, radius, motion

Elevation shadow-1, shadow-2, shadow-3. Radius knob 0rem: rounded-sm, rounded-lg, rounded-xl, rounded-2xl. Motion cubic-bezier(0.23, 1, 0.32, 1); fast 60ms · base 100ms · slow 150ms · slower 300ms.

## When to use it

- **Use for:** CLIs, dev infra, hacker tools, hackathons, developer launch pages
- **Avoid:** mainstream or non-technical audiences, long reading
- **Token file:** `packages/tokens/css/directions/terminal.css`

## Signature

`$`/`>` prompts in brand-text; bracketed actions `[ deploy ]`; hard offset shadow on ONE focal element (shadow-3); solid 1px borders (not alpha); amber solids take dark text; warning is a yellower hue so "pending" never reads as the CTA; box-drawing dividers; tabular timestamps.

## Don't

rounded corners, soft shadows, gradients, serif, emoji, glass, typewriter headlines.

## Install

```css
@import "tailwindcss";
@import "@void/tokens/base.css";
@import "@void/tokens/directions/terminal.css";
```

```ts
import { Geist_Mono } from "next/font/google";
const mono = Geist_Mono({ subsets: ["latin"], variable: "--font-geist-mono", display: "swap" });
Alt: JetBrains_Mono (variable "--font-geist-mono" works too; ~40KB).
```


---

<!-- https://void-design.vercel.app/directions/instrument · updated 2026-09-16 -->

# Instrument

Trustworthy, dense, data-first. Cool slate neutrals, a chart-grade blue, tabular everything.

direction 03/06 · light + dark · IBM Plex Sans · IBM Plex Mono · radius 0.375rem

Everything below the page header is rendered in Instrument: its fonts, radius, motion and palette, with a switch between its light and dark schemes.

## Type

Display IBM Plex Sans 600, text IBM Plex Sans, mono IBM Plex Mono (SIL OFL 1.1). The scale is closed: text-display-lg, text-display-sm, text-2xl, text-md, text-sm, label-mono, font-mono.

## Palette

Semantic tokens in OKLCH, a separate scale per scheme. Contrast gate: 66 checks passed, 0 failed. Tightest: dark --fg on --surface-hover 13.63:1 Lc94.

| Token | Light | Dark |
| --- | --- | --- |
| --bg | oklch(99% 0.002 255) | oklch(16.5% 0.006 255) |
| --bg-subtle | oklch(97.4% 0.004 255) | oklch(19% 0.007 255) |
| --surface | oklch(100% 0 0) | oklch(20% 0.008 255) |
| --surface-raised | oklch(100% 0 0) | oklch(23.5% 0.009 255) |
| --surface-hover | oklch(95.2% 0.006 255) | oklch(25.5% 0.01 255) |
| --surface-active | oklch(92.6% 0.009 255) | oklch(28.5% 0.011 255) |
| --line | oklch(25% 0.02 255 / 0.12) | oklch(90% 0.02 255 / 0.1) |
| --line-subtle | oklch(25% 0.02 255 / 0.07) | oklch(90% 0.02 255 / 0.06) |
| --line-strong | oklch(25% 0.02 255 / 0.22) | oklch(90% 0.02 255 / 0.18) |
| --line-focus | oklch(55% 0.17 255) | oklch(70% 0.13 255) |
| --fg | oklch(22% 0.015 255) | oklch(95% 0.005 255) |
| --fg-muted | oklch(46% 0.018 255) | oklch(78.5% 0.012 255) |
| --fg-subtle | oklch(54% 0.018 255) | oklch(62.5% 0.014 255) |
| --fg-faint | oklch(64% 0.018 255) | oklch(50.5% 0.014 255) |
| --fg-on-brand | oklch(100% 0 0) | oklch(100% 0 0) |
| --brand | oklch(53% 0.17 255) | oklch(55% 0.17 255) |
| --brand-hover | oklch(49% 0.16 255) | oklch(51% 0.16 255) |
| --brand-subtle | oklch(95.5% 0.02 255) | oklch(26% 0.055 255) |
| --brand-line | oklch(80% 0.08 255) | oklch(45% 0.11 255) |
| --brand-text | oklch(49% 0.15 255) | oklch(80% 0.102 255) |
| --success | oklch(49% 0.11 155) | oklch(79% 0.13 155) |
| --success-subtle | oklch(95.5% 0.035 155) | oklch(25% 0.045 155) |
| --warning | oklch(52% 0.11 70) | oklch(83% 0.12 80) |
| --warning-subtle | oklch(96% 0.042 85) | oklch(26% 0.045 75) |
| --danger | oklch(51% 0.18 25) | oklch(79% 0.121 25) |
| --danger-subtle | oklch(95.5% 0.022 25) | oklch(26% 0.06 25) |
| --grain-opacity | 0 | 0 |

## Depth, radius, motion

Elevation shadow-1, shadow-2, shadow-3. Radius knob 0.375rem: rounded-sm, rounded-lg, rounded-xl, rounded-2xl. Motion cubic-bezier(0.23, 1, 0.32, 1); fast 100ms · base 150ms · slow 200ms · slower 300ms.

## When to use it

- **Use for:** analytics, admin, finance, ops consoles, internal tools, docs for data products
- **Avoid:** marketing landing pages, big-type storytelling
- **Token file:** `packages/tokens/css/directions/instrument.css`

## Signature

13-14px UI, 36px table rows, sticky header on bg-subtle, right-aligned tabular-nums slashed-zero numbers; delta chips (text-success on bg-success-subtle); panels separated by border-line-subtle, shadows only on floating layers (shadow-2/3); filters in the URL.

## Don't

hero-size type, decorative gradients, a second accent in chrome (chart palette is separate).

## Install

```css
@import "tailwindcss";
@import "@void/tokens/base.css";
@import "@void/tokens/directions/instrument.css";
```

```ts
import { IBM_Plex_Sans, IBM_Plex_Mono } from "next/font/google";
const sans = IBM_Plex_Sans({ subsets: ["latin"], variable: "--font-plex-sans", display: "swap" });
const mono = IBM_Plex_Mono({ subsets: ["latin"], weight: ["400", "500"], variable: "--font-plex-mono", display: "swap" });
```


---

<!-- https://void-design.vercel.app/directions/editorial · updated 2026-09-16 -->

# Editorial

Literary, considered, slow. Warm paper, near-black ink, an oxblood red pencil, hairline rules.

direction 04/06 · light-first · Newsreader · IBM Plex Mono · radius 0.125rem

Everything below the page header is rendered in Editorial: its fonts, radius, motion and palette, with a switch between its light and dark schemes.

## Type

Display Newsreader 400, text Newsreader, mono IBM Plex Mono (SIL OFL 1.1). The scale is closed: text-display-lg, text-display-sm, text-2xl, text-md, text-sm, label-mono, font-mono.

## Palette

Semantic tokens in OKLCH, a separate scale per scheme. Contrast gate: 66 checks passed, 0 failed. Tightest: dark --fg on --surface-hover 12.66:1 Lc90.

| Token | Light | Dark |
| --- | --- | --- |
| --bg | oklch(98.3% 0.008 85) | oklch(17.5% 0.008 70) |
| --bg-subtle | oklch(96.4% 0.011 85) | oklch(20% 0.009 70) |
| --surface | oklch(99.3% 0.004 85) | oklch(21% 0.009 70) |
| --surface-raised | oklch(99.6% 0.003 85) | oklch(24% 0.01 70) |
| --surface-hover | oklch(94.4% 0.013 85) | oklch(26% 0.011 70) |
| --surface-active | oklch(92% 0.015 85) | oklch(29% 0.012 70) |
| --line | oklch(25% 0.02 60 / 0.16) | oklch(95% 0.02 85 / 0.13) |
| --line-subtle | oklch(25% 0.02 60 / 0.09) | oklch(95% 0.02 85 / 0.08) |
| --line-strong | oklch(22% 0.02 60 / 0.7) | oklch(95% 0.02 85 / 0.6) |
| --line-focus | oklch(47% 0.15 22) | oklch(72% 0.12 28) |
| --fg | oklch(21% 0.015 60) | oklch(93% 0.012 85) |
| --fg-muted | oklch(45% 0.02 65) | oklch(78.5% 0.015 80) |
| --fg-subtle | oklch(53.5% 0.02 70) | oklch(62.5% 0.015 75) |
| --fg-faint | oklch(63% 0.02 70) | oklch(51% 0.015 75) |
| --fg-on-brand | oklch(98.5% 0.008 85) | oklch(98.5% 0.008 85) |
| --brand | oklch(46% 0.15 22) | oklch(52% 0.15 24) |
| --brand-hover | oklch(41% 0.14 22) | oklch(48% 0.14 24) |
| --brand-subtle | oklch(94.5% 0.025 30) | oklch(27% 0.05 25) |
| --brand-line | oklch(76% 0.08 25) | oklch(46% 0.1 25) |
| --brand-text | oklch(46% 0.15 22) | oklch(80% 0.1 28) |
| --success | oklch(47% 0.1 150) | oklch(79% 0.11 150) |
| --success-subtle | oklch(95% 0.03 150) | oklch(26% 0.04 150) |
| --warning | oklch(49% 0.1 70) | oklch(80% 0.11 80) |
| --warning-subtle | oklch(95% 0.04 85) | oklch(27% 0.045 75) |
| --danger | oklch(50% 0.19 32) | oklch(79% 0.123 35) |
| --danger-subtle | oklch(95% 0.025 32) | oklch(27% 0.06 35) |
| --grain-opacity | 0.045 | 0.06 |

## Depth, radius, motion

Elevation shadow-1, shadow-2, shadow-3. Radius knob 0.125rem: rounded-sm, rounded-lg, rounded-xl, rounded-2xl. Motion cubic-bezier(0.23, 1, 0.32, 1); fast 150ms · base 250ms · slow 400ms · slower 600ms.

## When to use it

- **Use for:** publications, essays, research, studios, premium brands, long-form docs
- **Avoid:** dense data apps, dashboards
- **Token file:** `packages/tokens/css/directions/editorial.css`

## Signature

body 17-19px/1.65 in 65ch columns; structure from 1px rules (border-line-strong for section heads) not boxes; mono uppercase kickers and bylines; underlined links (decoration-brand-line, underline-offset-4); italic decks; drop caps; grain on paper.

## Don't

cards, pills, icon grids, sans headlines, gradients, bouncy or scaling motion.

## Install

```css
@import "tailwindcss";
@import "@void/tokens/base.css";
@import "@void/tokens/directions/editorial.css";
```

```ts
import { Newsreader, IBM_Plex_Mono } from "next/font/google";
const serif = Newsreader({ subsets: ["latin"], variable: "--font-newsreader", display: "swap", style: ["normal", "italic"] });
const mono = IBM_Plex_Mono({ subsets: ["latin"], weight: ["400", "500"], variable: "--font-plex-mono", display: "swap" });
```


---

<!-- https://void-design.vercel.app/directions/swiss · updated 2026-09-16 -->

# Swiss

Rational, confident, poster-like. Achromatic neutrals, black type, a visible grid, one signal red.

direction 05/06 · light-first · Schibsted Grotesk · JetBrains Mono · radius 0rem

Everything below the page header is rendered in Swiss: its fonts, radius, motion and palette, with a switch between its light and dark schemes.

## Type

Display Schibsted Grotesk 700, text Schibsted Grotesk, mono JetBrains Mono (SIL OFL 1.1). The scale is closed: text-display-lg, text-display-sm, text-2xl, text-md, text-sm, label-mono, font-mono.

## Palette

Semantic tokens in OKLCH, a separate scale per scheme. Contrast gate: 66 checks passed, 0 failed. Tightest: light --fg on --surface-hover 15.77:1 Lc93.

| Token | Light | Dark |
| --- | --- | --- |
| --bg | oklch(100% 0 0) | oklch(14% 0 0) |
| --bg-subtle | oklch(96.5% 0 0) | oklch(17.5% 0 0) |
| --surface | oklch(100% 0 0) | oklch(17.5% 0 0) |
| --surface-raised | oklch(100% 0 0) | oklch(21% 0 0) |
| --surface-hover | oklch(94% 0 0) | oklch(24% 0 0) |
| --surface-active | oklch(90.5% 0 0) | oklch(28% 0 0) |
| --line | oklch(18% 0 0) | oklch(100% 0 0 / 0.3) |
| --line-subtle | oklch(87% 0 0) | oklch(100% 0 0 / 0.12) |
| --line-strong | oklch(18% 0 0) | oklch(96% 0 0) |
| --line-focus | oklch(55% 0.22 29) | oklch(70% 0.18 29) |
| --fg | oklch(18% 0 0) | oklch(96% 0 0) |
| --fg-muted | oklch(44% 0 0) | oklch(78.5% 0 0) |
| --fg-subtle | oklch(53.5% 0 0) | oklch(61% 0 0) |
| --fg-faint | oklch(63% 0 0) | oklch(50% 0 0) |
| --fg-on-brand | oklch(100% 0 0) | oklch(100% 0 0) |
| --brand | oklch(56.5% 0.22 29) | oklch(56.5% 0.22 29) |
| --brand-hover | oklch(51% 0.2 29) | oklch(52% 0.2 29) |
| --brand-subtle | oklch(95.5% 0.022 29) | oklch(26% 0.07 29) |
| --brand-line | oklch(80% 0.1 29) | oklch(45% 0.15 29) |
| --brand-text | oklch(51% 0.2 29) | oklch(80% 0.114 29) |
| --success | oklch(47% 0.12 150) | oklch(79% 0.13 150) |
| --success-subtle | oklch(95.5% 0.035 150) | oklch(25% 0.045 150) |
| --warning | oklch(50% 0.11 70) | oklch(82% 0.12 80) |
| --warning-subtle | oklch(96% 0.042 85) | oklch(26% 0.045 75) |
| --danger | oklch(47% 0.18 15) | oklch(79% 0.122 15) |
| --danger-subtle | oklch(95.5% 0.022 15) | oklch(26% 0.06 15) |
| --grain-opacity | 0 | 0 |

## Depth, radius, motion

Elevation shadow-1, shadow-2, shadow-3. Radius knob 0rem: rounded-sm, rounded-lg, rounded-xl, rounded-2xl. Motion linear; fast 0ms · base 100ms · slow 150ms · slower 300ms.

## When to use it

- **Use for:** agencies, architecture, type/design tools, events, portfolios, data journalism
- **Avoid:** friendly consumer onboarding, soft brands
- **Token file:** `packages/tokens/css/directions/swiss.css`

## Signature

12-column grid with asymmetric spans (label col 3 / content col 9); flush-left ragged right; full-contrast 1px rules (border-line is ink here); numbered sections "01 —" in mono; huge type contrast; hover = invert (bg-fg text-bg); radius 0; mechanical linear motion.

## Don't

shadows, rounded anything, centred body text, more than 2 weights, decorative icons.

## Install

```css
@import "tailwindcss";
@import "@void/tokens/base.css";
@import "@void/tokens/directions/swiss.css";
```

```ts
import { Schibsted_Grotesk, JetBrains_Mono } from "next/font/google";
const sans = Schibsted_Grotesk({ subsets: ["latin"], variable: "--font-schibsted", display: "swap" });
const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jetbrains-mono", display: "swap" });
```


---

<!-- https://void-design.vercel.app/directions/warm · updated 2026-09-16 -->

# Warm

Approachable and calm, like a well-made notebook app. Warm off-white, soft clay, rounded not bubbly.

direction 06/06 · light-first · Bricolage Grotesque · Figtree · JetBrains Mono · radius 0.625rem

Everything below the page header is rendered in Warm: its fonts, radius, motion and palette, with a switch between its light and dark schemes.

## Type

Display Bricolage Grotesque 600, text Figtree, mono JetBrains Mono (SIL OFL 1.1). The scale is closed: text-display-lg, text-display-sm, text-2xl, text-md, text-sm, label-mono, font-mono.

## Palette

Semantic tokens in OKLCH, a separate scale per scheme. Contrast gate: 66 checks passed, 0 failed. Tightest: dark --fg on --surface-hover 12.45:1 Lc92.

| Token | Light | Dark |
| --- | --- | --- |
| --bg | oklch(98.6% 0.006 75) | oklch(18.5% 0.01 55) |
| --bg-subtle | oklch(96.6% 0.009 70) | oklch(21% 0.011 55) |
| --surface | oklch(99.8% 0.001 75) | oklch(22.5% 0.012 55) |
| --surface-raised | oklch(100% 0 0) | oklch(26% 0.013 55) |
| --surface-hover | oklch(94.6% 0.012 70) | oklch(28% 0.014 55) |
| --surface-active | oklch(92.2% 0.015 65) | oklch(31% 0.015 55) |
| --line | oklch(30% 0.03 50 / 0.12) | oklch(95% 0.02 70 / 0.11) |
| --line-subtle | oklch(30% 0.03 50 / 0.07) | oklch(95% 0.02 70 / 0.07) |
| --line-strong | oklch(30% 0.03 50 / 0.22) | oklch(95% 0.02 70 / 0.2) |
| --line-focus | oklch(55% 0.15 42) | oklch(72% 0.13 45) |
| --fg | oklch(23% 0.02 50) | oklch(94.5% 0.01 75) |
| --fg-muted | oklch(47% 0.025 55) | oklch(78.5% 0.018 70) |
| --fg-subtle | oklch(54% 0.022 60) | oklch(64% 0.018 65) |
| --fg-faint | oklch(63.5% 0.022 60) | oklch(51.5% 0.018 65) |
| --fg-on-brand | oklch(100% 0 0) | oklch(100% 0 0) |
| --brand | oklch(55% 0.155 40) | oklch(55% 0.155 40) |
| --brand-hover | oklch(51% 0.15 40) | oklch(51% 0.15 40) |
| --brand-subtle | oklch(95% 0.026 45) | oklch(28% 0.05 42) |
| --brand-line | oklch(80% 0.08 42) | oklch(47% 0.1 42) |
| --brand-text | oklch(50% 0.15 40) | oklch(80% 0.11 50) |
| --success | oklch(49% 0.11 155) | oklch(79% 0.12 155) |
| --success-subtle | oklch(95.5% 0.035 155) | oklch(26% 0.045 155) |
| --warning | oklch(51% 0.105 80) | oklch(83% 0.12 85) |
| --warning-subtle | oklch(96% 0.05 90) | oklch(27% 0.045 80) |
| --danger | oklch(51% 0.18 22) | oklch(79% 0.121 25) |
| --danger-subtle | oklch(95.5% 0.022 22) | oklch(27% 0.06 25) |
| --grain-opacity | 0.03 | 0.05 |

## Depth, radius, motion

Elevation shadow-1, shadow-2, shadow-3. Radius knob 0.625rem: rounded-sm, rounded-lg, rounded-xl, rounded-2xl. Motion cubic-bezier(0.23, 1, 0.32, 1); fast 150ms · base 220ms · slow 320ms · slower 500ms.

## When to use it

- **Use for:** consumer SaaS, productivity/notes, education, health, community
- **Avoid:** security/infra, luxury, dense data
- **Token file:** `packages/tokens/css/directions/warm.css`

## Signature

10px radius (cards radius-xl 15px, dialogs 20px); soft warm layered shadows on cards sitting on bg-subtle; brand-subtle callouts with brand-text; sentence case; generous 24px card padding; ease-spring for panels, ease-spring-bouncy only for success moments; grain on heroes.

## Don't

neon, pure greys, sharp corners, 3D blobs, heavy drop shadows, Poppins/Nunito.

## Install

```css
@import "tailwindcss";
@import "@void/tokens/base.css";
@import "@void/tokens/directions/warm.css";
```

```ts
import { Bricolage_Grotesque, Figtree, JetBrains_Mono } from "next/font/google";
const display = Bricolage_Grotesque({ subsets: ["latin"], variable: "--font-bricolage", display: "swap" });
const sans = Figtree({ subsets: ["latin"], variable: "--font-figtree", display: "swap" });
const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jetbrains-mono", display: "swap", preload: false });
```
