Storing Color in CSS: A Format Decision Tree
Hex for paste. OKLCH for math. Custom properties for runtime. Pick one layer per job.
A contractor pasted #2563eb from Figma into forty components and shipped. Six months later, the product needed dark mode, perceptual shade ramps, and runtime theme switching for enterprise white-labeling. The hex values were not wrong. They were frozen in the wrong layer. Math happened in Sass where it could not see user preferences. Hovers were duplicated hex pairs maintained by hand. The temporary palette became production debt because nobody decided where each format belonged.
CSS color is not one syntax. It is a stack of representations, each suited to a different job: pasting from design tools, computing perceptual adjustments, exposing theme knobs at runtime, deriving variants without preprocessor rebuilds. Mixing layers, doing math on pasted hex in scattered files, is how systems calcify. A format decision tree is boring documentation that prevents expensive refactors.
This article walks through four layers, a production failure that mapped each mistake to a layer violation, and a minimal architecture that keeps paste, math, runtime, and derivation separate.
Layer one: the paste boundary with hex and rgb
Design tools export sRGB colors. Figma, Sketch, and most handoffs arrive as six-digit hex or rgb() triplets. Accept that at the boundary and convert once into your internal format. Do not treat pasted hex as the long-term source of truth scattered through components.
Hex is compact and universally recognized at the import boundary. Modern space-separated rgb() with alpha is clearer for transparency when you truly need alpha at paste time, modal scrims and glass effects that designers specify as rgba in handoff notes. MDN’s CSS color syntax guide documents hex, rgb(), hsl(), lab(), oklch(), and more. At the paste layer, stay sRGB because designers and PMs can read #2563eb in a spreadsheet. Downstream tokens convert it.
The paste layer is also where you record provenance. A token import spreadsheet with columns for Figma path, hex anchor, OKLCH converted value, and last verified date prevents silent drift when someone updates a swatch in design tools without opening the repository. Conversion scripts should be idempotent and version-controlled so a re-run on the same input produces the same OKLCH within documented rounding tolerance.
The mistake is leaving hex in twenty-seven component files because it matches Figma today. Figma will change. Grep will not forgive you. The better pattern is one palette file that maps a hex anchor to an OKLCH token components never see. Contractors and agencies should receive explicit written rules: paste into the import file only, never into components/. Violations are faster to catch with path-based lint than with design review alone.
Layer two: math in OKLCH and perceptual uniformity
When you lighten, darken, or build ramps programmatically, format choice matters. HSL lightness does not match human perception. Identical L steps in HSL look uneven across hues. OKLCH, derived from Björn Ottosson’s Oklab, aligns lightness steps more closely with how people see contrast change. CSS Color Module Level 4 specifies oklch() for authors. Ottosson’s published error tables against CAM16 and Ebner-Fairchild data are the scientific backing for a decision that otherwise sounds like frontend fashion. When product asks why the palette math changed, you can point to measurable lightness prediction rather than taste.
Store brand and neutral ramps in OKLCH when any downstream step performs math: relative from syntax, color-mix(in oklch, …), gradient in oklch interpolation, or automated tint generation. If you only store hex and convert at mix time, you add conversion cost on every computed style and invite rounding drift across browsers. Canonical OKLCH at definition time keeps operands consistent.
Store brand and neutral ramps in OKLCH when you perform math. Before OKLCH, teams used HSL and fought yellows that brightened too fast and blues that barely moved. After OKLCH, a loop generating steps one hundred through nine hundred produces usable UI steps without hand-tuning every swatch, though you should still eyeball extreme ends where chroma must taper.
Stay sRGB for one-off colors with no derived family, email templates requiring hex-only clients, and assets exported to PNG. Use OKLCH for design-system tokens, theme generation, and any color-mix() or relative color work in OKLCH space. Provide sRGB fallbacks if your audience includes legacy environments:
.btn {
background: #2563eb;
background: oklch(0.55 0.18 264);
}
Layer three: runtime with custom properties
Preprocessor variables compile away. They cannot respond to data-theme, prefers-color-scheme, or a user toggle without rebuilding CSS. CSS custom properties resolve at computed-value time in the cascade, exactly what themes need.
Custom properties inherit. Set theme overrides on the document root and components using var(--surface) track automatically if authors avoided hard-coded hex in components. JavaScript can set custom properties for white-label tenants without redeploying compiled CSS, an advantage preprocessors cannot match without generating N theme files.
The mistake is Sass generating two flat stylesheets, light and dark, duplicated for every component. The better pattern is one stylesheet with token swap on data-theme. The mistake is storing only hex in custom properties then running color-mix(in oklch, var(--brand) …) where --brand is hex. Mixing in OKLCH wants operands already in a perceptual workflow; convert at definition time.
:root {
--surface: oklch(0.98 0.01 260);
--text: oklch(0.22 0.02 260);
}
[data-theme="dark"] {
--surface: oklch(0.18 0.02 260);
--text: oklch(0.93 0.01 260);
}
When four layers collapsed into one spreadsheet of hex
An enterprise onboarding product added white-labeling six months after launch. The original contractor architecture was forty components with inline hex, no token file, and a Sass file that defined $brand but was not imported anywhere in the React tree because the SPA used CSS modules with literal colors. Leadership assumed white-labeling was a week of configuration. Engineering discovered there was no configuration surface, only archaeology.
The first attempted fix duplicated every hex into custom properties on :root without converting to OKLCH. Runtime theming worked for solid fills. Hover states remained duplicated hex pairs per brand because nobody had derived them. Dark mode was scoped as a second CSS bundle compiled from Sass dark variables, reintroducing the dual-file problem modern custom properties were meant to eliminate. Perceptual ramps for chart tints still used HSL stepping in a Node script because the script predated the white-label request.
The breaking incident was a demo for a prospect who changed accent in the CMS during the call. Primary buttons updated. Hover and focus did not. Chart series colors partially updated because three code paths owned color: CSS modules with hex, a chart config with HSL-generated tints, and a marketing landing page still on the old contractor hex. The prospect saw a product that could not decide its own brand color.
Remediation enforced four layers in writing. Layer one was a single import JSON with hex anchors from Figma, never imported by components. Layer two converted anchors to OKLCH in a build script with contrast validation. Layer three exposed only custom properties to the application. Layer four used relative OKLCH syntax and color-mix(in oklch, …) for hovers, muted variants, and disabled states. Email and PDF exports received computed hex from the same OKLCH math at build time because those media cannot use runtime derivation.
White-label demos during remediation exposed another layer violation: sales engineers set accent colors in a CMS that wrote hex into inline styles on the document root, bypassing custom properties entirely. The fix added a documented JavaScript API that set --brand with OKLCH strings validated against a regex and a gamut check. CMS configuration stored OKLCH, not hex, at rest. Layer three rules applied to runtime overrides the same way they applied to compiled CSS.
Training contractors and agencies became part of the architecture rollout. New statements of work included a link to the four-layer doc and a lint-enforced components path ban on hex. The first agency violation was caught in CI within a week rather than discovered during a tenant onboarding demo six months later. Architecture docs nobody reads fail. Architecture docs tied to failing CI checks survive.
Migration took two quarters because components were touched incrementally. CI blocked new hex in src/components. The lesson was not that hex is evil. Hex is correct at the paste boundary. The failure was using paste format for math, runtime, and derivation simultaneously.
Layer four: derivation, anti-patterns, and sane architecture
Duplicating hover, active, muted, and subtle-dark scales inflates token count quickly. color-mix() blends two colors in a chosen space, ideal for tints toward white or black. Relative color syntax adjusts channels of a single color with calc(). Use mix when the second operand is a distinct reference. Use relative from when nudging lightness, chroma, or hue of one base. Full patterns live in Relative Colors in CSS.
Derivation for one-off colors nobody will reuse is indirection without payoff. Paste hex for a single marketing gradient stop. Derive only tokens with multiple dependents. When you mix, specify the interpolation space explicitly. Default sRGB mixes in color-mix() reproduce the same hue drift and gray midpoints that made HSL ramps painful. color-mix(in oklch, var(--brand) 85%, black) aligns darken behavior more closely with perceptual expectation than mixing in sRGB toward black, especially for cobalt and teal accents where naive RGB paths look washed out rather than darkened.
:root {
--brand: oklch(0.55 0.18 264);
--brand-hover: oklch(from var(--brand) calc(l - 0.06) c h);
--surface: oklch(0.98 0.01 260);
--text: oklch(0.22 0.02 260);
}
.button--primary {
background: var(--brand);
color: white;
}
.button--primary:hover {
background: var(--brand-hover);
}
Triple storage of the same swatch as --blue-600, hex, and OKLCH in different files without a canonical definition guarantees drift. HSL for perceptual ramps fails the perceptual test teams already acknowledge in slide decks. Sass darken() on CSS variables fails because Sass runs before variables resolve. Converting the entire codebase to OKLCH in one pull request without visual regression breaks more than it fixes. Migrate tokens in place and snapshot critical UI.
Named CSS keywords are fine for demos and documentation examples, not for production theme tokens. System colors like CanvasText matter in forced-colors mode as a separate concern from brand palettes. Tooling at each layer should match the job: DevTools color picker or a spreadsheet for paste and convert at the boundary, WCAG relative luminance checks on computed values for contrast verification, precomputed opaque blends for email where runtime mix is unavailable, and a single markdown or wiki table documenting design-to-CSS mapping so onboarding engineers do not invent a fifth layer.
Incoming color from a design tool should paste as hex or rgb at the palette boundary only, then convert immediately to OKLCH for tokens. Runtime theme, user toggle, and white-label need custom properties, not Sass variables alone. Hover, tint, and disabled variants from one seed need color-mix() or relative oklch(from …), not hand-maintained hex pairs. Alpha over photos needs rgb or oklch with alpha channel, not opaque mix toward white unless that is the intended design. HTML email needs hex on inline styles as a separate fork from web token architecture. Throwaway prototypes may use named keywords or quick hex, provided those values never copy into production components during a deadline crunch.
Document which repository file owns which layer in a short architecture note reviewers can cite in pull requests. tokens/import.json for paste anchors, tokens/theme.css for OKLCH definitions and custom properties, components/ for consumption only through var(), and scripts/convert-tokens.mjs for hex-to-OKLCH at the boundary. When a contractor pastes hex into a component, the review question is not whether the hex matches Figma today but which layer was violated. The answer should be obvious from file path alone.
Decision reviews for new color features should start with which layer is affected. A request for tenant-specific accent colors is layer three unless the accent also needs new ramp math, which pulls in layer two. A request for a one-off webinar landing page gradient is layer one paste at the stop level unless the stops become reusable tokens. That thirty-second classification prevents layer violations from shipping as quick hacks.
Stakeholder demos should use the same token file production uses, not a Figma screen with hex copied into slides. Slides lie about architecture because PowerPoint does not read custom properties. A live Storybook or staging URL with the token-backed theme proves the four-layer model works before leadership funds migration away from contractor hex.
Regression snapshots should include at least one page that exercises derivation, not only solid fills. A button hover screenshot catches layer four violations where solid fills still look correct because base tokens were migrated but hover remained duplicated hex. One derived-state snapshot per release cycle is cheap insurance.
New hires should read the four-layer doc in week one before touching color in components. Onboarding slides that only show OKLCH syntax without layer boundaries produce engineers who convert notation while violating architecture the same way the contractor did with hex paste. A one-page layer diagram in the wiki beats a fifty-slide deck that never mentions where paste stops and math begins.
Formats are not fashion cycles. They are boundaries. Put paste, math, runtime, and derivation in separate layers, document which file owns which layer, and the next time product asks for dark mode plus enterprise themes, you swap tokens instead of archaeology.