The Three-Variable Theme: Deconstructing Linear's System

How Linear generates their entire UI theme from just three variables: base color, accent color, and contrast level.

Linear's three-variable theme system visualization

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.

Preset Themes
More Presets
bgBase
bgSub
bgBaseHover
bgBorder
controlPrimary
controlPrimaryHover
labelBase
labelMuted
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.

Product Design Development

Buttons

Primary, Secondary, Tertiary, Icon Buttons with Hover/Active states.

New

Form Elements

Elevated Card

This card uses a slightly elevated background (bgSub). Cards help organize related content and actions.

Progress Indicators

Uploading files 60%
Loading data...

Alerts

This is an informational alert with bgSelected as background.
Operation completed successfully!

Interactive States

Border Styles

Thin Border (bgBorderThin)
Faint Border (bgBorderFaint)
Solid Border (bgBorderSolid)
Selected Border (bgSelectedBorder)

Surface Variations

Shaded Background (bgShade)
Selected Background (bgSelected)

Semantic Colors

Purple Blue Teal Green Yellow Orange Red
This is a warning alert (Yellow).
This is an error alert (Red).

This is faint text (labelFaint).

This is a link (labelLink)

This text uses yellowHighlight.

Semantic Foreground on Semantic Background (e.g., Purple)

Misc Elements

Overlay (bgModalOverlay)
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], manipulated in LCH space, and only converted to RGB at the very end for CSS output.

The Three Inputs

Here's the entire definition of Linear's default dark theme:

linear-dark-theme.js [3 highlights]
1 const darkTheme = ThemeHelper.generateTheme({
2 base: [12, 2, 270], // LCH: Dark bluish-grey
Note

Base color: L=12 (very dark), C=2 (nearly neutral), H=270 (blue hue). This becomes the background.

3 accent: [55, 80, 300], // LCH: Vibrant purple
Note

Accent color: L=55 (mid-range brightness), C=80 (highly saturated), H=300 (purple). Used for buttons, links, and active states.

4 contrast: 33, // How much visual separation between elements
Note

Contrast ranges from ~10 to ~60. Higher values push backgrounds further apart, make borders more visible, and increase text/background contrast.

5 colorFormat: "RGB"
6 });

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.

light-dark-detection.js
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:

contrast-factors.js [3 highlights]
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 } and applies it scaled by the relevant contrast factor. The same recipe produces visually different results depending on whether you're in a light theme at contrast 20 vs. a dark theme at contrast 50.

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:

color-derivation.js [3 highlights]
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:

generated-theme.css
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:

component-usage.html
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?

theme-variants.js [2 highlights]
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() calls the same generator with a modified base color, producing a full set of 98 colors tuned to that new base. Linear uses this for <Elevated> wrappers, sidebar panels, menu overlays, selection backgrounds, and focus states. Each variant is lazy-evaluated and memoized, so the cost of having six variant functions is effectively zero until they're actually needed.

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: 55 instead of contrast: 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 color: #666 breaks the contract.
  • 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.