Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save bahulneel/6591034870ca23579bceaf9b0f98506d to your computer and use it in GitHub Desktop.

Select an option

Save bahulneel/6591034870ca23579bceaf9b0f98506d to your computer and use it in GitHub Desktop.

How recognising design semantics transformed my colour system

Note: All colour names and values in this article are fictional substitutes to protect brand identity. The actual implementation uses a vibrant fruit-inspired palette.

Why does defining a brand colour require 121 CSS definitions?

Modern design systems promise consistency and maintainability, yet we find ourselves managing sprawling colour scales (50, 100, 200, through 950 for every hue). We duplicate these scales for dark mode. We add opacity modifiers for states. What started as 11 carefully chosen brand colours becomes hundreds of CSS custom properties, most of which never get used.

During my Tailwind 4 migration, I stumbled into a different approach. Not through grand architectural planning, but through bidirectional exploration (working top-down until it broke, then bottom-up until it broke, then switching again). What emerged was a revelation: design has its own semantics, separate from UI semantics. Understanding this distinction unlocked everything else.

This is the story of how var() abstraction revealed a hidden layer, how OKLCH made colour mixing work properly, and how recognising what colours mean in design (not just where they're used in UI) fixed dark mode forever.

The migration tool: mechanical translation

The Tailwind 4 migration started with the official tool. It worked exactly as advertised; everything rendered correctly, dark mode still functioned, components looked identical. But looking at the result, something bothered me. Hardcoded hex values everywhere. No abstraction, no flexibility, no structure.

Post-migration felt like the perfect time to clean house. Not because Tailwind 4 required it, but because the migration gave me the mental space to finally organise things properly.

Tailwind 4's automatic migration

npx @tailwindcss/upgrade

My Tailwind 3 JavaScript config became Tailwind 4 CSS custom properties:

/* Tailwind 3 - tailwind.config.js */
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: {
          DEFAULT: '#059669',
          50: '#ecfdf5',
          100: '#d1fae5',
          // ... through 950
        },
        secondary: { /* ... */ },
        accent: { /* ... */ },
      }
    }
  }
}

Became:

/* Tailwind 4 - Migrated output */
@theme inline {
  --color-primary: #059669;
  --color-primary-50: #ecfdf5;
  --color-primary-100: #d1fae5;
  /* ... through 950 */

  --color-secondary: #7c3aed;
  --color-secondary-50: #f5f3ff;
  /* ... through 950 */

  --color-accent: #0ea5e9;
  /* ... full swatches for all semantic roles */
}

Clean. Mechanical. Successful. But just a flat list of CSS custom properties that happened to work.

Learning from shadcn's idiomatic pattern

With the migration complete, I checked shadcn/vue's Tailwind 4 setup guide to see their recommended patterns.

Their CSS structure stopped me:

/* shadcn's pattern - from their docs */
@custom-variant dark (&:is(.dark *));

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  /* ... */
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --primary: oklch(0.985 0 0);
  /* ... */
}

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-primary: var(--primary);
  /* ... */
}

Two things jumped out:

First: var() indirection. They weren't putting colour values directly in the @theme layer. They defined variables first (in :root and .dark), then referenced them in @theme. This created a layer of abstraction between colour definitions and Tailwind tokens.

Second: OKLCH instead of hex. I'd heard of OKLCH but hadn't explored it. Why would they use this weird colour space?

The var() pattern was the key insight. Variables create indirection. Indirection creates abstraction. Abstraction creates thinking space.

I could restructure. And if I was going to restructure anyway, maybe I could finally go back to the original style guide and fix the mess I'd created years ago.

Adding dark mode foundation

Following shadcn's pattern, I added the custom variant:

/* variants.css */
@custom-variant dark (&:where(.dark-mode, .dark-mode *));

This told Tailwind that the dark: prefix should work with my .dark-mode class. Basic dark mode plumbing was now in place, ready for whatever restructuring came next.

From one big file to many small ones

When var() opens the door to abstraction

Building a file structure that makes sense

I started simple. First instinct: separate colours from everything else.

/* config/colors.css - Just the design palette */
:root {
  --color-charcoal: #1e293b;
  --color-pearl: #fef3c7;
  --color-forest: #059669;
  --color-coral: #f97316;
  /* ... rest of the palette */
}

Then typography got its own file. Then spacing. Each time I split something out, I created a clean import structure:

/* config.css - The aggregator */
@import './config/colors.css';
@import './config/typography.css';
@import './config/spacing.css';

The pattern emerged naturally: one concern per file, one directory per layer, one aggregator file per directory.

css/
├── config.css          # "I aggregate config/"
├── config/
│   ├── colors.css
│   ├── typography.css
│   └── spacing.css
├── themes.css          # "I aggregate themes/"
├── themes/
│   └── inline/
│       ├── core.css
│       └── status-colors.css

As I organised, I noticed something. The physical act of separating files prompted questions: 'What makes these files different?' 'Where does this definition belong?' When something felt wrong in a file, when I hesitated about placement, that discomfort was information.

I was missing an abstraction. Something needed to sit between config/colors.css (the palette) and themes/inline/core.css (the UI tokens).

But I didn't know what yet.

What the style guide actually said

I pulled up the original style guide our designer had provided. Not the Tailwind config I'd created from it years ago, but the actual design document.

It wasn't a list of semantic roles. It was a visual layout showing 11 carefully chosen colours organised into a structure:

Block Primary Column Secondary Column
Monochrome 'Charcoal' (#1e293b) 'Graphite' (#6b7280)
'Pearl' (#fef3c7) 'Ash' (#e5e7eb)
Chromatic 'Forest' (#059669) 'Violet' (#7c3aed)
'Coral' (#f97316) 'Azure' (#0ea5e9), 'Teal' (#0d9488)
'Citrus' (#84cc16) 'Sage' (#22c55e)

Two monochrome row-pairs (one dark, one light). Three chromatic row-pairs showing colour relationships. Each row visually grouped colours together.

But the guide never explained why. What did 'Primary Column' versus 'Secondary Column' mean? What did the row groupings signify? When should paired colours be used together? And why did Row 2 chromatic have one primary paired with TWO secondaries ('Coral' with both 'Azure' and 'Teal')? The asymmetry wasn't explained.

Looking back at my old Tailwind 3 config, I could see the arbitrary decisions I'd made to map this ambiguous structure to shadcn's clear semantic roles:

  • 'Forest' (#059669, green) → primary brand colour (decision made, never questioned)
  • 'Violet' (#7c3aed, purple) → unused (breaking its row-pair with 'forest')
  • 'Azure' (#0ea5e9, cyan) → accent (ignoring its row-pairing with 'coral')
  • 'Teal' (#0d9488, darker cyan) → invented hue-sub-secondary-b role (not a real semantic)
  • 'Sage' (#22c55e, light green) → success status colour (another green!)
  • 'Citrus' (#84cc16, yellow-green) → unused (its pair also broken)

The killer issue: 'Forest' is green. 'Sage' is green. Users expect green to mean success. But I'd mapped 'forest' to the primary brand colour.

The style guide had structure but no semantic guidance. My old config had added semantics but lost structure. The migration tool had faithfully converted one mess into another, preserving nothing.

Attempting direct mapping (top-down breaks)

I faced two translation problems: understanding what the style guide's row-pairs and primary/secondary columns meant, and mapping those 11 colours to shadcn's much smaller set of UI tokens (primary, secondary, success, warning, etc.).

Looking at the style guide and shadcn's tokens side-by-side, I tried the obvious approach: map directly.

'Forest' (Primary Column, chromatic) → --color-primary? But it's green, and users will think 'success'.

'Sage' (Secondary Column, chromatic) → --color-success? But it's also green, users will confuse it with the brand.

What about 'coral' paired with 'azure' AND 'teal'? How do three colours map to the single accent token? Do I pick one and ignore the others? Do I create variant tokens?

What does 'Primary Column' even mean? Visual hierarchy? Lightness? Usage priority? The style guide was silent.

The questions cascaded. Every colour raised ambiguities. Every pairing begged for explanation. I couldn't map what I didn't understand.

Top-down broke. Time to try bottom-up.

The pivot: encode first, map later (bottom-up begins)

If I couldn't map the style guide to tokens directly, maybe I could extract the palette first and figure out the mappings later.

The var() pattern from shadcn showed the way. Variables create indirection. I could define the 11 colours as variables, reference them elsewhere, and give myself time to think about what they mean before committing to semantic mappings.

I created config/colors.css:

/* config/colors.css */
:root {
  /* Monochrome Row-Pairs */
  --color-charcoal: #1e293b;  /* Paired with graphite */
  --color-graphite: #6b7280;  /* Paired with charcoal */

  --color-pearl: #fef3c7;     /* Paired with ash */
  --color-ash: #e5e7eb;       /* Paired with pearl */

  /* Chromatic Row-Pairs */
  --color-forest: #059669;    /* Paired with violet */
  --color-violet: #7c3aed;    /* Paired with forest */

  --color-coral: #f97316;     /* Paired with azure AND teal */
  --color-azure: #0ea5e9;     /* Paired with coral */
  --color-teal: #0d9488;      /* Also paired with coral */

  --color-citrus: #84cc16;    /* Paired with sage */
  --color-sage: #22c55e;      /* Paired with citrus */
}

The inline comments documented the pairings from the style guide without trying to resolve what they meant. I was just preserving structure, not interpreting it yet.

This was progress. I now had well-named variables I could reference. The style guide's structure was visible. But the ambiguities were documented, not resolved.

Key realisation: var() creates indirection, indirection creates thinking space. I could reference var(--color-forest) in my theme layer without committing to what 'forest' meant semantically. The variables bought me time to figure it out.

Recognising design has its own semantics

When UI semantics aren't enough

With colours extracted, I needed something between colours and UI tokens. I could add semantic names like shadcn's --primary, --secondary, --muted.

But then I hit a problem.

Too many unused shades (bottom-up breaks, switch to top-down)

I started creating semantic colours with full Tailwind swatches: 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950. That's 11 shades per colour. With 11 colours from my palette, I was creating 121 static shade definitions.

But looking at my actual components and shadcn's token usage, most colours only needed 3-4 variants. The rest? Dead weight. Copy-pasted complexity that would never be referenced.

Bottom-up broke. I was building too much. Time to switch directions and work top-down from actual needs.

I looked at what tokens shadcn's components actually used:

  • --color-primary (the default)
  • --color-primary-foreground (the contrasting text colour)
  • Maybe --color-primary-hover or opacity modifiers

Not 11 shades. Just a few specific, meaningful variants.

Discovering OKLCH (making mixing work right)

If I wasn't going to pre-generate swatches, how would I create variants when needed?

I'd learned about color-mix() from the Tailwind CSS v4 blog post. Tailwind 4 provides color-mix(), and I could generate variants on-demand. But I'd heard that mixing in RGB or HSL space produces strange results. '10% lighter' doesn't mean perceptually lighter, it means mathematically lighter (which isn't the same thing to human eyes).

This is where shadcn's OKLCH usage made sense. I investigated.

OKLCH is a colour space designed around human perception. When you say '10% lighter' in OKLCH, it actually looks 10% lighter to your eye. When you mix two OKLCH colours, the result is perceptually uniform.

That's why shadcn uses OKLCH. Combined with color-mix(), it produces perceptually correct results.

I converted my colors.css from hex to OKLCH:

/* config/colors.css - Before */
:root {
  --color-forest: #059669;
  --color-azure: #0ea5e9;
}

/* config/colors.css - After */
:root {
  --color-forest: oklch(0.571 0.151 160.87);
  --color-azure: oklch(0.634 0.154 229.37);
}

The numbers look weird (lightness, chroma, hue), but now color-mix() would produce visually correct results.

Note: I used an OKLCH converter to transform the hex values. The conversion is mechanical once you understand the three components.

Deleting what's not needed

With OKLCH in place and color-mix() ready, I deleted all 121 static swatch definitions.

Every --hue-primary-50 through --hue-primary-950. Gone. Every --mono-primary-50 through --mono-primary-950. Gone.

I kept just the 11 base colours from the style guide. No swatches, no scales, no arbitrary shade numbers.

Top-down reduction complete. From 121 static definitions down to 11 base colours.

Now I could work bottom-up again, adding only what I actually needed.

Starting with structured variations (bottom-up resumes)

With just base colours and color-mix() available, I needed to create the variants shadcn's tokens actually used.

Instead of numbered shades (50, 100, 200), I added structured variations with semantic meaning:

/* config/semantic.css */
:root {
  --hue-primary-default: var(--color-forest);
  --hue-primary-inverse: /* ... what colour for text when forest is background? */
  --hue-primary-lighter: color-mix(in oklch, var(--hue-primary-default), white 20%);
  --hue-primary-darker: color-mix(in oklch, var(--hue-primary-default), black 15%);
}

The pattern changed to {hue|mono}-{role}-{variation} where variation meant something:

  • default: The base colour
  • inverse: What to use for foreground when default is background
  • lighter: Subtle, recessive
  • darker: Emphasis, prominent

Now when I mapped to UI tokens:

/* themes/inline/core.css */
@theme inline {
  --color-primary: var(--hue-primary-default);
  --color-primary-foreground: var(--hue-primary-inverse);
}

The variations had meaning. lighter wasn't '200 on the arbitrary Tailwind scale', it was 'the subtle, recessive version of this colour'. The names documented intent.

This worked. I could map all the tokens shadcn needed with just these four variations per colour.

But there was still a problem.

When design semantics diverge from UI

Uncovering the hidden abstraction

The ambiguity problem (top-down pressure)

With structured variations working, the real problem resurfaced: the style guide had multiple primary/secondary row-pairs across rows, while the UI offered a very small semantic set. I could map 1:1 by discarding most colours, but that felt wrong and failed to honour the designer's structure.

The word 'primary' was overloaded by context: one 'primary' belonged to the style guide's table structure, another to UI usage. The bridge I'd built (hue-primary) was trying to express both and did neither well.

Pressure from both directions: top-down (from UI tokens) demanded unambiguous names; bottom-up (from palette structure) demanded that I preserve the guide's organisation.

I needed something in the middle. But not just another layer. I needed to recognise what that layer was for.

Design has its own semantics

The breakthrough came from a simple realisation:

Design semantics are their own vocabulary, roles and sentiments, independent of UI usage.

The style guide's structure wasn't arbitrary. The row-pairs weren't decoration. They encoded design semantics:

  • Sentiment: What does this colour communicate emotionally? (positive, negative, caution)
  • Contrast theory: Is this achromatic (mono) or chromatic (hue)?
  • Relationships: Which colours work together? (the pairings)

These are design semantics. They're different from UI semantics:

  • Usage context: Where is this colour used? (primary brand, success status, warning alert)
  • Component roles: What UI element gets this colour? (button, badge, alert)

These are two fixed things that can't change:

  • The palette colours (from the designer's style guide)
  • The UI tokens (from shadcn's component system)

What I was missing was the abstraction between them. The layer that translates design meaning into UI usage.

Expanding with sentiment (the missing abstraction)

I revised my semantic layer. Instead of trying to use shadcn's role names (primary, secondary) in the design layer, I used sentiment-based names that captured colour psychology:

/* config/semantic/light.css */
:root {
  /* Chromatic colours - hue system */
  --hue-primary-default: var(--color-forest);    /* Brand/hierarchy */
  --hue-positive-default: /* ... need a different green for success */
  --hue-caution-default: var(--color-coral);     /* Warning/attention */

  /* Achromatic colours - mono system */
  --mono-primary-default: var(--color-charcoal); /* Dark contrast */
  --mono-primary-inverse: var(--color-pearl);    /* Light contrast */
}

The pattern became: {hue|mono}-{role|sentiment}-{variation}

For the 'forest' collision, I made an explicit choice. 'Forest' would be hue-primary (the brand/hierarchy sentiment). For success, I'd add a plain colour:

/* config/colors.css - Addition */
:root {
  /* ... existing colours ... */

  /* Plain colours for resolving semantic collisions */
  --color-green: #10b981;  /* Tailwind green-600 for success - resolves forest collision */
}

Then in the semantic layer:

/* config/semantic/light.css */
:root {
  --hue-primary-default: var(--color-forest);  /* Brand - the designer's chosen green */
  --hue-positive-default: var(--color-green);  /* Success - plain green, different from brand */
}

I'd found the abstraction that joins palette colours to UI tokens.

Design semantics (hue-primary, hue-positive, mono-primary) encode what colours mean in the design vocabulary (hierarchy, positive sentiment, achromatic contrast). UI semantics (--color-primary, --color-success, --color-foreground) encode where colours are used in components.

The collision was resolved. The ambiguity was disambiguated. And it was done explicitly, with comments documenting the reasoning.

Three concerns naturally separate

The complete flow now looked like:

LAYER 1: Colours (config/colors.css)
  --color-forest: oklch(...)        /* Paired with violet */
  --color-green: oklch(...)          /* Resolves forest collision */
  --color-charcoal: oklch(...)       /* Paired with graphite */
  --color-pearl: oklch(...)          /* Paired with ash */
  ↓
LAYER 2: Design Semantics (config/semantic/light.css)
  --hue-primary-default: var(--color-forest)    /* Brand/hierarchy */
  --hue-positive-default: var(--color-green)    /* Success sentiment */
  --mono-primary-default: var(--color-charcoal) /* Dark contrast */
  --mono-primary-inverse: var(--color-pearl)    /* Light contrast */
  ↓
LAYER 3: UI Semantics (themes/inline/core.css)
  --color-primary: var(--hue-primary-default)
  --color-success: var(--hue-positive-default)
  --color-foreground: var(--mono-primary-default)
  --color-background: var(--mono-primary-inverse)

Each layer had a clear purpose:

  • Layer 1 (Colours): The design palette - what colours exist
  • Layer 2 (Design semantics): Colour psychology and theory - what they mean
  • Layer 3 (UI semantics): Component usage - where they're used

This didn't come from planning. It emerged from finding the right abstraction between two fixed endpoints. Proper abstraction creates separation naturally. The 'layers' are an epiphenomenon of good abstraction.

The organisation had revealed what I needed.

Parallel semantics for dark mode

When structure suggests extension

Preparing for parallel mapping

With the semantic layer working, I looked at the file name: config/semantic.css.

Just semantic.css. Singular. Not mode-specific.

But shadcn's pattern had separate definitions for :root and .dark. Different colour assignments for different modes. If semantics were mode-specific, shouldn't the file name reflect that?

I renamed semantic.css to semantic/light.css.

The implications hit immediately. If there's a light.css, there should be a dark.css. Peer files, side-by-side in the same directory. Parallel mappings for different contexts.

Inferring dark mode from contrast

I had my palette of 11 colours. I had my semantic mappings for light mode. Now I needed dark mode mappings.

Traditional approach: manually define dark mode colours, hope they work. But I could do better. I could use contrast checking tools to determine which colours from my palette worked on dark backgrounds.

I used contrast checkers to test my palette against dark backgrounds. Which colours maintained readable contrast? Which needed adjustment?

The pattern emerged:

  • Monochrome pairs swap: 'charcoal' (dark) for light mode foreground becomes background in dark mode. 'Pearl' (light) for light mode background becomes foreground in dark mode.
  • Chromatic hues stay consistent: 'Forest' means brand/hierarchy in both modes. 'Coral' means caution/attention in both modes. Colour psychology doesn't flip with background colour.

Creating parallel design semantics

I created config/semantic/dark.css as a peer to light.css:

/* config/semantic/dark.css */
:root.dark-mode {
  /* Mono system SWAPS */
  --mono-primary-default: var(--color-pearl);    /* Light for dark mode foreground */
  --mono-primary-inverse: var(--color-charcoal); /* Dark for dark mode background */

  /* Hue system STAYS CONSISTENT */
  --hue-primary-default: var(--color-forest);    /* Brand/hierarchy - same in both modes */
  --hue-positive-default: var(--color-green);    /* Success - same sentiment */
  --hue-caution-default: var(--color-coral);     /* Warning - same psychology */
}

Same semantic structure. Same variable names. Different colour assignments where mode demanded it.

My UI layer didn't change at all:

/* themes/inline/core.css - No changes needed! */
@theme inline {
  --color-primary: var(--hue-primary-default);
  --color-success: var(--hue-positive-default);
  --color-foreground: var(--mono-primary-default);
  --color-background: var(--mono-primary-inverse);
}

The @theme layer referenced design semantics. Design semantics adapted per mode. The UI layer got dark mode automatically, with zero duplication.

Organizing the variant

The @custom-variant dark definition was sitting in my main CSS file. Following the 'one concern per file' pattern, I moved it:

/* variants.css */
@custom-variant dark (&:where(.dark-mode, .dark-mode *));

Clean separation. Variant definitions live in their own file.

Now my structure looked like:

css/
├── variants.css                # Dark mode variant
├── config/
│   ├── colors.css              # 11 base colours
│   ├── semantic/
│   │   ├── light.css          # Design semantics for light
│   │   └── dark.css           # Design semantics for dark
├── themes/
│   └── inline/
│       └── core.css           # UI tokens

Seeing light.css and dark.css as peers in the same directory made their relationship obvious. Parallel mappings. Same structure, different assignments.

The organisation was teaching me how the system worked.

From good contrast to guaranteed contrast

Making accessibility systematic

When green on white isn't readable

With dark mode working, I started using the system in components. 'Green' (my hue-positive colour, used for success states) looked great as a badge background. But when I used it for text on a white background?

Barely readable.

<!-- This looks bad -->
<div class="bg-white">
  <span class="text-success">Success!</span>
  <!-- green text, insufficient contrast -->
</div>

I checked the contrast ratio: 2.8:1. WCAG AA requires 4.5:1 for normal text. Accessibility failure.

I could manually darken the colour for text use. But that doesn't scale. What if success colour appears in 50 places? Do I remember to use the darker variant every time? And what about other colours with similar contrast issues?

I needed the system to handle this systematically.

Adding accessible variants

The fix belonged in the design semantics layer, right where I defined what colours mean.

I expanded the variation pattern to include accessible:

/* config/semantic/light.css */
:root {
  --hue-positive-default: var(--color-green);
  --hue-positive-accessible: color-mix(
    in oklch,
    var(--hue-positive-default),
    black 35%
  );

  --hue-caution-default: var(--color-coral);
  --hue-caution-accessible: color-mix(
    in oklch,
    var(--hue-caution-default),
    black 30%
  );
}

The -accessible variant mixed more aggressively with black to guarantee WCAG AA contrast on light backgrounds. Each colour got its own mixing percentage, tuned individually for compliance.

Dark mode was interesting. Most colours were already accessible on dark backgrounds (light colours on dark naturally have high contrast):

/* config/semantic/dark.css */
:root.dark-mode {
  --hue-positive-default: var(--color-green);
  --hue-positive-accessible: var(--hue-positive-default); /* Already accessible! */

  --hue-caution-default: var(--color-coral);
  --hue-caution-accessible: var(--hue-caution-default); /* No adjustment needed */
}

The design semantics layer now encoded mode-specific accessibility logic. Light mode: darken for text. Dark mode: colours already work.

Automatic protection with CSS specificity

Having accessible variants available was great. But developers (including me) would need to remember to use them. I wanted the system to automatically upgrade colours in problematic contexts.

I created layers/contrast-fixes.css:

/* Automatic contrast protection */
.bg-background .text-success,
.bg-white .text-success,
[class*='bg-pearl'] .text-success {
  color: var(--color-success-accessible) !important;
}

.bg-background .border-success,
.bg-white .border-success,
[class*='bg-pearl'] .border-success {
  border-color: var(--color-success-accessible) !important;
}

/* Pattern repeats for all colours with contrast issues */

Using CSS descendant selectors, the system intercepts bad combinations and automatically upgrades to accessible variants. Light background + success text? Intercepted and fixed.

The !important is intentional. This is a safety mechanism, like a circuit breaker. You can't accidentally create an inaccessible combination. The system prevents it.

Now my UI layer could simply reference the default variant:

/* themes/inline/core.css */
@theme inline {
  --color-success: var(--hue-positive-default);
}

And developers could write:

<div class="bg-white">
  <span class="text-success">Success!</span>
  <!-- Automatically upgraded to accessible variant -->
</div>

The contrast-fixes layer intercepts and upgrades automatically based on the background context.

Accessibility became systematic, not aspirational.

From opacity modifiers to colour theory

Enabling first-class state variants

The button that wouldn't hover

shadcn's default button hover state uses opacity: hover:bg-primary/90.

For many colours, this works fine. For saturated colours like my 'forest' green? Barely noticeable.

/* shadcn default */
.button-primary:hover {
  background: oklch(from var(--color-primary) l c h / 0.9);
}

90% opacity of 'forest' (saturated dark green) on a 'pearl' (light) background looked almost identical to 100%. Users couldn't tell if they were hovering.

I could tweak the percentage. 90% → 85%? 80%? But that felt like whack-a-mole. Different colours would need different percentages. And what about active state? Disabled? Would I tune percentages for every colour and every state?

The problem was deeper: opacity modifiers aren't semantic. '90% opacity' doesn't tell me what the colour is for. Is it less important? Recessive? Or more prominent because it's an interaction state?

I needed semantic state variants that worked for all colours.

Mixing with your palette, not just pure extremes

I'd been using color-mix() with pure white and black for my lighter, darker, and accessible variants. This worked for accessibility (you want maximum contrast), but for subtle perceptual changes, it felt wrong.

/* Current approach - mixing with pure extremes */
--hue-primary-lighter: color-mix(in oklch, var(--hue-primary-default), white 20%);

When I mixed 'forest' with pure white, the result was technically lighter, but it felt like it was leaving my design system's intended colour range. It was becoming washed out, less 'forest', more 'generic light green'.

Key insight: not all mixing needs pure white and black.

For accessible variants, you want maximum contrast. Use the pure achromatic extremes (white and black).

But for subtle state changes and perceptual variants, mixing with your actual palette colours keeps variants within your design system's intended range and maintains perceptual relationships.

I introduced mixing anchors:

/* config/semantic/light.css */
:root {
  /* Mixing anchors from the palette */
  --lightest: var(--color-pearl);
  --darkest: var(--color-charcoal);

  /* Subtle perceptual variants use palette anchors */
  --hue-primary-lighter: color-mix(in oklch, var(--hue-primary-default), var(--lightest) 20%);
  --hue-primary-darker: color-mix(in oklch, var(--hue-primary-default), var(--darkest) 15%);

  /* Accessible variants still use pure extremes for max contrast */
  --hue-primary-accessible: color-mix(in oklch, var(--hue-primary-default), black 25%);
}

The beauty: mixing logic stays the same across modes because the anchors adapt:

/* config/semantic/dark.css */
:root.dark-mode {
  /* Same variable names, swapped assignments */
  --lightest: var(--color-pearl);  /* Still pearl, but now for darkening */
  --darkest: var(--color-charcoal); /* Still charcoal, but now for lightening */

  /* Same mixing formulas, perceptually correct results */
  --hue-primary-lighter: color-mix(in oklch, var(--hue-primary-default), var(--lightest) 20%);
}

In dark mode, mixing with 'pearl' (light) makes colours lighter, not darker. The anchor names stay the same, but their semantic effect inverts with the mode. The mixing logic doesn't need to know about modes.

Adding perceptual variants

With proper mixing anchors, I could create meaningful state variants. Instead of thinking in opacity percentages, I thought in perceptual roles:

  • recessive: Faded back, less important (for disabled)
  • default: The base colour
  • vibrant: Enhanced, attention-getting (for hover)
  • prominent: Stronger, more emphasis (for active/pressed)

This is a perceptual axis. From 'fade into the background' through 'normal' to 'grab attention' to 'very strong'.

/* config/semantic/light.css */
:root {
  --hue-primary-default: var(--color-forest);

  /* Perceptual axis */
  --hue-primary-recessive: color-mix(in oklch, var(--hue-primary-default), transparent 60%);
  --hue-primary-vibrant: color-mix(in oklch, var(--hue-primary-default), var(--lightest) 15%);
  --hue-primary-prominent: color-mix(in oklch, var(--hue-primary-default), var(--darkest) 20%);
}

The percentages aren't arbitrary. They're tuned for perceptual difference. Each step on the axis is noticeably different from the previous one. OKLCH ensures the steps are perceptually uniform.

And because these variants live in the design semantics layer, they work for ALL design semantic colours. Every hue-* colour got the perceptual axis. Every mono-* colour got it. Systematic.

Systematic state mappings

Now I could create first-class state tokens in the UI layer:

/* themes/inline/ui-state-colors.css */
@theme inline {
  /* Primary states */
  --color-primary-hover: var(--hue-primary-vibrant);
  --color-primary-active: var(--hue-primary-prominent);
  --color-primary-disabled: var(--hue-primary-recessive);

  /* Success states */
  --color-success-hover: var(--hue-positive-vibrant);
  --color-success-active: var(--hue-positive-prominent);
  --color-success-disabled: var(--hue-positive-recessive);

  /* Pattern repeats for ALL UI semantic colours */
}

The systematic pattern:

  • -hover-vibrant (enhanced for interaction)
  • -active-prominent (strong for pressed state)
  • -disabled-recessive (faded for inactive)

Every UI semantic colour (primary, success, warning, destructive, surface-primary, surface-success, etc.) got the full set of state variants automatically.

My button component could use semantic state tokens:

<template>
  <button
    class="bg-primary hover:bg-primary-hover active:bg-primary-active disabled:bg-primary-disabled"
  >
    Click me
  </button>
</template>

The hover state was now noticeably different. The active state was clearly pressed. The disabled state was obviously inactive.

And all of it was systematic, generated from colour theory principles, not arbitrary opacity values.

From organising to understanding

How file structure revealed the pattern

When separation creates clarity

I need to step back and talk about the process of discovery. This wasn't planned. I didn't design a three-layer architecture on paper and implement it. I organised files post-migration, and the patterns emerged.

In my previous post on abstractions, I explored how abstractions function as the 'User Interface of Ideas', drawing from Don Norman's principles in "The Design of Everyday Things." The key insight: effective abstractions, like effective UI, create affordances (they make possible actions obvious) and constraints (they prevent wrong actions and guide correct ones).

File structure creates affordances for thinking. And constraints for maintaining clarity.

When I separated config/colors.css from themes/inline/core.css, the physical act of separation prompted a question: 'What's the essential difference between these files?' The discomfort of deciding which file a colour definition belonged in forced clarity. One file was about what colours exist in the design palette. The other was about where those colours get used in the interface. The separation revealed the conceptual distinction.

When I created config/semantic/light.css and config/semantic/dark.css as peer files, side-by-side in the same directory, their physical proximity made their relationship obvious. These weren't base and override. They were parallel mappings (two different ways of expressing the same semantic concepts for different contexts). Seeing them as peers revealed they should have parallel structure.

When all my status colours co-located in themes/inline/status-colors.css, the pattern became visible: success, warning, error, info. They all followed the same contrast challenges, they all needed accessible variants. If they'd been scattered across multiple files or buried in a larger file, I might never have noticed the systematic nature.

This is what Norman calls constraints in good design. The structure prevents wrong actions and guides correct ones. I couldn't put a UI semantic token in the config layer without it feeling wrong (wrong file!). I couldn't map colours directly without the intermediate file asking 'but what does this colour mean?'

Physical organisation creates affordances for discovering conceptual organisation.

The discomfort was information. When something felt out of place, when I hesitated about where a definition belonged, that discomfort was a signal. I was missing an abstraction. Creating a new file and naming it forced me to articulate what that missing abstraction was.

The act of organising forced clarity about what I was actually organising.

What this system enables

By the end, the structure looked like this:

css/
├── variants.css                # Custom variant definitions
├── config/
│   ├── colors.css              # 11 base colours in OKLCH
│   ├── semantic/
│   │   ├── light.css          # Design semantics for light mode
│   │   └── dark.css           # Design semantics for dark mode
├── themes/
│   └── inline/
│       ├── core.css           # UI semantic tokens
│       ├── status-colors.css  # Status/contrast tokens
│       └── ui-state-colors.css # State variants
└── layers/
    └── contrast-fixes.css      # Automatic accessibility protection

With this architecture in place, I can:

Rebrand in minutes: Change the 11 colours in Layer 1. Everything else adjusts automatically.

Fix dark mode forever: Dark mode is just a different mapping in Layer 2. Parallel structure, zero duplication.

Guarantee accessibility: Accessible variants in Layer 2 + contrast-fixes in Layer 3 = systematic protection.

Scale states systematically: Every colour gets the same perceptual variants. New UI semantic token? It automatically gets hover/active/disabled states.

Encode designer intent: The design semantics layer documents why colours were chosen, not just which colours. Future developers (including future me) understand the reasoning.

Maintain perceptual consistency: OKLCH + mixing anchors + perceptual variants = colours that actually look right across modes and states.

But the deeper win: the structure teaches you how to use it. The file organisation is the documentation. New developers can open the css/ folder and immediately understand the system by looking at the directory tree.

That's what good abstraction looks like.

What abstractions are hiding in your CSS files, waiting to be revealed through organisation?

Getting started: your own journey

Implementation guide and resources

You don't have to follow my exact path. But if you want to implement this system, here's a practical starting point.

The file structure template

Start with organisation. Create this structure:

css/
├── main.css
├── variants.css
├── config.css
├── config/
│   ├── colors.css
│   ├── semantic.css
│   └── semantic/
│       ├── light.css
│       └── dark.css
├── themes.css
└── themes/
    └── inline/
        ├── core.css
        └── status-colors.css

Your main.css:

@import 'tailwindcss';

/* Custom variants for dark mode */
@import './variants.css';

/* Layer 1: Foundation */
@import './config.css';

/* Layer 2: Semantics */
@import './themes.css';

Each aggregator imports its directory:

/* config.css */
@import './config/colors.css';
@import './config/semantic.css';

/* config/semantic.css */
@import './semantic/light.css';
@import './semantic/dark.css';

/* themes.css */
@import './themes/inline.css';

/* themes/inline.css */
@import './inline/core.css';
@import './inline/status-colors.css';

The structure enforces the dependency order. You can't accidentally reference a theme token in the config layer (wrong file!).

Building your design semantics

Start with your palette. Convert to OKLCH (use a converter like oklch.com):

/* config/colors.css */
:root {
  --color-charcoal: oklch(0.24 0.01 240);
  --color-pearl: oklch(0.96 0.02 85);
  --color-forest: oklch(0.57 0.15 161);
  --color-coral: oklch(0.75 0.18 45);
  /* ... your other colours */
}

Then map them to design semantic meanings. Ask yourself: what does each colour mean in your design system?

/* config/semantic/light.css */
:root {
  /* Mixing anchors */
  --lightest: var(--color-pearl);
  --darkest: var(--color-charcoal);

  /* Mono system (achromatic) */
  --mono-primary-default: var(--color-charcoal);
  --mono-primary-inverse: var(--color-pearl);

  /* Hue system (chromatic) */
  --hue-primary-default: var(--color-forest);  /* Brand/hierarchy */
  --hue-positive-default: var(--color-forest); /* Success sentiment */
  --hue-caution-default: var(--color-coral);   /* Warning/attention */

  /* Add perceptual variants */
  --hue-primary-lighter: color-mix(in oklch, var(--hue-primary-default), var(--lightest) 20%);
  --hue-primary-darker: color-mix(in oklch, var(--hue-primary-default), var(--darkest) 15%);
  --hue-primary-vibrant: color-mix(in oklch, var(--hue-primary-default), var(--lightest) 15%);
  --hue-primary-prominent: color-mix(in oklch, var(--hue-primary-default), var(--darkest) 20%);
  --hue-primary-recessive: color-mix(in oklch, var(--hue-primary-default), transparent 60%);

  /* Accessible variants use pure extremes for maximum contrast */
  --hue-primary-accessible: color-mix(in oklch, var(--hue-primary-default), black 20%);

  /* Repeat pattern for positive, caution, etc. */
}

For dark mode, swap the mono pair and keep hues consistent:

/* config/semantic/dark.css */
:root.dark-mode {
  /* Same mixing anchors */
  --lightest: var(--color-pearl);
  --darkest: var(--color-charcoal);

  /* Mono system SWAPS */
  --mono-primary-default: var(--color-pearl);
  --mono-primary-inverse: var(--color-charcoal);

  /* Hue system STAYS CONSISTENT */
  --hue-primary-default: var(--color-forest);
  --hue-positive-default: var(--color-forest);

  /* Variants recalculate with same logic */
  --hue-primary-vibrant: color-mix(in oklch, var(--hue-primary-default), var(--lightest) 15%);

  /* Accessible variants: lighter colours often already work */
  --hue-primary-accessible: var(--hue-primary-default); /* Already accessible! */
}

Finally, map to UI tokens:

/* themes/inline/core.css */
@theme inline {
  --color-primary: var(--hue-primary-default);
  --color-success: var(--hue-positive-default);
  --color-warning: var(--hue-caution-default);
  --color-foreground: var(--mono-primary-default);
  --color-background: var(--mono-primary-inverse);

  /* State tokens */
  --color-primary-hover: var(--hue-primary-vibrant);
  --color-primary-active: var(--hue-primary-prominent);
  --color-primary-disabled: var(--hue-primary-recessive);
}

Extending incrementally

Start small. You don't need all the variants on day one:

  1. Start with default and inverse - Get the basic three-layer flow working
  2. Add lighter and darker - When you need them
  3. Add accessible variants - When you hit contrast issues
  4. Add vibrant/prominent/recessive - When opacity modifiers aren't enough
  5. Add contrast-fixes - When you want automatic protection

The system grows with your needs. Each addition reveals the next pattern.

A note on complexity: This approach introduces separation that might feel unnecessary for simple projects. If you're building a single-theme marketing site with no dark mode, direct mapping works fine. This architecture pays dividends at scale (multiple themes, complex accessibility requirements, or design systems supporting many products).

Key principles:

  • Let file structure guide you
  • When something feels wrong in a file, listen (you're missing an abstraction)
  • Co-locate related concepts to reveal patterns
  • Use peer files (light.css + dark.css) for parallel mappings
  • One concern per file, always
  • Reference colours via var(--color-*), never use raw values outside colors.css

And remember: this emerged from solving real problems, not from planning. Your problems will be different. Your structure will be different. That's good.

Resources and further reading

Conclusion: uncovering abstractions through bidirectional exploration

This journey began with a practical question: 'How should I organise my CSS files after migrating to Tailwind 4?'

It ended with understanding that design has its own semantics, separate from UI semantics. And that recognising this distinction unlocked a systematic approach to colour that enables zero-duplication dark mode, guarantees accessibility, and makes rebranding a matter of changing 11 values instead of 121.

But the deeper lesson isn't about the specific architecture. It's about the process of discovery through bidirectional exploration.

Top-down until it breaks, then bottom-up until it breaks, then switch directions.

Each iteration: refine, remove, and replace the abstraction between fixed endpoints. The palette colours were fixed (from the designer's style guide). The UI tokens were fixed (from shadcn's component system). The design semantic layer was the hidden abstraction between them, waiting to be uncovered.

I found it by:

  • Trying top-down (style guide → tokens directly): broke on ambiguities
  • Switching to bottom-up (extract palette, build up semantic names): broke on complexity
  • Switching back to top-down (reduce to actual token needs, discover OKLCH): enabled mixing
  • Resuming bottom-up (add structured variations): hit ambiguity pressure from both ends
  • Converging on the middle (design semantics with sentiment): found the abstraction

The best abstractions emerge from best effort in both directions.

In "The Design of Everyday Things," Don Norman teaches us that good design creates affordances (physical properties that suggest how something should be used). File structure does the same for code. By organising CSS into separate files and directories, I created affordances for discovering missing conceptual layers. The physical act of separation prompted questions: 'What's the difference between these?' 'Where does this belong?' 'Why does this feel wrong here?'

The discomfort became information. The structure became a thinking tool.

The design semantics layer wasn't planned. It was uncovered through bidirectional exploration, through letting each direction break and teach me what was missing, through recognising that the fixed endpoints needed something specific between them.

Just as effective abstractions serve as the 'User Interface of Ideas' (as I explored in my previous post), file organisation serves as the UI for discovering those abstractions. Good structure makes possible actions obvious and suggests its own extension.

And good abstraction creates separation naturally. The three layers aren't a design decision. They're the epiphenomenon of finding the right abstraction between palette and UI.

What abstractions are hiding in your CSS files, waiting to be uncovered through bidirectional exploration?

Start organising. Let patterns emerge. Work top-down until it breaks. Work bottom-up until it breaks. Switch directions. Trust the discomfort when something feels out of place. Listen to the questions your file structure asks you.

The abstraction will reveal itself.

And when it does, I'd love to hear about your journey.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment