Design System
Tailwind v4
White Tree Digital styles entirely in Tailwind v4 (@tailwindcss/vite), and the project holds a hard line on writing utilities the v4 way — no @apply, no deprecated or renamed v3 utilities, line-height modifiers instead of leading-*, gap instead of space-x-* in flex, and a @theme-token model where colors come from CSS variables that Sanity can override at runtime.
The rules below are enforced project-wide; the authoritative source is website/tailwind.md, read first by every contributor (it's the first entry in website/CLAUDE.md's "Read first" list). This page covers the v4 rules, the twMerge requirement for any primitive that accepts a class prop, and the eyebrow-as-class convention. Color values themselves live in Brand tokens & theme; typography cuts in Typography.
The mental model
Tailwind v4 is configured in CSS, not JavaScript. There is no tailwind.config.js; the theme is declared in an @theme block at the top of src/styles/global.css and every theme value is exposed as a CSS custom property at runtime:
@theme {
--color-paper: #f5f1e6;
--color-paper-2: #ece6d4;
/* ...the rest of the brand palette... */
--font-display: "Archivo", system-ui, sans-serif;
}
Because utilities like bg-paper compile to background: var(--color-paper), the site can be re-themed at request time: Layout.astro fetches the globalStyles Sanity singleton on every SSR request and injects a :root { --color-*: ... } override <style>. The @theme values are fallbacks, not the source of truth. Full detail in Brand tokens & theme.
The practical consequence: write idiomatic v4 utilities, lean on the design scale, and never reach for @apply or vanilla CSS when a utility exists.
Core principles
From tailwind.md's top-level rules:
- Tailwind v4.1+ only — the codebase tracks the latest version (text-shadow and mask utilities below are v4.1 features).
- Never use deprecated or removed utilities — always use the replacement (tables below).
- Never use
@apply— use CSS variables, the--spacing()function, or a framework component instead. - Remove redundant classes — including redundant breakpoint variants.
- Group elements logically to simplify responsive tweaks later.
No @apply, ever
@apply is banned across the project. It is listed both in the core principles and again as pitfall #6 in tailwind.md. The eyebrow utility (below) is the canonical case where you might be tempted — and the project deliberately writes it as plain CSS with var() references rather than @apply-ing Tailwind classes. If you find yourself wanting @apply, extract a framework component or use a CSS variable instead.
Removed and renamed utilities
These never appear in the codebase. The replacement is mandatory, not optional.
Removed (use the modern equivalent):
| Deprecated | Replacement |
|---|---|
bg-opacity-*, text-opacity-*, border-opacity-*, ring-opacity-*, etc. | Opacity modifiers — bg-black/50, text-paper/70 |
flex-shrink-* | shrink-* |
flex-grow-* | grow-* |
overflow-ellipsis | text-ellipsis |
decoration-slice | box-decoration-slice |
decoration-clone | box-decoration-clone |
Renamed (the v3 name is gone — the bare name shifted one step):
| v3 | v4 |
|---|---|
bg-gradient-* | bg-linear-* |
shadow-sm | shadow-xs |
shadow | shadow-sm |
rounded-sm | rounded-xs |
rounded | rounded-sm |
blur-sm | blur-xs |
blur | blur-sm |
backdrop-blur | backdrop-blur-sm |
outline-none | outline-hidden |
ring | ring-3 |
The rename trap: bare names shifted
The bare names (shadow, rounded, blur, ring) didn't disappear — they changed meaning. In v4, shadow is now shadow-sm's old value, and rounded is the old rounded-sm. Copy-pasting v3 markup (or AI-generated v3 snippets) silently changes the visual. Translate every one: e.g. bg-gradient-to-br → bg-linear-to-br, ring → ring-3.
Layout and spacing
Gap over space-x-* / space-y-* in flex and grid. Space utilities add margins to children and break with flex-wrap and on the last item; gap is consistent and wrap-safe.
<!-- Don't: space utilities break with wrapped items -->
<div class="flex flex-wrap space-x-4">…</div>
<!-- Do: gap works with every flex direction and wrapping -->
<div class="flex flex-wrap gap-4">…</div>
Other spacing rules:
min-h-dvh, nevermin-h-screen—min-h-screenis buggy on mobile Safari.size-*over separatew-*/h-*when the dimensions are equal.- Prefer top/left margins over bottom/right (unless the element is conditionally rendered); use padding on the parent instead of a bottom margin on the last child.
- For max-widths, prefer the container scale (
max-w-2xs) over arbitrary spacing (max-w-72). - No arbitrary values — use the design scale (
ml-4, notml-[16px]). This is pitfalls #5 and #9.
Typography utilities
Never use leading-*. Set line height with the size modifier instead, and use a fixed value from the spacing scale rather than a named one:
<!-- Don't -->
<p class="text-base leading-7">…</p>
<p class="text-lg leading-relaxed">…</p>
<!-- Do -->
<p class="text-base/7">…</p>
<p class="text-lg/8">…</p>
Know the actual pixel values when sizing: text-xs = 12px, text-sm = 14px, text-base = 16px, text-lg = 18px, text-xl = 20px. The font families themselves are driven by the --font-display / --font-sans / --font-mono @theme tokens — see Typography.
Color and opacity
Use the opacity-modifier syntax everywhere — the *-opacity-* utilities are removed:
<!-- Don't -->
<div class="bg-red-500 bg-opacity-60">…</div>
<!-- Do -->
<div class="bg-red-500/60">…</div>
This is how the dark theme expresses translucency without inventing new color tokens: muted text and hairlines are opacity modifiers on existing tokens — text-paper/70, border-paper/15 — rather than separate --color-* values. See Brand tokens & theme.
Responsive design
Only add a breakpoint variant when the value actually changes:
<!-- Don't: md:px-4 and lg:px-4 repeat the base -->
<div class="px-4 md:px-4 lg:px-4">…</div>
<!-- Do -->
<div class="px-4 lg:px-8">…</div>
Redundant breakpoint classes are pitfall #2.
Gradients, container queries, shadows, masks
- Gradients:
bg-linear-*replaces the removedbg-gradient-*; v4 also addsbg-radial/bg-radial-[<position>]andbg-conic/bg-conic-*. - Container queries: mark a wrapper
@containerand use@md:/@lg:size variants, plus container units liketext-[50cqw]. - Text shadows (v4.1):
text-shadow-2xsthroughtext-shadow-lg, with opacity modifiers (text-shadow-sm/50). - Masks (v4.1): composable mask utilities —
mask-t-from-50%,mask-b-from-20% mask-b-to-80%,mask-radial-*, etc.
Working with CSS variables
All theme values are reachable as CSS variables, so custom CSS references the same tokens the utilities do:
.custom-element {
background: var(--color-forest);
border-radius: var(--radius-lg);
}
When you need spacing math in CSS, use the dedicated --spacing() function — this is one of the sanctioned alternatives to @apply:
.custom-class {
margin-top: calc(100vh - --spacing(16));
}
Extend the theme by adding to the @theme block (CSS, not a JS config):
@import "tailwindcss";
@theme {
--color-mint-500: oklch(0.72 0.11 178);
}
Component patterns and nesting
- Don't override utility inheritance — instead of
text-centeron a parent andtext-lefton a child, puttext-centeronly where it applies. - Extract repeated patterns into framework components, not CSS classes. Keep utilities in templates/JSX; use data attributes for complex state-based styling. (The project does exactly this — see
light:/data-surfacein Brand tokens & theme.) - Nest CSS only when the parent itself has styles. Avoid empty parent selectors.
twMerge for any class-prop primitive
Every UI primitive that accepts a caller-supplied class — Button, Pill, SectionHead, SectionShell, etc. — must compose its classes through twMerge from tailwind-merge.
The reason is specificity. Tailwind utilities all have equal CSS specificity, so when two conflict (hidden vs inline-flex, border-paper/15 vs border-rule, px-4 vs px-5.5) the winner is decided by source order in the compiled stylesheet — not by the order classes appear in the class attribute. That makes a caller's extraClass override unreliable on its own. twMerge resolves conflicts by keeping the last conflicting utility in its argument list, so extraClass (passed last) always wins.
The live pattern, from Button.astro:
---
import { twMerge } from 'tailwind-merge';
const {class: extraClass} = Astro.props;
const base = 'inline-flex items-center ...';
const variants = { primary: 'bg-ink text-paper px-5.5 py-3.5', /* ... */ };
---
<button class={twMerge(base, variants[variant], extraClass)}>...</button>
Use plain class={} after twMerge, not class:list
Once twMerge has returned a flat string, render it with plain class={...} — not class:list={...}. class:list would re-introduce the source-order ambiguity twMerge exists to fix. class:list is still fine for purely conditional, non-conflicting class assembly inside a component (e.g. tone-driven color swaps with no extraClass involved) — SectionHead uses it that way for the eyebrow color. The rule is narrow: any time an external class override participates, it goes through twMerge.
Eyebrow is a class, not a component
The small uppercase mono label above section headings — the "eyebrow" — is intentionally not a component. It's the .eyebrow class in src/styles/global.css, a utility label that sets the mono font, bold weight, uppercase, and letter-spacing (display: inline-flex with a gap so an optional glyph sits beside the text). Apply it as a className:
<span class:list={['eyebrow', 'text-gold light:text-forest']}>Our work</span>
Color is the only thing callers vary — it's set with a Tailwind utility on the element, and the mono label inherits it. (On a light surface the light: variant flips gold to forest; that surface system is documented in Brand tokens & theme.)
Two reasons it stays vanilla CSS:
- The pattern carries no logic — there's no behavior to encapsulate, so a component would add nothing.
- Vanilla CSS is preferred over
@applyper the core rules — the class declares the type styling directly withvar()-backed tokens rather than@apply-ing Tailwind utilities.
The general heuristic: treat short, utility-styled spans as classNames, not components, unless they carry logic. Reach for a real component only when there's behavior to encapsulate.
Common pitfalls (the checklist)
tailwind.md closes with the recurring anti-patterns it forbids — a fast self-review list:
- Old opacity utilities — use
/opacitysyntax (bg-red-500/60). - Redundant breakpoint classes — only specify changes.
space-x-*/space-y-*in flex/grid — always usegap.leading-*— use line-height modifiers (text-sm/6).- Arbitrary values — use the design scale.
@apply— use components or CSS variables.min-h-screenon mobile — usemin-h-dvh.- Separate width/height — use
size-*when equal. - Arbitrary spacing —
ml-4overml-[16px].
Where this lives
website/tailwind.md— the authoritative rule set (core principles, removed/renamed utility tables, layout/typography/color/responsive rules, v4.1 features, component patterns, nesting, and the numbered pitfalls). Read first perwebsite/CLAUDE.md.website/CLAUDE.md— the "Read first" pointer totailwind.md, thetwMergesection (specificity rationale + the Button pattern), and the eyebrow-as-class convention under "Components".website/src/styles/global.css— the@themetoken block, the.eyebrowclass (mono uppercase label), and the runtime CSS-variable references that make Sanity-driven theming work.website/src/components/ui/Button.astro— the canonicaltwMerge(base, variants[variant], extraClass)implementation; mirror it in any newclass-prop primitive.website/src/components/ui/SectionHead.astro— shows the eyebrow applied asclass:list={['eyebrow', eyebrowColor]}and the non-conflictingclass:listcolor-swap case.
Related: Brand tokens & theme (the color tokens and light: surface system), Typography (font tokens and cuts), and Components (the primitives that consume twMerge).