Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Stop picking colors. Start defining intent.

The Color System is a semantic, automated palette generator designed for modern web applications. It solves the hardest parts of theming—accessibility, dark mode, and consistency—by using math instead of magic numbers.

The Problem

In traditional design systems, you pick a color for “Light Mode” and a color for “Dark Mode”.

  • “Light Card: #ffffff
  • “Dark Card: #1a1a1a

But what happens when you need a “High Contrast” mode? Or a “Dim” mode? Or when your brand color changes from Blue to Purple? You have to manually audit and update every single hex code to ensure text remains readable.

The Solution

This system flips the model. You define Constraints and Intent:

“I need a surface that guarantees APCA 60 contrast against the page background.”

The system solves for the exact lightness value that meets that criteria.

Wait, what is APCA?

APCA (Advanced Perceptual Contrast Algorithm) is the new standard for measuring contrast. Unlike the old WCAG 2.0 math (which was simple but often wrong), APCA models how the human eye actually perceives light and dark.

Don't worry about the math. The system handles it for you. Just know that "APCA 60" means "easy to read".

New to accessibility? The system's defaults are designed to meet or exceed WCAG AA/AAA standards out of the box. You don't need to be an expert to ship an accessible app.

Key Features

  • Automated Contrast: Lightness values are solved mathematically to ensure APCA/WCAG compliance.
  • Themeable: Change a few “Anchors” and “Key Colors” to generate a completely new theme.
  • Platform Native: Built on modern CSS features like oklch, @property, and light-dark().
  • Framework Agnostic: Core logic is pure TypeScript; output is standard CSS.

Getting Started

Check out the Usage section to learn how to install and use the system in your project.

Quick Start

Get up and running with the Color System in under 5 minutes.

1. The Setup

For this guide, we’ll assume you have the CSS file generated (or you’re using the CLI).

<!-- In your HTML head -->
<link rel="stylesheet" href="./theme.css" />

2. Your First Surface

The system works by placing content on Surfaces. Let’s create a simple card.

<body class="surface-page">
  <div class="surface-card">
    <h2 class="text-strong">Hello World</h2>
    <p class="text-subtle">This is my first color system component.</p>
    <button class="surface-action hue-brand">Click Me</button>
  </div>
</body>

What just happened?

  • surface-page: Sets the background of the body.
  • surface-card: Creates a distinct container with a slightly different background color.
  • text-strong: Automatically picks a high-contrast text color for the card.
  • text-subtle: Picks a lower-contrast text color.
  • surface-action: Creates a button background.
  • hue-brand: Tints the button with your brand color.

3. The Magic Switch (Dark Mode)

You don’t need to write any extra CSS for dark mode. Just add the force-dark class to the body (or let the system detect the user’s preference).

<body class="surface-page force-dark">
  <!-- Everything inside here automatically inverts! -->
  <div class="surface-card">...</div>
</body>

The surface-card will become dark, the text-strong will become light, and the hue-brand button will adjust its lightness to remain accessible.

4. Changing the Brand

Want to change your brand color? You don’t need to find-and-replace hex codes. Just update the CSS variable.

:root {
  /* Change from Blue (260) to Pink (330) */
  --hue-brand: 330;
}

Every element using hue-brand (like our button) will instantly update, maintaining perfect contrast ratios.

Next Steps

  • Learn about the different Surfaces available.
  • Understand how Context works.
  • Check out the CLI to generate your own theme.

Philosophy

The Color System is built on three core pillars: Math, Semantics, and Adaptability.

Math vs. Magic

Most color systems are built on Magic Numbers.

“Use Blue-500 for buttons and Gray-100 for cards.”

This forces you to be the calculator.

  1. Is it accessible? You have to know that Blue-800 is safe on Blue-200. Is Blue-500 safe? You have to check a table.
  2. What about Dark Mode? Blue-800 on Blue-200 might work in Light Mode, but in Dark Mode, you need to invert it. Now you’re managing two sets of magic numbers.
  3. Is it consistent? The math of perception is non-linear. A step of “100” in lightness looks different in dark mode than in light mode. To make them feel the same, you have to manually tweak the values.

This system is built on Math.

“I need a Card.”

The system translates this intent into a mathematical rule: Must have APCA 60 contrast against the background.

The Solver handles the complexity for you:

  • It guarantees contrast: It picks the exact lightness to hit APCA 60.
  • It handles polarity: It automatically flips for Dark Mode.
  • It balances perception: It adjusts the lightness steps so that “contrast” feels the same in both modes.
  • It shifts hue: It automatically warms up lighter colors and cools down darker colors (the “Bezold–Brücke effect”) so you don’t have to manually pick “warmer” grays.

You define the intent. The system solves the math.

1. Guarantees Contrast

All these buttons have different hues, but the system mathematically guarantees they meet the same contrast standard.

2. Handles Polarity & Perception

Light Mode
Card
Lc 90 vs Page
Dark Mode
Card
Lc 90 vs Page

The system automatically inverts the colors. Notice how the “Card” feels equally distinct in both modes, even though the actual lightness values are different.

3. Shifts Hue (Bezold–Brücke Effect)

Linear (Boring)
Same hue (260) at all lightness levels.
Shifted (Dynamic)
Hue rotates (Blue → Purple → Pink) as lightness increases.

The system automatically rotates the hue as lightness changes, mimicking natural light and creating a richer, more vibrant palette.

Designing with Intent

Our semantic roles (like “Surface”, “Action”, “Link”) are not arbitrary choices. They are derived directly from the fundamental semantics of the web platform.

By aligning our taxonomy with these platform primitives, we ensure that accessibility is not an “add-on” or a “special case.” It is the foundation of the design. When you design with these concepts, you are designing with the grain of the web, ensuring your application feels native and works perfectly for every user, regardless of their device or settings.

For the curious: Under the hood, we map these roles to CSS System Colors (like Canvas, ButtonFace, Highlight). This is how we support Windows High Contrast mode automatically. But you don’t need to know that to use the system—just use the semantic names.

The Surface Model

In this system, you don’t pick colors. You pick Surfaces.

A Surface is not just a background color. It is a Context Creator. When you place an element on a surface, that surface dictates how text, borders, and other elements should behave to remain accessible.

Polarity: The “Light Switch”

Before understanding specific surfaces, you must understand Polarity. Every surface has a polarity that determines how it reacts to Light and Dark mode.

Page-Aligned
Follows the theme (Light on Light)
Inverted
Opposes the theme (Dark on Light)
  • Page-Aligned: These surfaces follow the global mode. They are light in Light Mode and dark in Dark Mode. (e.g., Cards, Sidebars).
  • Inverted: These surfaces flip the global mode. They are dark in Light Mode and light in Dark Mode. (e.g., Tooltips, Primary Buttons).

The Solver handles this automatically. You just ask for a spotlight (which is inverted), and it ensures it pops against the current theme.

The Categories

The system groups surfaces into four semantic categories based on their intent:

  1. Canvas: The foundation. The “floor” of your app.
  2. Container: Objects that hold content. They sit on the canvas.
  3. Action: Interactive elements. They sit on containers or the canvas.
  4. Spotlight: Attention-grabbing elements. They float above everything.

The Default Hierarchy

The system comes with a standard set of surfaces that map to these categories. While you can create custom surfaces in the Theme Builder, these defaults cover most UI patterns.

Page (Canvas)
The infinite backdrop
Workspace (Canvas)
Elevated area
<div class="surface-card docs-p-4 docs-rounded">
  <strong>Card (Container)</strong>
  <div class="text-subtle docs-mb-4">Contained element</div>

  <div class="surface-tinted docs-p-4 docs-rounded">
    <strong>Tinted (Container)</strong>
    <div class="text-subtle">Subtle grouping</div>
  </div>
</div>

Interactors (Actions)

Actions are surfaces that invite user input. They are the “buttons” and “controls” of your interface.

Spotlights (Attention)

Spotlights are used to draw immediate attention. They use Inverted Polarity to create maximum contrast and visual separation from the page.

Spotlight
High emphasis
Soft Spotlight
Medium emphasis

The Reactive Pipeline

How does this actually work in the browser?

We use a technique called the Reactive Pipeline. Instead of hardcoding hex values into classes, we use CSS Custom Properties (var(--...)) and the Relative Color Syntax (oklch(from ...)).

The Flow

  1. Input Variables: You set high-level intent variables.

    .hue-brand {
      --hue-brand: 250;
    }
    
  2. The Engine (engine.css): The engine listens to these variables and recalculates the colors in real-time.

    /* Simplified Engine Logic */
    --computed-surface: oklch(from var(--surface-token) l c var(--hue-brand));
    
  3. The Output: The browser renders the final color.

Why is this powerful?

  • Instant Theming: Change --hue-brand on the <body>, and the entire app updates instantly. No re-compiling CSS.
  • Scoped Theming: Change --hue-brand on a specific <div>, and only that section changes color.
  • Animation: Because these are just numbers, you can animate them! The system handles the color interpolation for you.

Context Consumers

Text and borders are Context Consumers. They don’t have their own colors; they calculate their color based on the surface they are sitting on.

  • text-strong: “I need high contrast against whatever surface I am on.”
  • text-subtle: “I need to be readable, but less prominent.”
  • bordered: “I need a decorative border that is visible on this surface.”

This means you can copy-paste a component from a light card to a dark spotlight, and the text will automatically invert to remain readable.

Strong Text
Subtle Text
Bordered Element
Strong Text
Subtle Text
Bordered Element

Core Concepts

This section details the fundamental building blocks of the color system.

  • Surfaces: The containers that create context.
  • Context: How polarity and mode affect color calculation.
  • Accessibility: How the system handles high contrast and print.

Surfaces & Context

Everything sits on a Surface. A surface is not just a background color; it creates a Context for everything inside it.

Page
Background
Card
Content
Action
Interactive
Spotlight
Emphasis

When you nest surfaces (e.g., a Card on a Page), the system automatically adjusts the context so that text and borders maintain perfect contrast.

Foregrounds (Text & Icons)

Text utilities consume the Context provided by the surface. You don’t need to know which surface you are on; you just declare the hierarchy.

Text Strong (Primary)
Text Subtle (Secondary)
Text Subtler (Meta)
  • text-strong (Default): Primary content. High contrast.
  • text-subtle: Secondary content. Medium contrast.
  • text-subtler: Meta-data or low-emphasis content.
  • text-link: Interactive navigation elements. Uses the brand hue.

States

Interactive elements have standard states that work across all surfaces.

  • hover / active: Interaction feedback.
  • state-selected: For chosen items (e.g., a selected list option). Maps to System Highlight.
  • state-disabled: For non-interactive items. Maps to System GrayText.

Borders

Borders also consume the surface context.

Bordered
Decorative edge
Interactive
Input / Active edge
  • bordered: Adds a decorative border (low contrast) to define the edge of a surface.
  • border-interactive: A higher contrast border for inputs or active elements.

Surfaces

A Surface is the fundamental building block of the Color System. It is not just a background color; it is a semantic container that defines the context for everything inside it.

The Role of a Surface

When you apply a surface class (e.g., .surface-card), the system does three things:

  1. Sets the Background: It applies the calculated background color.
  2. Sets the Context: It updates local CSS variables (like --context-text-strong) to ensure text is readable on this specific surface.
  3. Sets the Border: It defines the default border color for elements inside it.

Surface Types

Page
The Foundation
Workspace
Sidebar / Dashboard
Card
Content Container
Tinted
Grouped Item

1. The Canvas (Foundations)

These surfaces form the backdrop of your application.

Page
Infinite background
Workspace
Elevated area
  • surface-page: The infinite background. Usually the lightest (in light mode) or darkest (in dark mode) point.
  • surface-workspace: A slightly elevated area, often used for sidebars, navigation rails, or the main content area in a dashboard.

2. The Objects (Containers)

These surfaces hold content. They sit on top of the canvas.

Card
Distinct boundary
Tinted
Subtle grouping
  • surface-card: The workhorse of UI design. Used for panels, posts, and grouped content.
  • surface-tinted: A subtle variation, often used to group related items without a hard boundary. It usually has a slight tint of the brand color.

3. The Interactors (Actions)

These surfaces are interactive. They invite clicks and touches.

  • surface-action: Used for buttons, toggles, and active states.
  • surface-action-soft: A lower-emphasis interactive surface (e.g., a secondary button).

4. The Spotlights (Attention)

These surfaces demand attention. They often invert the polarity to stand out.

Spotlight
High Contrast
Soft Spotlight
Medium Contrast
  • surface-spotlight: High contrast. Used for tooltips, toasts, and primary call-to-actions.
  • surface-soft-spotlight: A softer version, often used for badges or indicators.

Nesting Surfaces

Surfaces are designed to be nested. The system automatically adapts text contrast based on the parent surface.

Text on Page
Text on Card
Text on Spotlight

Context & Polarity

Context is the mechanism that allows the color system to adapt to different environments (Light Mode, Dark Mode, High Contrast) without changing your code.

Polarity

The system divides the world into two “polarities”:

1. Page Polarity (page)

This is the standard polarity. It follows the system theme.

  • Light Mode: Light background, Dark text.
  • Dark Mode: Dark background, Light text.

Most surfaces (surface-page, surface-card) use Page Polarity.

2. Inverted Polarity (inverted)

This polarity flips the relationship.

  • Light Mode: Dark background, Light text.
  • Dark Mode: Light background, Dark text (or a lighter dark).

Surfaces like surface-spotlight (Tooltips, Toasts, Primary Buttons) often use Inverted Polarity to stand out.

The Context Provider Pattern

Every surface acts as a Context Provider. It sets CSS variables that tell its children how to behave.

/* Simplified CSS */
.surface-card {
  /* I am a light surface */
  --context-text-strong: #000000;
  --context-border: #cccccc;
}

.surface-spotlight {
  /* I am a dark surface */
  --context-text-strong: #ffffff;
  --context-border: transparent;
}

When you use a utility class like .text-strong, it doesn’t have a color of its own. It simply asks the context:

.text-strong {
  color: var(--context-text-strong);
}
sequenceDiagram
    participant Parent as .surface-card
    participant Child as .text-strong

    Parent->>Parent: Sets --context-text-strong: #000
    Child->>Parent: Reads var(--context-text-strong)
    Child->>Child: Applies color: #000

Live Demo

The following example is rendered using the actual Color System CSS. Notice how the text color automatically adapts when nested inside the dark surface-spotlight.

I am on the Page Surface.

I am on a Card Surface (Page Polarity).

I am on a Spotlight Surface (Inverted Polarity).

Why is this useful?

  1. Portability: You can move a component from a Card to a Spotlight, and the text will automatically invert.
  2. Maintainability: You don’t need to write .card .text and .spotlight .text overrides.
  3. Nesting: It handles infinite nesting levels automatically.

Accessibility

The Color System is designed with accessibility as a first-class citizen. It automates many of the tedious aspects of compliance, ensuring your theme is inclusive by default.

Contrast Compliance (APCA)

The core solver uses the APCA (Advanced Perceptual Contrast Algorithm) to calculate lightness values. APCA is the candidate method for WCAG 3.0 (Silver), representing the future of web accessibility standards.

Unlike WCAG 2.1, which uses simple ratios (e.g., 4.5:1) that can be inaccurate for dark modes, APCA models human visual perception. It accounts for:

  • Polarity: Dark text on light backgrounds is perceived differently than light text on dark backgrounds.
  • Spatial Frequency: Thinner fonts require higher contrast than bolder fonts to be equally legible.
  • Context: The surrounding luminance affects perceived contrast.

By using APCA, the system ensures your theme is not just “compliant” with a checklist, but genuinely readable for all users in all modes.

  • Text: The system automatically selects text colors that meet the required Lc (Lightness Contrast) scores for their context.
  • Borders: Decorative and interactive borders are generated with specific contrast targets to ensuring visibility without clutter.

Forced Colors (Windows High Contrast)

The system includes built-in support for Forced Colors Mode (commonly known as Windows High Contrast). This mode is used by people with low vision to strip away complex styling and enforce a limited, high-contrast palette.

How it Works

The CSS Engine detects forced-colors: active and automatically maps your semantic surfaces to standard System Colors:

SurfaceSystem Color
surface-cardCanvas / CanvasText
surface-actionButtonFace / ButtonText
state-selectedHighlight / HighlightText
text-linkLinkText

Testing

You can verify this behavior in Chrome/Edge DevTools:

  1. Open the Command Menu (Ctrl+Shift+P / Cmd+Shift+P).
  2. Type “Show Rendering” and select it.
  3. Scroll down to Emulate CSS media feature prefers-contrast.
  4. Or, look for Emulate CSS media feature forced-colors and set it to active.

High Contrast Preference

The system respects the user’s operating system preference for higher contrast (prefers-contrast: more).

When you run color-system build (or solve), the generator automatically creates a High Contrast Variant of your theme. This variant:

  1. Widening Anchors: Pushes background and foreground anchors to pure Black (0%) and White (100%) to maximize dynamic range.
  2. Desaturation: Removes chroma from text and surfaces to reduce visual noise and improve legibility.

This variant is wrapped in a @media (prefers-contrast: more) block in your theme.css, so the browser applies it instantly with zero runtime overhead.

The system includes a print stylesheet (@media print) that optimizes your theme for paper.

Strategy: “Ink & Paper”

Instead of trying to translate your digital theme directly to print, we map it to the physical constraints of ink and paper:

  1. Force Light Mode: We explicitly set color-scheme: light to ensure all light-dark() tokens resolve to their light values. This prevents printing dark backgrounds which waste massive amounts of ink.
  2. Remove Chroma: We set --base-chroma: 0 to remove all saturation. This ensures that even if a surface has a slight tint in light mode, it prints as grayscale.
  3. White Backgrounds: We explicitly set the background of main surfaces (.surface-card, etc.) to white.
    • Why hardcode white? In the context of print, white represents the paper. By setting it explicitly, we ensure that no background ink is laid down, even if the user has “Background Graphics” enabled in their print settings.
    • No !important: We rely on the low specificity of our CSS Engine (:where(...)) to allow these print overrides to apply naturally without needing !important.
  4. Borders: Since we remove background colors, we add a 1px solid border using the text-subtlest token to maintain the visual structure of cards and sections.
  5. Cleanup: We hide purely interactive elements (like .surface-action) that serve no purpose on a static page.

The Solver

The Solver is the engine that powers the color system. It takes your high-level Intent and turns it into precise CSS Tokens.

You can interact with the Theme Builder in two ways:

  1. The UI: The interactive web interface (for exploration).
  2. The CLI: The color-system command line tool (for production).

Both use the exact same “Solver” logic under the hood.

The “Theme Builder” Model

To understand the solver, it helps to think about the controls you see in the Theme Builder UI. The solver is simply the code that runs every time you move a slider or add a surface.

1. Anchors: Defining the Playing Field

In the Theme Builder, you set the Anchors. These are the boundaries of your color system.

Page Anchors

Defines the lightness range for the "Page" polarity.

Start (Background) 0.98
End (Elevated) 0.12

The solver takes these values and asks: “Can I fit readable text inside this range?” If the answer is “No” (and the anchor is adjustable), the solver moves the slider for you until the text is readable.

2. Surfaces: The “Steps”

In the Theme Builder, you add Surfaces to a list.

Surfaces

Page .surface-page Passes
<!-- Surface Item 2 -->
<div class="surface-card docs-p-2 docs-rounded docs-border" style="display: flex; align-items: center; gap: 1rem;">
  <span class="text-strong" style="flex: 1;">Card</span>
  <code class="text-subtle">.surface-card</code>
  <span style="font-size: 0.8em; background: #dcfce7; color: #166534; padding: 2px 6px; border-radius: 4px;">Passes</span>
</div>

<!-- Surface Item 3 -->
<div class="surface-card docs-p-2 docs-rounded docs-border" style="display: flex; align-items: center; gap: 1rem;">
  <span class="text-strong" style="flex: 1;">Sidebar</span>
  <code class="text-subtle">.surface-sidebar</code>
  <span style="font-size: 0.8em; background: #dcfce7; color: #166534; padding: 2px 6px; border-radius: 4px;">Passes</span>
</div>

The solver’s job is to place these surfaces evenly between your Start and End anchors. It doesn’t just divide the lightness evenly (e.g., 10%, 20%, 30%). It divides the Contrast Space evenly. This ensures that the visual “step” from Page to Card looks the same as the step from Card to Sidebar.

Why Contrast Space?

If we just divided the lightness values evenly (Linear Lightness), the steps would look uneven to the human eye. Dark colors bunch up, and light colors spread out. By dividing by Contrast (Linear Perception), every step feels visually consistent.

Linear Lightness (Bad)
Mathematically equal steps (-10%). To the eye, the lighter steps feel too jumpy, while the darker steps blend together.
Linear Contrast (Good)
Perceptually equal steps. The solver makes larger adjustments to the darker shades to ensure every step feels distinct.

3. The Result: Generated Tokens

Finally, the solver outputs the CSS tokens that the Theme Builder (and your app) uses.

Generated CSS

--lightness-surface-page: light-dark(0.98, 0.12);
--lightness-surface-card: light-dark(0.95, 0.15);
--lightness-surface-sidebar: light-dark(0.92, 0.18);

The Pipeline

When you run pnpm solve (or change a setting in the Builder), this pipeline executes:

  1. Hydrate: Read your config.
  2. Adjust Anchors: Ensure the range supports High Contrast text.
  3. Distribute: Calculate the target contrast for each surface.
  4. Solve Lightness: Use binary search to find the exact lightness value that hits that contrast target.
  5. Solve Text: Find the text colors that sit accessibly on top of those surfaces.
  6. Generate: Write the CSS.

Deep Dive

This section covers the internal mechanisms of the Color System. You don’t need to understand these to use the system, but they are helpful for advanced configuration and customization.

  • Anchors: How the system defines the lightness range for a theme.
  • Hue Shifting: The algorithm used to simulate natural color shifts in shadows and highlights.

Glossary

  • Anchor: A fixed point (like “Black” or “White”) that defines the extremes of your theme.
  • APCA: The math used to calculate contrast. It’s better than the old WCAG math.
  • Context: The environment a color lives in. Is it on a dark background? Is it in light mode?
  • Hue Shift: The natural phenomenon where colors change hue as they get lighter or darker (e.g., Blue turns Purple as it gets lighter).
  • Polarity: The “direction” of contrast. “Page” polarity is Light-on-Light (standard). “Inverted” polarity is Dark-on-Light (high contrast).
  • Surface: A container that has a background color and defines the context for its children.

Understanding APCA

The Advanced Perceptual Contrast Algorithm (APCA) is a new method for calculating contrast that aligns much closer to how the human eye actually perceives light and color.

Is this standard? Yes. APCA is the candidate contrast method for WCAG 3.0 (the next generation of web accessibility standards). While WCAG 2.x is the current legal baseline in many places, APCA represents the future of accessibility science. By using it, you are future-proofing your design and providing a better experience for users today, even if the math is different.

Unlike the older WCAG 2.x ratio (e.g., 4.5:1), APCA produces a score called Lc (Lightness Contrast).

Why not WCAG 2.x?

The old math was simple, but flawed. It treated all colors equally. But your eye doesn’t.

The “Font Weight” Problem

WCAG 2.x treats all text the same, regardless of how thin or thick it is. But your eye needs much more contrast to see a thin line than a thick block.

WCAG Passes (4.5:1) Passes
Thin Text
Technically passes WCAG AA.
Hard to read.
WCAG Fails (3.5:1) Fails
Bold Text
Technically fails WCAG AA.
Easy to read.

APCA understands this. It would give the thin text a lower score and the bold text a higher score, matching your perception.

The “Polarity” Problem

WCAG 2.x treats White on Black exactly the same as Black on White. But your eye doesn’t.

Positive Polarity
Dark on Light
The eye resolves this detail very well.
Negative Polarity
Light on Dark
The eye needs slightly more contrast here to avoid "halation".

The Solver accounts for this. It picks slightly different lightness values for Dark Mode to ensure the perceptual contrast remains constant.

The Lc Score

APCA outputs a score from Lc 0 (invisible) to Lc 106 (pure black on pure white).

ScorePerceptionUse Case
Lc 15Barely visibleWatermarks, decorative borders
Lc 30SubtleDisabled text, placeholders
Lc 45Readable (Large)Large headlines, non-critical text
Lc 60Readable (Body)The gold standard for body text
Lc 75PreferredPreferred for long-form reading
Lc 90High ContrastSpotlights, critical actions

Visualizing Contrast

Lc 15 (Decorative)
Lc 30 (Disabled)
Lc 45 (Headlines)
Lc 60 (Body Text)
Lc 90 (High Contrast)
Lc 15 (Decorative)
Lc 30 (Disabled)
Lc 45 (Headlines)
Lc 60 (Body Text)
Lc 90 (High Contrast)

Note: The opacity values above are approximations for demonstration. The actual system calculates precise lightness values.

Anchors

Anchors are the fixed points that define the dynamic range of your color theme. They tell the solver where to start and where to end.

The Concept

Imagine a slider that represents Lightness (0% to 100%).

  • In Light Mode, your page background might be at 98% lightness.
  • In Dark Mode, your page background might be at 10% lightness.

These points are Anchors. The solver uses them to calculate the available space for all other surfaces.

graph LR
    subgraph Light Mode
    L_Start[Start: 0.98] --- L_Range[Dynamic Range] --- L_End[End: 0.90]
    end

    subgraph Dark Mode
    D_Start[Start: 0.10] --- D_Range[Dynamic Range] --- D_End[End: 0.25]
    end

    style L_Start fill:#f9f9f9,stroke:#333
    style L_End fill:#e0e0e0,stroke:#333
    style D_Start fill:#1a1a1a,stroke:#fff,color:#fff
    style D_End fill:#404040,stroke:#fff,color:#fff

Anchor Types

1. Start Anchor (bg-start)

This defines the background of your application (usually surface-page).

  • Light Mode: Typically high lightness (e.g., 0.98).
  • Dark Mode: Typically low lightness (e.g., 0.10).

2. End Anchor (bg-end)

This defines the lightness of the most elevated or “highest” surface in the stack.

  • Light Mode: Usually slightly darker than the start (e.g., 0.90), creating a “layered” effect where cards are slightly darker than the page.
  • Dark Mode: Usually lighter than the start (e.g., 0.20), creating a “light-on-dark” effect.

3. Foreground Anchors (fg-start, fg-end)

These define the range for text and icons.

  • Strong Text: Needs high contrast against the background.
  • Subtle Text: Needs lower contrast but still readable.

Adjustable Anchors

Some anchors can be marked as adjustable. This gives the solver permission to move them if necessary to meet accessibility goals.

Example: If you set your Dark Mode background to 0.0 (Pitch Black), but your brand color has low luminance, the solver might not be able to find a text color that passes APCA 60. If the background anchor is adjustable, the solver might bump it up to 0.05 to reduce the contrast requirement or find a better middle ground.

Configuration

Anchors are defined in color-config.json:

{
  "anchors": {
    "light": {
      "bg": { "start": 0.98, "end": 0.9 },
      "fg": { "start": 0.1, "end": 0.5 }
    },
    "dark": {
      "bg": { "start": 0.1, "end": 0.25 },
      "fg": { "start": 0.95, "end": 0.7 }
    }
  }
}

Hue Shifting

Why Shift Hues?

In the real world, objects rarely stay the same hue as they get lighter or darker.

  • Natural Light: Shadows are often cooler (bluer) due to ambient skylight, while direct highlights are warmer (yellower) from the sun.
  • The Bezold–Brücke Effect: As light intensity increases, our perception of hue shifts. Reds become yellower, and violets become bluer.

If you create a color palette by simply changing lightness (e.g., oklch(0.5 0.2 260)oklch(0.9 0.2 260)), the result can feel “synthetic” or “flat”. The shadows might look muddy, or the highlights might look washed out.

Hue Shifting mimics natural light physics by rotating the hue as lightness changes. This creates palettes that feel:

  1. More Dynamic: Colors feel alive rather than static.
  2. More Natural: Mimics the way light interacts with surfaces in the real world.
  3. More Distinct: Helps differentiate surfaces that are close in lightness.
Static Hue (Boring)
Just changing lightness. The result feels mechanical and flat.
Shifted Hue (Dynamic)
Rotating hue from Blue (260) to Purple (320) as lightness increases. The result feels more vibrant and natural.

Why Non-Linear Hue Shifting?

The color system supports optional hue rotation across the lightness spectrum. This feature allows surfaces to shift from cooler tones in darker regions to warmer tones in lighter regions (or vice versa), creating a more dynamic and perceptually harmonious color palette.

The Problem with Linear Shifting

A naive implementation might apply hue rotation linearly. However, this doesn’t match human perception. Our eyes perceive warmth and coolness non-linearly across the lightness spectrum.

No Shift (Static Hue)
Shadows can feel "muddy" or flat because they lack the natural coolness of ambient light.
Linear Shift
A linear shift (0° to 60°) can feel abrupt in the mid-tones, making the color change too noticeable.

Cubic Bezier Solution

Instead, we use a cubic Bezier curve to map lightness values (0-1) to hue rotation factors (0-1). This allows us to keep the hue stable in the deep shadows and bright highlights, while concentrating the shift in the mid-tones where it adds the most vibrancy.

No Shift
Static hue.
Linear Shift
Abrupt mid-tones.
Bezier Shift
Smooth S-curve.
function cubicBezier(t: number, p1: number, p2: number): number {
  const oneMinusT = 1 - t;
  return (
    3 * oneMinusT * oneMinusT * t * p1 + 3 * oneMinusT * t * t * p2 + t * t * t
  );
}

export function calculateHueShift(
  lightness: number,
  config?: HueShiftConfig
): number {
  if (!config) return 0;
  const { curve, maxRotation } = config;
  const factor = cubicBezier(lightness, curve.p1[1], curve.p2[1]);
  return factor * maxRotation;
}

Control Points

The default configuration uses control points that create an S-curve:

{
  "hueShift": {
    "curve": {
      "p1": [0.5, 0],
      "p2": [0.5, 1]
    },
    "maxRotation": 180
  }
}

What These Mean

  • P1: [0.5, 0]: First control point at 50% horizontally, 0% vertically
  • P2: [0.5, 1]: Second control point at 50% horizontally, 100% vertically

This creates a smooth S-curve that:

  1. Starts slowly at lightness = 0 (minimal hue shift in darks)
  2. Accelerates through mid-tones (where our eyes are most sensitive)
  3. Finishes smoothly at lightness = 1 (full rotation in lights)

Visual Comparison

Here is how the hue rotation (0° to 180°) is applied across the lightness spectrum (0.1 to 0.9).

Linear
Bezier
Dark
Mid
Light

Notice how the Bezier curve:

  • Flattens at the extremes (smoother transitions in very dark/light)
  • Steepens in the middle (more dramatic shift where it matters)

Perceptual Benefits

  1. Natural Warmth Progression: Mimics how we perceive natural lighting (cool shadows → neutral midtones → warm highlights)

  2. Better Mid-Tone Separation: The steeper middle section ensures distinct hue differences between closely-spaced surfaces in the mid-lightness range (where most UI elements live)

  3. Smooth Extremes: The flatter curves at 0 and 1 prevent jarring hue jumps in already-extreme lightness values

Customization

You can customize the curve by adjusting the control points:

{
  "hueShift": {
    "curve": {
      "p1": [0.3, 0], // Shift acceleration point earlier
      "p2": [0.7, 1] // Shift deceleration point later
    },
    "maxRotation": 120 // Less dramatic overall shift
  }
}

Experiment with:

  • Moving P1/P2 horizontally to change where the acceleration happens
  • Moving P1/P2 vertically to create asymmetric curves
  • Adjusting maxRotation for subtler or more dramatic effects

Tip: You can also adjust these settings visually in the Theme Builder. The UI provides a curve editor where you can drag the control points and see the color palette update in real-time.

Implementation Note

The cubic Bezier implementation assumes the curve starts at (0,0) and ends at (1,1), with only the middle two control points configurable. This constraint ensures the hue shift is always 0° at lightness 0 and maxRotation at lightness 1, providing predictable behavior while allowing artistic control over the interpolation.

  • OKLCH Color Space: Hue rotations happen in the perceptually uniform OKLCH space, ensuring equal visual impact across the spectrum
  • Chroma Independence: Hue shifts don’t affect saturation, maintaining consistent vibrancy
  • CSS @property: Registered custom properties allow smooth animated transitions between hue-shifted values

Usage

The Color System can be used in two ways:

  1. Build Time (CLI): Generate a static CSS file with all your tokens. Best for performance and standard projects.
  2. Run Time (Browser): Generate tokens on the fly in the browser. Best for user-customizable themes.

Quick Start

The fastest way to understand the system is to play with the Theme Builder.

Open the Interactive Demo

Once you have a configuration you like, you can export the color-config.json and use it with the CLI.

Installation

pnpm add -D color-system

Next Steps

CLI Usage

The color-system CLI is the primary tool for generating your theme tokens.

Installation

pnpm add -D color-system
# or
npm install -D color-system

Commands

init

Scaffolds a new configuration file in your project.

npx color-system init

This creates a color-config.json file with default settings.

Generate

Generates the CSS tokens based on your configuration.

npx color-system [config-file] [output-file]
  • config-file: Path to your JSON config (default: ./color-config.json).
  • output-file: Path where the CSS will be written (default: ./theme.css).

Example:

npx color-system ./design/colors.json ./src/styles/theme.css

Configuration

The configuration file controls the Theme Builder. See the Theme Builder API for details on how the math works.

{
  "anchors": { ... },
  "groups": [ ... ],
  "hueShift": { ... }
}

CSS Architecture

This document details how the generated CSS works. Whether you use the CLI or the Runtime, the underlying CSS model is the same.

Generated Tokens

When you run the Theme Builder (CLI or UI), it generates a CSS rule for each surface. Inside that rule, it assigns values to a standard set of Local Tokens.

For a surface named card, the .surface-card class defines:

TokenDescription
--surface-tokenThe background color of the surface itself.
--text-high-tokenThe color for high-emphasis text (e.g., headings) on this surface.
--text-subtle-tokenThe color for medium-emphasis text (e.g., body) on this surface.
--text-subtlest-tokenThe color for low-emphasis text (e.g., metadata) on this surface.
--border-dec-tokenA low-contrast border color for decorative separation.
--border-int-tokenA higher-contrast border color for interactive boundaries (inputs, buttons).

How they are used

These tokens are scoped to the surface class. They reuse the same names (e.g., --text-high-token) but have different values depending on which surface you are inside.

.surface-card {
  /* The background uses the surface token directly */
  --surface-token: light-dark(...);

  /* The text tokens are exposed to children via Context Variables */
  --context-text-high: var(--text-high-token);
  --context-text-subtle: var(--text-subtle-token);
  --context-text-subtlest: var(--text-subtlest-token);
  --context-border-decorative: var(--border-dec-token);
  --context-border-interactive: var(--border-int-token);
}

This means you rarely use --text-high-token directly. Instead, you use the utility class .text-strong, which consumes --context-text-high.

The Context Model

We use a Context Provider/Consumer pattern to handle composition and nesting. This allows surfaces to be nested infinitely without the child needing to know about the parent.

1. Provider (The Surface)

A surface class (like .surface-card) does two things:

  1. Sets its own background: It uses the generated token for its background color.
  2. Sets the Context: It redefines the “meaning” of generic variables for its children.
.surface-card {
  /* 1. Set Background */
  background-color: var(--lightness-surface-card);

  /* 2. Set Context for Children */
  /* "When inside a card, 'subtle text' should look like this..." */
  --context-text-subtle: var(--lightness-subtle-on-card);
  --context-border-decorative: var(--border-decorative-on-card);
}

2. Consumer (The Utility)

A utility class (like .text-subtle) is “dumb”. It doesn’t know what color it is. It just asks the context.

.text-subtle {
  /* "I don't know where I am, but I'll use whatever my parent says is 'subtle'" */
  --surface-text-lightness: var(--context-text-subtle);
}

Why this matters

This ensures Orthogonality:

  • Adding a new surface doesn’t require updating every utility class.
  • You can nest a .surface-card inside a .surface-sidebar inside a .surface-page, and the text will always have the correct contrast for its immediate parent.

Accessibility Implementation

The system handles accessibility automatically through Taxonomy Alignment.

Forced Colors (Windows High Contrast)

We use a Variable Swap trick to support Forced Colors mode without needing manual overrides for every surface.

  1. The Engine: All surfaces use the variable --computed-surface for their background.
  2. The Swap: In engine.css, we detect Forced Colors mode and swap the definition of that variable globally.
/* Normal Mode */
:root {
  /* Calculated from tokens */
  --computed-surface: oklch(...);
}

/* Forced Colors Mode */
@media (forced-colors: active) {
  :root {
    /* Swapped to System Color */
    --computed-surface: Canvas;
    --computed-fg-color: CanvasText;
  }
}

What this means for you:

  • Custom Surfaces: Any surface you create (e.g., .surface-sidebar) automatically becomes Canvas (Background) and CanvasText (Foreground) in High Contrast mode. You don’t need to do anything.
  • Semantic Overrides: If you are building a custom button surface, you may want to map it to ButtonFace. You can do this with a simple CSS override:
@media (forced-colors: active) {
  .surface-my-button {
    --computed-surface: ButtonFace;
    --computed-fg-color: ButtonText;
  }
}

High Contrast (APCA)

Good News: The default themes generated by this system are already highly accessible.

  • Body text targets Lc 60+ (roughly WCAG AA/AAA).
  • Headings target Lc 75+.
  • Spotlights target Lc 90.

For most users, the standard theme is sufficient.

Prefers Contrast Some users explicitly request even higher contrast via their OS settings (prefers-contrast: more). This is supported on macOS and Windows.

The system automatically handles this by generating a High Contrast Variant during the build process. This variant:

  1. Widens Anchors: Pushes background and foreground anchors to pure Black (0%) and White (100%).
  2. Desaturates: Removes chroma from text and surfaces to reduce visual noise.

This variant is automatically appended to your theme.css inside a @media (prefers-contrast: more) block.

/* Standard Theme (Default) */
.surface-card {
  ...;
}

/* High Contrast Override (Automatic) */
@media (prefers-contrast: more) {
  :root {
    --base-chroma: 0; /* Force Grayscale */
  }
  .surface-card {
    /* Re-generated tokens with widened anchors */
    --surface-token: ...;
    --text-subtle-token: ...;
  }
}

This allows you to maintain your brand identity while satisfying users who need sharper edges and deeper blacks, with zero runtime cost.

UI Primitives

The Color System provides essential UI primitives that adapt to the current theme context.

Elevation (Shadows)

Shadows provide depth and hierarchy. The system generates a semantic scale of shadows that are subtle in Light Mode and stronger in Dark Mode to ensure visibility.

shadow-sm
shadow-md
shadow-lg
shadow-xl

Usage

Use the utility classes or CSS variables:

.my-card {
  box-shadow: var(--shadow-md);
}
/* OR */
<div class="shadow-md">...</div>

Focus Indicators

Accessible focus indicators are critical for keyboard navigation. The system provides a universal focus ring that adapts to the brand color and ensures contrast.

Usage

Apply the .focus-ring utility class to interactive elements. It applies styles on :focus-visible.

<button class="surface-action focus-ring">Click Me</button>

The focus ring uses the --focus-ring-color variable, which is derived from your brand hue.

:root {
  --focus-ring-color: ...; /* Auto-generated */
}

Data Visualization

The Color System includes a built-in engine for generating categorical color palettes that harmonize with your theme. These palettes are designed for data visualization (charts, graphs, maps) where you need distinct colors to represent different categories.

The Problem

Standard color palettes (like “Tableau 10” or “D3 Category 10”) are great, but they often clash with your custom theme.

  • If your theme is “Soft Pastel”, a neon chart looks out of place.
  • If your theme is “High Contrast”, a subtle chart might be illegible.
  • In Dark Mode, standard colors often lose contrast or look muddy.

The Solution: Harmonized Fixed Hues

Instead of using a fixed set of hex codes, we use a Harmonized Fixed Hues strategy:

  1. Fixed Hues: We start with a curated list of distinct hues (Red, Orange, Yellow, Green, etc.) to ensure every color is nameable and distinct.
  2. Solved Lightness: We solve the lightness of each color against your page background to ensure it meets accessibility targets (APCA ~105).
  3. Shared Chroma: We apply a consistent chroma (vibrancy) across the palette, which you can tune to match your brand.

Usage

The system generates CSS variables in the format --chart-N:

.my-chart {
  color: var(--chart-1); /* First color in the palette */
}

.my-chart-bar:nth-child(2) {
  background-color: var(--chart-2);
}

Configuration

You can customize the palette in your color-config.json:

{
  "palette": {
    "targetChroma": 0.12,
    "hues": [25, 45, 85, 125, 150, 190, 250, 280, 320, 360]
  }
}
  • targetChroma: Controls the vibrancy. 0.12 is a safe default. Higher values (e.g., 0.18) are more vibrant but might be harder to balance in Light Mode.
  • hues: An array of hue angles (0-360) to use for the palette.

Accessibility

Because the lightness is solved relative to the background:

  • In Light Mode, the colors will be darker (like text) to stand out against the white page.
  • In Dark Mode, the colors will automatically flip to be lighter (pastels) to stand out against the dark page.

This ensures your charts are always legible, regardless of the user’s theme preference.

Framework Integration

The Color System is framework-agnostic because it outputs standard CSS. However, you can easily integrate it with your favorite tools.

React

Since the system uses standard CSS classes, you can use them directly in your JSX.

function Card({ children }) {
  return (
    <div className="surface-card p-4 rounded-lg">
      <h2 className="text-strong text-xl">Title</h2>
      <p className="text-subtle">{children}</p>
    </div>
  );
}

Dynamic Theming

To change the theme dynamically (e.g., user-selected brand color), you can use the runtime module.

import { useEffect } from "react";
import { injectTheme, generateTheme } from "color-system/runtime";

function ThemeProvider({ brandColor }) {
  useEffect(() => {
    const css = generateTheme({
      keyColors: { brand: brandColor },
    });
    const style = injectTheme(css);
    return () => style.remove();
  }, [brandColor]);

  return <slot />;
}

Tailwind CSS

You can configure Tailwind to use the system’s CSS variables.

// tailwind.config.js
module.exports = {
  theme: {
    colors: {
      // Map Tailwind utilities to System variables
      surface: {
        page: "var(--computed-surface-page)",
        card: "var(--computed-surface-card)",
      },
      text: {
        strong: "var(--computed-text-strong)",
        subtle: "var(--computed-text-subtle)",
      },
    },
  },
};

Note: We are working on a dedicated Tailwind plugin to automate this mapping.

API Reference

The Color System API is divided into three parts based on where you use them.

1. Build Pipeline

Tools for generating static CSS tokens as part of your build process.

  • CLI: The command-line interface for generating themes.
  • Generator API: Node.js API for generating CSS strings programmatically.

2. Universal API

Core logic that can be used in both Node.js (build time) and the browser (runtime).

  • Solver API: The math engine that calculates accessible colors.

3. Runtime Control

Tools for managing the theme in the browser.

  • Runtime API: The JavaScript controller (ThemeManager) and CSS Engine for dynamic theming.

Generator API

The Generator API is used to create the CSS strings that power your theme. This is typically used in a build script or a server-side process.

generateTokensCss

Generates the CSS variables for all surfaces and their states.

import { generateTokensCss } from "color-system/generator";

const css = generateTokensCss(
  groups, // SurfaceGroup[]
  backgrounds, // Map<string, Record<Mode, number>> (from solver)
  hueShiftConfig, // Optional: HueShiftConfig
  borderTargets, // Optional: BorderTargets
  selector // Optional: Prefix selector (e.g. ".theme-dark")
);

Parameters

  • groups: The configuration of your surfaces (names, polarities, states).
  • backgrounds: The solved lightness values for each surface (output from solve()).
  • hueShiftConfig: Configuration for hue shifting (curve, max rotation).
  • borderTargets: Target alpha values for borders.
  • selector: An optional CSS selector to prefix all rules with.

Output

Returns a string containing the CSS definitions.

/* Example Output */
.surface-card {
  --surface-token: light-dark(oklch(0.98 0 0), oklch(0.2 0 0));
  --text-high-token: light-dark(oklch(0.1 0 0), oklch(0.9 0 0));
  /* ... */
}

Solver API

The Solver API contains the core logic for calculating accessible colors. It is “Universal”, meaning it can be used in Node.js (for build scripts) or in the browser (for dynamic theme builders).

solve

The main entry point. Calculates the lightness values for all surfaces based on the configuration.

import { solve } from "color-system";

const result = solve({
  anchors: { ... }, // PolarityAnchors
  groups: [ ... ]   // SurfaceGroup[]
});

console.log(result.backgrounds); // Map<string, { light: number, dark: number }>
console.log(result.surfaces);    // SurfaceConfig[] (with computed values)

Parameters

  • config: A SolverConfig object containing:
    • anchors: The start/end lightness values for each polarity and mode.
    • groups: The list of surfaces to solve for.

Return Value

Returns an object containing:

  • backgrounds: A Map where the key is the surface slug (e.g., “card”) and the value is an object with light and dark lightness numbers (0-1).
  • surfaces: The original surface configurations, enriched with computed text colors.

getKeyColorStats

Analyzes a set of key colors to determine their average lightness, chroma, and hue. Useful for aligning the theme to a brand color.

import { getKeyColorStats } from "color-system";

const stats = getKeyColorStats({
  primary: "#3b82f6",
  secondary: "#10b981",
});

console.log(stats);
// { lightness: 0.5, chroma: 0.15, hue: 200 }

Runtime API

The Runtime API allows you to change the theme dynamically in the browser. This is useful for:

  • Theme Builders: Letting users customize their UI.
  • White Labeling: Loading a brand’s colors from an API at runtime.
  • User Preferences: Allowing users to tweak contrast or saturation.

The Reactive Pipeline (Engine)

The core logic lives in css/engine.css. It uses CSS @property to create a reactive data flow that responds instantly to JavaScript changes.

1. Inputs (Variables)

The engine listens to specific CSS variables that you can set on the :root or any element.

  • --base-hue: The primary brand hue (0-360).
  • --base-chroma: The saturation level (0-0.25).
  • --surface-token: The lightness value (usually set by the static CSS, but can be overridden).

2. Computation (The Engine)

The engine calculates intermediate values and combines them into the final color.

/* Simplified Engine Logic */
.surface-card {
  /* Calculate Chroma based on the base chroma */
  --computed-surface-C: calc(var(--base-chroma) * 0.5);

  /* Calculate Hue (potentially shifted) */
  --computed-surface-H: var(--base-hue);

  /* Combine into final color */
  --computed-surface: oklch(
    var(--surface-token) var(--computed-surface-C) var(--computed-surface-H)
  );
}

3. Output (Properties)

The computed colors are assigned to standard CSS properties.

.surface-card {
  background-color: var(--computed-surface);
  color: var(--computed-fg-color);
}

Animation Strategy

The runtime supports smooth animations for both continuous and discrete changes.

Continuous Changes (Hue/Chroma)

When you animate --base-hue (e.g., from Blue to Red), the browser interpolates the number, and the engine recalculates the color every frame. This is “free” because of the reactive pipeline.

Discrete Changes (Light/Dark Mode)

When light-dark() flips from Light to Dark, the input token (--surface-token) changes instantly. Normally, this would cause a harsh snap.

The Fix: We transition the Computed Properties, not the inputs.

/* css/engine.css */
@property --computed-surface {
  syntax: "<color>";
  inherits: true;
  initial-value: transparent;
}

* {
  /* The browser interpolates the RESULT of the calculation */
  transition: --computed-surface 0.2s;
}

By registering --computed-surface with @property, we tell the browser it’s a color. When the input snaps, the result changes, and the browser transitions smoothly between the old result and the new result.

Browser Integration

The library provides a ThemeManager class to manage the theme mode and sync it with the browser’s native UI (Address Bar, Favicon).

ThemeManager

The ThemeManager handles:

  1. Mode Switching: Toggling between light, dark, and system modes.
  2. DOM Updates: Applying the correct classes or color-scheme styles to the root element.
  3. Browser Sync: Automatically updating the address bar color and favicon.
import { ThemeManager } from "color-system/browser";

// Initialize the manager
const themeManager = new ThemeManager({
  // Optional: Custom classes for light/dark modes
  lightClass: "light-theme",
  darkClass: "dark-theme",

  // Optional: Generator for dynamic favicons
  faviconGenerator: (color) => `
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
      <circle cx="16" cy="16" r="14" fill="${color}" />
    </svg>
  `,
});

// Set the mode
themeManager.setMode("dark"); // Force dark mode
themeManager.setMode("light"); // Force light mode
themeManager.setMode("system"); // Follow system preference

// Get the current resolved mode ('light' or 'dark')
console.log(themeManager.resolvedMode);

// Clean up listeners when done
themeManager.dispose();

Automatic Syncing

When you call setMode(), the ThemeManager automatically:

  1. Updates the root element (e.g. document.documentElement).
  2. Waits for styles to compute.
  3. Updates <meta name="theme-color"> to match the body background.
  4. Updates the favicon (if a generator is provided) to match the body text color.