Managing themes is painful. You hand-pick 30 shades of grey, tune your accent colors, ship it, and then discover that one disabled button on an obscure settings page looks like mud against its slightly-less-muddy background. Add dark mode and the whole house of cards threatens to collapse under the weight of its own CSS variables.
What if you could define the essence of your theme with just three variables and let math derive the rest? That's exactly what Linear does. Three inputs. 98 colors out.
The Origin Story
Shortly after Linear announced their UI redesign, Andreas Eldh, one of the engineers involved, shared how the system came to be.
Try It Yourself
Before we dig into how it works, play with it. Pick a base color, an accent, and a contrast level. Watch how the entire palette updates.
More Presets
View Complete Theme Palette
Background Colors
Used for various UI surface and container backgrounds
Border Colors
Defines boundaries and separations between UI elements
Text Colors
For all typography with appropriate hierarchy
Primary Controls
For main interactive elements using accent color
Tertiary Controls
For tertiary interactive elements and selections
Scrollbar Colors
For scrollbar and scroll-related elements
Purple
For purple semantic elements
Blue
For blue semantic elements
Teal
For teal semantic elements
Green
For success state elements
Yellow
For warning state elements
Orange
For alert state elements
Red
For error state elements
Misc UI Colors
For various UI elements
Theme Configuration
Core theme settings that generate all colors
Theme Preview
Text Styles
This text uses the primary text color (labelBase) against the base background color.
This is muted text (labelMuted) for less important information.
Buttons
Primary, Secondary, Tertiary, Icon Buttons with Hover/Active states.
Form Elements
Elevated Card
This card uses a slightly elevated background (bgSub). Cards help organize related content and actions.
Progress Indicators
Alerts
Interactive States
Border Styles
Surface Variations
Semantic Colors
This is faint text (labelFaint).
This is a link (labelLink)This text uses yellowHighlight.
Misc Elements
Scrollable Area
This area demonstrates scrollbar styling (scrollbarBg, scrollbarBgHover, scrollbarBgActive).
Scroll down to see the effect.
More content...
More content...
More content...
End of content.
Why LCH Changes Everything
The instinct when building a theme is to take your base color and make it 10% lighter for hover, 20% darker for a subdued background. In RGB or HSL, that leads to muddy, hue-shifted results. Making a vibrant blue "lighter" in HSL can wash it out or shift it toward purple.
LCH (Lightness, Chroma, Hue) solves this. Its key property: adjusting Lightness preserves the perceived Chroma and Hue. A dark navy adjusted to be lighter stays navy, not lavender. This is what makes programmatic theme generation viable — you can derive dozens of shades from a single color and have them all feel cohesive.
Linear's entire theme system operates in LCH. Every color is defined as
a three-element array [L, C, H]
The Three Inputs
Here's the entire definition of Linear's default dark theme:
From these three values, the generator produces 98 semantic colors, shadow definitions, and functions to create contextual theme variants like elevated panels and sidebars.
How the Engine Works
Step 1: Light or Dark?
The first thing the generator does is classify the theme. A base Lightness above 50 means light mode. It also flags extreme cases — near-white and near-black — that need special treatment.
| 1 | const baseColorLCH = themeConfig.base; |
| 2 | const isLight = baseColorLCH[0] > 50; |
| 3 | const isVeryLightLowChroma = isLight && baseColorLCH[0] > 98 && baseColorLCH[1] < 8; |
| 4 | const isVeryDarkLowChroma = !isLight && baseColorLCH[0] < 5 && baseColorLCH[1] < 8; |
This classification drives everything downstream. In light themes, "darker" means moving away from the base; in dark themes, "lighter" means moving away. The sign of every adjustment flips accordingly.
Step 2: Contrast-Driven Adjustment Factors
This is the heart of the system. Instead of hard-coded color values, the generator calculates adjustment factors based on the contrast input. Different parts of the UI get different sensitivity levels:
| 1 | // Backgrounds & shades — most sensitive to contrast changes |
| 2 | const contrastFactorPrimary = (isLight ? -1 : 1) * themeConfig.contrast / 30; |
| Note The sign flip (isLight ? -1 : 1) appears everywhere. Dark themes lighten to create contrast; light themes darken. Dividing by 30 makes this the most responsive factor. | |
| 3 | |
| 4 | // Controls (buttons, inputs) — moderate sensitivity |
| 5 | const contrastFactorSecondary = (isLight ? -0.8 : 1) * themeConfig.contrast / 70; |
| 6 | |
| 7 | // Borders — least sensitive, with progressive boost at high contrast |
| 8 | const contrastFactorBorder = (isLight ? -0.9 : 0.8) * |
| Note Borders divide by 10 instead of 30 — they need more subtle adjustments. The progressive boost (Math.max(contrast - 30, 0)) makes high-contrast themes more border-visible without affecting normal contrast levels. | |
| 9 | (themeConfig.contrast + Math.max(themeConfig.contrast - 30, 0) * 0.1) / 10; |
| 10 | |
| 11 | // Text — inverse relationship: high contrast = subtle adjustment (text is already far from bg) |
| Note Text has an inverse relationship with contrast. At high contrast, the base text color already has good separation from the background, so adjustments become more subtle. | |
| 12 | const contrastFactorText = (isLight ? -1 : 1) * (3 + (100 - themeConfig.contrast) / 70) / 4; |
| 13 | |
| 14 | // Shadows — opacity scales with contrast |
| 15 | const shadowOpacityMultiplier = Math.max( |
| 16 | 1, 1 + Math.max(themeConfig.contrast - 30, 0) / (isLight ? 50 : 10) |
| 17 | ); |
Each factor powers a helper function that takes any LCH color and an
adjustment recipe like { l: 2, c: -0.5 }
Step 3: Deriving 98 Colors
With the factors defined, the generator derives every color the UI needs. Each color is named for its purpose, not its shade:
| 1 | // Backgrounds — derived from the base color |
| 2 | const bgBaseHover = adjustPrimary(base, { l: 2, c: -0.5 }); |
| 3 | const bgSub = adjustPrimary(base, { l: 8, c: -0.8 }); |
| 4 | const bgShade = adjustPrimary(base, { l: -3, c: -0.5 }); |
| Note Background variants adjust Lightness up or down and slightly reduce Chroma. The primary contrast factor scales these values — at contrast 33, l:8 becomes roughly l:8.8 in dark mode. | |
| 5 | |
| 6 | // Borders — derived from base using the less-sensitive border factor |
| 7 | const bgBorder = adjustBorder(base, { l: isLight ? -6 : 15, c: -1 }); |
| 8 | const bgBorderFaint = adjustBorder(base, { l: isLight ? -3 : 8, c: -0.8 }); |
| 9 | const bgBorderSolid = adjustBorder(base, { l: isLight ? -15 : 25, c: 0 }); |
| 10 | |
| 11 | // Text — starts from ideal text color for the background, then adjusts |
| 12 | const labelTitle = adjustText(base, { l: 0, c: 0 }, { emphasisMultiplier: 1.1 }); |
| 13 | const labelBase = adjustText(base, { l: 0, c: 0 }); |
| Note Text is the cleverest part. adjustText first calculates the ideal text color for the given background (black or white), then applies contrast-scaled adjustments to create hierarchy. | |
| 14 | const labelMuted = adjustText(base, { l: -8, c: -2 }); |
| 15 | |
| 16 | // Controls — derived from the accent color |
| 17 | const controlPrimary = accent; |
| 18 | const controlPrimaryHover = adjustPrimary(accent, { l: 3, c: 2 }); |
| Note Controls use the accent color directly, with hover and label colors derived from it. The label color is calculated the same way as text — ensuring readability on the accent background. | |
| 19 | const controlPrimaryLabel = adjustText(accent, { l: 0, c: 0 }); |
| 20 | |
| 21 | // Selection & focus — accent shifted toward the base |
| 22 | const bgSelected = adjustPrimary(accent, { l: isLight ? 30 : -30, c: -6 }); |
| 23 | const bgFocus = adjustPrimary(accent, { l: isLight ? 30 : -30, c: -6 }); |
With a dark base of
#121214
and a purple accent of
#7c3aed ,
this produces:
- bgBaseHover:
#1c1c20— slightly lighter for hover feedback - bgSub:
#202024— elevated panels and secondary surfaces - bgBorder:
#2c2c30— visible but subtle separation - labelBase:
#ebebeb— primary text - labelMuted:
#a1a1aa— secondary, less prominent text - controlPrimary:
#7c3aed— buttons and active controls
All 98 colors are stored with their semantic names, then converted from LCH to the target CSS format (RGB, hex, etc.) in a single pass at the end.
Semantic Variables in Practice
The generated colors populate CSS custom properties:
| 1 | :root { |
| 2 | --color-bg-base: rgb(20, 20, 22); |
| 3 | --color-bg-base-hover: rgb(28, 28, 32); |
| 4 | --color-bg-sub: rgb(32, 32, 36); |
| 5 | --color-bg-border: rgb(44, 44, 48); |
| 6 | --color-label-base: rgb(235, 235, 235); |
| 7 | --color-label-muted: rgb(161, 161, 170); |
| 8 | --color-control-primary: rgb(124, 58, 237); |
| 9 | --color-control-primary-hover: rgb(139, 73, 252); |
| 10 | /* ... ~98 variables total ... */ |
| 11 | } |
Components reference these by purpose, never by shade number:
| 1 | <button class="bg-[var(--color-control-primary)] |
| 2 | hover:bg-[var(--color-control-primary-hover)] |
| 3 | text-[var(--color-control-primary-label)]"> |
| 4 | Submit |
| 5 | </button> |
| 6 | |
| 7 | <div class="bg-[var(--color-bg-sub)] |
| 8 | border border-[var(--color-bg-border)]"> |
| 9 | <p class="text-[var(--color-label-base)]">Panel content</p> |
| 10 | <span class="text-[var(--color-label-muted)]">Secondary info</span> |
| 11 | </div> |
Swap in a completely different theme — high-contrast light mode, a warm sepia theme, whatever — and every component adapts automatically. No code changes needed.
Contextual Variations
One of the most elegant parts of the system: each theme can generate variants of itself. Need a panel that sits visually "above" the background?
| 1 | // Lazy + memoized — only computed when first accessed |
| 2 | elevatedTheme: () => ( |
| 3 | memoizedElevatedTheme || ( |
| 4 | memoizedElevatedTheme = ThemeHelper.generateTheme({ |
| 5 | base: ColorUtils.adjust(baseColorLCH, { l: isLight ? 3 : 6 }), |
| Note The elevated variant bumps the base lightness by 3 (light themes) or 6 (dark themes). This subtle shift creates visual depth without any hard-coded color values. | |
| 6 | accent: accentColorLCH, |
| 7 | contrast: themeConfig.contrast, |
| 8 | baseTheme: finalThemeObject, |
| Note The baseTheme reference lets components navigate back to the parent theme if needed — useful for things like modals that need to match the page behind them. | |
| 9 | elevation: (themeConfig.elevation || 0) + 1 |
| 10 | }) |
| 11 | ) |
| 12 | ) |
This is recursive — elevatedTheme()<Elevated>
The Payoff
For a complex application like Linear, this system delivers real benefits:
- Mathematical consistency: Colors have guaranteed relationships. No more eyeballing whether grey-700 contrasts enough with grey-200.
- Rapid iteration: Tweaking three variables reshapes the entire UI. Adding a new theme is a one-liner, not a week-long design sprint.
- Built-in contrast scaling: The contrast parameter
adjusts the entire system proportionally. A high-contrast accessibility
mode isn't a separate set of hand-tuned colors — it's
contrast: 55instead ofcontrast: 33. - Maintainability at scale: Need to change how primary button hovers work? Adjust the generation logic once, not 50 components.
- Automatic adaptation: Components use semantic variables. They don't know or care which specific colors they're rendering — they adapt to any theme automatically.
The Catch
This is a powerful system, but it's not magic, and it's not for everyone.
- Color science expertise: Someone needs to understand LCH, perceptual contrast, and how to derive pleasing palettes programmatically. The contrast factor formulas aren't intuitive — they're the product of extensive iteration.
- Upfront investment: Building and tuning the generator is non-trivial. Linear's version evolved over five years and multiple hack weeks.
- Team discipline: Everyone must commit to using
semantic variables only. One developer reaching for
breaks the contract.color: #666 - Diminishing returns at small scale: If your app has one theme and fewer than 20 themed components, hand-picked colors are simpler and faster. The payoff kicks in when you need multiple themes, dark mode, high-contrast modes, or user-customizable themes.
- Not a replacement for design judgment: The generator gets you 90% of the way. Linear's team still tweaks the generation code itself — the math produces a baseline, not a final product.
Generating themes programmatically from minimal inputs, using LCH and contrast calculations, and applying them via semantic CSS variables is a genuinely powerful approach. It trades upfront complexity for consistency, speed, and maintainability at scale. For complex applications that need to support multiple themes, it might be the only sane path forward.
Base color: L=12 (very dark), C=2 (nearly neutral), H=270 (blue hue). This becomes the background.
Accent color: L=55 (mid-range brightness), C=80 (highly saturated), H=300 (purple). Used for buttons, links, and active states.
Contrast ranges from ~10 to ~60. Higher values push backgrounds further apart, make borders more visible, and increase text/background contrast.