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.
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, andlight-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-500for buttons andGray-100for cards.”
This forces you to be the calculator.
- Is it accessible? You have to know that
Blue-800is safe onBlue-200. IsBlue-500safe? You have to check a table. - What about Dark Mode?
Blue-800onBlue-200might work in Light Mode, but in Dark Mode, you need to invert it. Now you’re managing two sets of magic numbers. - 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
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)
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: 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:
- Canvas: The foundation. The “floor” of your app.
- Container: Objects that hold content. They sit on the canvas.
- Action: Interactive elements. They sit on containers or the canvas.
- 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.
<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.
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
-
Input Variables: You set high-level intent variables.
.hue-brand { --hue-brand: 250; } -
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)); -
The Output: The browser renders the final color.
Why is this powerful?
- Instant Theming: Change
--hue-brandon the<body>, and the entire app updates instantly. No re-compiling CSS. - Scoped Theming: Change
--hue-brandon 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.
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.
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(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: 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:
- Sets the Background: It applies the calculated background color.
- Sets the Context: It updates local CSS variables (like
--context-text-strong) to ensure text is readable on this specific surface. - Sets the Border: It defines the default border color for elements inside it.
Surface Types
1. The Canvas (Foundations)
These surfaces form the backdrop of your application.
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.
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.
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.
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?
- Portability: You can move a component from a Card to a Spotlight, and the text will automatically invert.
- Maintainability: You don’t need to write
.card .textand.spotlight .textoverrides. - 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:
| Surface | System Color |
|---|---|
surface-card | Canvas / CanvasText |
surface-action | ButtonFace / ButtonText |
state-selected | Highlight / HighlightText |
text-link | LinkText |
Testing
You can verify this behavior in Chrome/Edge DevTools:
- Open the Command Menu (
Ctrl+Shift+P/Cmd+Shift+P). - Type “Show Rendering” and select it.
- Scroll down to Emulate CSS media feature prefers-contrast.
- 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:
- Widening Anchors: Pushes background and foreground anchors to pure Black (0%) and White (100%) to maximize dynamic range.
- 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.
Print Styles
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:
- Force Light Mode: We explicitly set
color-scheme: lightto ensure alllight-dark()tokens resolve to their light values. This prevents printing dark backgrounds which waste massive amounts of ink. - Remove Chroma: We set
--base-chroma: 0to remove all saturation. This ensures that even if a surface has a slight tint in light mode, it prints as grayscale. - White Backgrounds: We explicitly set the background of main surfaces (
.surface-card, etc.) towhite.- Why hardcode white? In the context of print,
whiterepresents 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.
- Why hardcode white? In the context of print,
- Borders: Since we remove background colors, we add a
1px solidborder using thetext-subtlesttoken to maintain the visual structure of cards and sections. - 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:
- The UI: The interactive web interface (for exploration).
- The CLI: The
color-systemcommand 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.
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
.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.
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:
- Hydrate: Read your config.
- Adjust Anchors: Ensure the range supports High Contrast text.
- Distribute: Calculate the target contrast for each surface.
- Solve Lightness: Use binary search to find the exact lightness value that hits that contrast target.
- Solve Text: Find the text colors that sit accessibly on top of those surfaces.
- 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.
Hard to read.
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.
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).
| Score | Perception | Use Case |
|---|---|---|
| Lc 15 | Barely visible | Watermarks, decorative borders |
| Lc 30 | Subtle | Disabled text, placeholders |
| Lc 45 | Readable (Large) | Large headlines, non-critical text |
| Lc 60 | Readable (Body) | The gold standard for body text |
| Lc 75 | Preferred | Preferred for long-form reading |
| Lc 90 | High Contrast | Spotlights, critical actions |
Visualizing 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:
- More Dynamic: Colors feel alive rather than static.
- More Natural: Mimics the way light interacts with surfaces in the real world.
- More Distinct: Helps differentiate surfaces that are close in lightness.
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.
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.
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:
- Starts slowly at lightness = 0 (minimal hue shift in darks)
- Accelerates through mid-tones (where our eyes are most sensitive)
- 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).
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
-
Natural Warmth Progression: Mimics how we perceive natural lighting (cool shadows → neutral midtones → warm highlights)
-
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)
-
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
maxRotationfor 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.
Related Concepts
- 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:
- Build Time (CLI): Generate a static CSS file with all your tokens. Best for performance and standard projects.
- 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.
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:
| Token | Description |
|---|---|
--surface-token | The background color of the surface itself. |
--text-high-token | The color for high-emphasis text (e.g., headings) on this surface. |
--text-subtle-token | The color for medium-emphasis text (e.g., body) on this surface. |
--text-subtlest-token | The color for low-emphasis text (e.g., metadata) on this surface. |
--border-dec-token | A low-contrast border color for decorative separation. |
--border-int-token | A 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:
- Sets its own background: It uses the generated token for its background color.
- 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-cardinside a.surface-sidebarinside 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.
- The Engine: All surfaces use the variable
--computed-surfacefor their background. - 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 becomesCanvas(Background) andCanvasText(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:
- Widens Anchors: Pushes background and foreground anchors to pure Black (0%) and White (100%).
- 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.
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:
- Fixed Hues: We start with a curated list of distinct hues (Red, Orange, Yellow, Green, etc.) to ensure every color is nameable and distinct.
- Solved Lightness: We solve the lightness of each color against your page background to ensure it meets accessibility targets (APCA ~105).
- 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.12is 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 fromsolve()).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: ASolverConfigobject 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 withlightanddarklightness 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:
- Mode Switching: Toggling between
light,dark, andsystemmodes. - DOM Updates: Applying the correct classes or
color-schemestyles to the root element. - 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:
- Updates the root element (e.g.
document.documentElement). - Waits for styles to compute.
- Updates
<meta name="theme-color">to match the body background. - Updates the favicon (if a generator is provided) to match the body text color.