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):

DeprecatedReplacement
bg-opacity-*, text-opacity-*, border-opacity-*, ring-opacity-*, etc.Opacity modifiers — bg-black/50, text-paper/70
flex-shrink-*shrink-*
flex-grow-*grow-*
overflow-ellipsistext-ellipsis
decoration-slicebox-decoration-slice
decoration-clonebox-decoration-clone

Renamed (the v3 name is gone — the bare name shifted one step):

v3v4
bg-gradient-*bg-linear-*
shadow-smshadow-xs
shadowshadow-sm
rounded-smrounded-xs
roundedrounded-sm
blur-smblur-xs
blurblur-sm
backdrop-blurbackdrop-blur-sm
outline-noneoutline-hidden
ringring-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-brbg-linear-to-br, ringring-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, never min-h-screenmin-h-screen is buggy on mobile Safari.
  • size-* over separate w-* / 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, not ml-[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 removed bg-gradient-*; v4 also adds bg-radial / bg-radial-[<position>] and bg-conic / bg-conic-*.
  • Container queries: mark a wrapper @container and use @md: / @lg: size variants, plus container units like text-[50cqw].
  • Text shadows (v4.1): text-shadow-2xs through text-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-center on a parent and text-left on a child, put text-center only 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-surface in 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 classButton, 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:

  1. The pattern carries no logic — there's no behavior to encapsulate, so a component would add nothing.
  2. Vanilla CSS is preferred over @apply per the core rules — the class declares the type styling directly with var()-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:

  1. Old opacity utilities — use /opacity syntax (bg-red-500/60).
  2. Redundant breakpoint classes — only specify changes.
  3. space-x-* / space-y-* in flex/grid — always use gap.
  4. leading-* — use line-height modifiers (text-sm/6).
  5. Arbitrary values — use the design scale.
  6. @apply — use components or CSS variables.
  7. min-h-screen on mobile — use min-h-dvh.
  8. Separate width/height — use size-* when equal.
  9. Arbitrary spacing — ml-4 over ml-[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 per website/CLAUDE.md.
  • website/CLAUDE.md — the "Read first" pointer to tailwind.md, the twMerge section (specificity rationale + the Button pattern), and the eyebrow-as-class convention under "Components".
  • website/src/styles/global.css — the @theme token block, the .eyebrow class (mono uppercase label), and the runtime CSS-variable references that make Sanity-driven theming work.
  • website/src/components/ui/Button.astro — the canonical twMerge(base, variants[variant], extraClass) implementation; mirror it in any new class-prop primitive.
  • website/src/components/ui/SectionHead.astro — shows the eyebrow applied as class:list={['eyebrow', eyebrowColor]} and the non-conflicting class:list color-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).

Previous
Brand tokens & theme