---
title: "Color (craft)"
description: "Opacity modifiers work on all roles (bg-bg/80, border-brand/40) because v4 uses color-mix."
canonical: https://void-design.vercel.app/docs/craft/color
lastModified: 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.
