Alright, let's talk about themes. Specifically, let's talk about the soul-crushing agony of managing themes, especially dark mode. You painstakingly pick 30 shades of grey, hand-tune your accent colors, ship it, and then your designer points out that one disabled button on that obscure settings page looks like mud against its slightly-less-muddy background. Sound familiar?
We've all been there. Tweaking hex codes, juggling --color-grey-700
--color-grey-800
The "Aha!" Moment - Linear's Approach
What if, instead of defining hundreds of colors, you could define the essence of your theme with just three variables? That's the core idea behind the system Linear recently showcased in their approach to theme generation.
Shortly after Linear announced their UI redesign and the concepts behind their new theme system, Andreas Eldh, one of the engineers involved, shared some fascinating background on how it came to be.
This isn't just your run-of-the-mill CSS-in-JS; it's a glimpse into a sophisticated, programmatic theme generation engine. Imagine you're tired of tweaking 50 shades of gray for dark mode, only to find your buttons have vanished because the contrast is shot. This approach offers a different path: define the essence of your theme, and let math handle the fiddly bits.
Note: The following code was de-obfuscated by Google Gemini from Linear's minified production code, with function and variable names reconstructed based on their purpose.
The Full Theme Generation Method
Breaking Down the Theme Generator Step by Step
Let's break down how this theme generation works:
1. The Inputs (themeConfig): The Magic Ingredients
The function takes a themeConfig object that serves as the "recipe card" for your theme:
- base: An array [L, C, H] representing the main background color in the LCH color space. LCH is crucial here – unlike HSL, changing Lightness (L) in LCH doesn't drastically alter the perceived hue, making it much better for generating related shades.
- accent: Another [L, C, H] array for the primary accent color.
- contrast: A number telling the engine how much visual separation you want between elements (higher number = generally more contrast).
- colorFormat: The desired output format (e.g., "RGB", "LCH", "Hex").
- baseTheme, elevation, _themeType, sidebarInput: These are used for generating variants of a theme (like elevated panels or sidebars) recursively.
2. Setting the Stage: Light vs. Dark & Edge Cases
First, it figures out if we're dealing with a light or dark theme based on the base color's Lightness value:
1 | const baseColorLCH = themeConfig.base; |
2 | const isLight = baseColorLCH[0] > 50; // Is base Lightness > 50%? |
3 | const isVeryLightLowChroma = isLight && baseColorLCH[0] > 98 && baseColorLCH[1] < 8; // Near white? |
4 | const isVeryDarkLowChroma = !isLight && baseColorLCH[0] < 5 && baseColorLCH[1] < 8; // Near black? |
It also flags extreme cases (near-white or near-black with low color saturation) because these often need slightly different adjustment logic to look good.
For example, a very light theme with base color like
#fcfcfc
or a very dark theme with base color like
#0a0a0c
would be flagged for special handling.
3. The Secret Sauce: Contrast-Driven Adjustment Factors & Helpers
This is the core idea. Instead of magic numbers, the code calculates factors based on the input contrast and whether the theme is light or dark:
1 | // Primary factor (backgrounds, shades) |
2 | const contrastFactorPrimary = (isLight ? -1 : 1) * themeConfig.contrast / 30; |
Note This factor controls how much the base color changes for backgrounds and general shades. Notice the sign flip between light (-1) and dark (1) themes. | |
3 | const adjustColorPrimary = (colorLCH, adjustmentFactors) => /* uses ColorUtils.adjust with contrastFactorPrimary */; |
4 | |
5 | // Secondary factor (controls) |
6 | const contrastFactorSecondary = (isLight ? -0.8 : 1) * themeConfig.contrast / 70; |
7 | const adjustColorSecondary = (colorLCH, adjustmentFactors) => /* uses ColorUtils.adjust with contrastFactorSecondary */; |
8 | |
9 | // Border factor (less sensitive) |
10 | const contrastFactorBorder = (isLight ? -0.9 : 0.8) * (themeConfig.contrast + Math.max(themeConfig.contrast - 30, 0) * 0.1) / 10; |
Note The border factor is less sensitive (divided by 10 instead of 30), as borders need more subtle adjustments. It also has a progressive boost for high contrast themes. | |
11 | const adjustColorBorder = (colorLCH, adjustmentFactors) => /* adjusts L & C using contrastFactorBorder */; |
12 | |
13 | // Text factor (inverse relationship with contrast) |
14 | const contrastFactorText = (isLight ? -1 : 1) * (3 + (100 - themeConfig.contrast) / 70) / 4; |
Note For text, the factor has an inverse relationship with contrast - as contrast increases, the adjustment becomes more subtle because the base text color already has good contrast. | |
15 | const adjustTextColor = (backgroundColorLCH, adjustmentFactors, targetColorProps) => /* Gets text color for bg, then adjusts using contrastFactorText */; |
16 | |
17 | // Shadow factor |
18 | const shadowOpacityMultiplier = Math.max(1, 1 + Math.max(themeConfig.contrast - 30, 0) / (isLight ? 50 : 10)); |
19 | const getShadowColorCss = (alpha) => /* returns transparent black CSS with alpha * shadowOpacityMultiplier */; |
Notice these important aspects:
- Light/Dark Sign Flip: The
pattern appears frequently. Dark themes usually need positive lightness adjustments to get lighter variations, while light themes need negative ones.isLight ? -1 : 1
- Different Sensitivity: The factors divide the contrast input by different amounts (30, 70, 10), making some adjustments (like borders) less sensitive than others (like backgrounds).
- Helper Functions: The
helpers take an LCH color and an object specifying how much to change L, C, or H (e.g., ' l: 2, c: -1 '), then apply the relevant contrast factor to those amounts before using a ColorUtils.adjust function.adjustColor*
- Smart Text Handling: The
is particularly clever: it first determines the ideal base text color (black/white) for the given background using ColorUtils.getTextColor, then applies contrast-driven adjustments.adjustTextColor
4. Generating the Palette (generatedColorsLCH)
This is where the magic happens. The code uses the base/accent colors and the adjustment helpers to calculate dozens of specific colors needed for the UI:
1 | // Background colors |
2 | const bgBaseHoverColor = adjustColorPrimary(baseColorLCH, { l: 2, c: -0.5 }); |
3 | const bgSubColor = adjustColorPrimary(baseColorLCH, { l: 8, c: -0.8 }); |
4 | const bgShadeColor = adjustColorPrimary(baseColorLCH, { l: -3, c: -0.5 }); |
Note For background variants, it uses the primary contrast factor to derive hover states, subdued backgrounds, etc. | |
5 | |
6 | // Border colors |
7 | const bgBorderColor = adjustColorBorder(baseColorLCH, { l: isLight ? -6 : 15, c: -1 }); |
8 | const bgBorderFaintColor = adjustColorBorder(baseColorLCH, { l: isLight ? -3 : 8, c: -0.8 }); |
9 | |
10 | // Text colors |
Note For text, it always starts with the ideal text color for the given background (black or white), then applies subtle adjustments to create hierarchy. | |
11 | const labelTitleColor = adjustTextColor(baseColorLCH, { l: 0, c: 0 }, { emphasisMultiplier: 1.1 }); |
12 | const labelBaseColor = adjustTextColor(baseColorLCH, { l: 0, c: 0 }); |
13 | const labelMutedColor = adjustTextColor(baseColorLCH, { l: -8, c: -2 }); |
14 | |
15 | // Control colors (using accent) |
Note For controls, it starts with the accent color, then derives hover states and appropriate labels. | |
16 | const controlPrimaryColor = accentColorLCH; |
17 | const controlPrimaryHoverColor = adjustColorPrimary(controlPrimaryColor, { l: 3, c: 2 }); |
18 | const controlPrimaryLabelColor = adjustTextColor(controlPrimaryColor, { l: 0, c: 0 }); |
19 | |
20 | // Store all the generated colors in LCH format |
21 | const generatedColorsLCH = { |
22 | bgSub: bgSubColor, |
23 | bgBase: baseColorLCH, |
24 | bgBaseHover: bgBaseHoverColor, |
25 | bgBorder: bgBorderColor, |
Note All colors are stored with semantic names describing their function in the UI, not abstract names like "grey-700". | |
26 | labelBase: labelBaseColor, |
27 | labelMuted: labelMutedColor, |
28 | controlPrimary: controlPrimaryColor, |
29 | controlPrimaryHover: controlPrimaryHoverColor, |
30 | // ... (and many more) ... |
31 | }; |
For example, with a dark base of
#121214
and an accent of
#7c3aed
, it might generate:
- bgBaseHover:
#1c1c20
(slightly lighter) - bgSub:
#202024
(more elevated) - bgBorder:
#2c2c30
(visible but subtle) - labelBase:
#ebebeb
(light text on dark) - labelMuted:
#a1a1aa
(secondary text) - controlPrimary:
#7c3aed
(accent color) - controlPrimaryHover:
#8b49fc
(brighter accent)
5. Formatting and Final Assembly (finalThemeObject)
The final steps involve converting the LCH colors to CSS and assembling the complete theme:
1 | // Convert LCH values to the target CSS format (e.g., "RGB") |
2 | const formattedColorsCss = applyAdjustmentMultiplier( |
3 | generatedColorsLCH, |
4 | colorLCH => ColorUtils.toCss(themeConfig.colorFormat, colorLCH) |
5 | ); |
Note All the calculated LCH colors are converted to the target format (like RGB strings) for use in CSS. | |
6 | |
7 | // Calculate shadow CSS strings |
8 | const shadowColor04 = getShadowColorCss(0.04); |
9 | // ... other shadow opacities ... |
10 | |
11 | // Assemble the final object |
12 | const finalThemeObject = { |
13 | focusColor: formattedColorsCss.focusColor, |
14 | contrast: themeConfig.contrast, |
15 | colorFormat: themeConfig.colorFormat, |
16 | isDark: !isLight, |
17 | |
Note Shadow definitions are generated with opacity based on the contrast settings, creating a complete design system. | |
18 | // CSS Shadow Strings |
19 | shadowLow: isLight ? "0 1px 2px rgba(0,0,0,0.04)" : "0 1px 2px rgba(0,0,0,0.125)", |
20 | shadowMedium: "0 2px 4px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.16)", |
21 | // ... other shadows ... |
22 | |
23 | // Direct style values (example) |
24 | inputPadding: "6px 12px", |
25 | inputBackground: formattedColorsCss.bgBase, |
26 | |
27 | // The map of CSS color strings |
Note The theme object includes functions to generate related theme variants, which we'll discuss next. | |
28 | color: formattedColorsCss, |
29 | |
30 | // --- Functions to generate related themes --- |
31 | elevatedTheme: () => (/* ... */), |
32 | subTheme: () => (/* ... */), |
33 | // ... other theme variants ... |
34 | |
35 | baseTheme: themeConfig.baseTheme // Link back to parent theme if this is a variant |
36 | }; |
The final theme object includes:
- Metadata: contrast level, color format, whether it's dark
- CSS Color Strings: all the semantic colors, formatted for CSS
- Shadow Definitions: pre-computed CSS shadow strings at different elevations
- Direct Style Values: other styling constants like padding
- Theme Variant Functions: methods to generate related themes
6. Recursive & Lazy Theme Variants
One of the most powerful aspects of this system is how it handles theme variants:
1 | // Initialize memoized variables |
2 | let memoizedElevatedTheme; |
3 | let memoizedSubTheme; |
4 | |
5 | // In the final theme object: |
6 | elevatedTheme: () => ( |
7 | memoizedElevatedTheme || ( |
8 | memoizedElevatedTheme = ThemeHelper.generateTheme({ |
9 | base: ColorUtils.adjust(baseColorLCH, { l: isLight ? 3 : 6 }), |
10 | accent: accentColorLCH, |
11 | contrast: themeConfig.contrast, |
12 | colorFormat: themeConfig.colorFormat, |
13 | baseTheme: finalThemeObject, |
14 | elevation: (themeConfig.elevation || 0) + 1 |
Note The elevatedTheme function uses a slightly lighter base color. It doesn't calculate until needed, and it caches the result. | |
15 | }) |
16 | ) |
17 | ), |
18 | |
19 | subTheme: () => ( |
20 | memoizedSubTheme || ( |
21 | memoizedSubTheme = ThemeHelper.generateTheme({ |
22 | base: generatedColorsLCH.bgSub, |
23 | accent: accentColorLCH, |
24 | contrast: themeConfig.contrast, |
25 | colorFormat: themeConfig.colorFormat, |
26 | baseTheme: finalThemeObject |
Note The subTheme function uses the already calculated bgSub color as its new base, creating a contextually appropriate variant. | |
27 | }) |
28 | ) |
29 | ), |
Functions like elevatedTheme()
subTheme()
sidebarTheme()
- Lazy Evaluation: They don't run immediately. They only calculate when called.
- Memoization: They check if they've already generated this variant (using the memoized variables).
- Recursion: If not previously generated, they call the main
function again, but with modified inputs.generateTheme()
- Contextual Derivation: They modify the base color (e.g., using a lighter color for elevated panels) but maintain the relationship to the original theme.
- Linking: They set
to create a connection back to the parent theme.baseTheme: finalThemeObject
This allows creating contextual variations (like a panel that's slightly
lighter than the main background) that are still derived from the original
base/accent/contrast inputs. The sidebarTheme()
7. Memoization (Performance is Key!)
The final piece of the puzzle is performance optimization:
1 | // Apply memoization for performance |
2 | ThemeHelper.generateTheme = memoize( |
3 | ThemeHelper.generateThemeUnmemoized, |
4 | // Cache key generator based on config values |
5 | (config) => Object.values(config).join("_") + Object.values(config.sidebarInput || {}).join("_") |
6 | ); |
Since generating a theme involves many calculations, and theme variant
functions might call each other recursively, calculating the same theme
variant multiple times would be slow. The memoize()
generateTheme()
Imagine defining your entire dark theme like this (conceptually):
- Base Color: A super dark, slightly cool grey
#121214
(defined precisely using LCH). - Accent Color: That vibrant Linear purple
#7c3aed
(again, LCH). - Contrast Level: A number, say 33, dictating the overall visual separation.
1 | const darkThemeRefresh = ThemeHelper.generateTheme({ |
2 | base: [12, 2, 270], // LCH: Dark bluish-grey |
3 | accent: [55, 80, 300], // LCH: Vibrant purple |
4 | contrast: 33, |
Note Linear's theme system uses just three core inputs:
From just these three variables, the theme generator derives dozens of semantic colors. | |
5 | colorFormat: "RGB" |
6 | }); |
From just these inputs, a magical function spits out everything: background shades, hover states, faint borders, solid borders, primary text, muted text, link colors, button backgrounds (normal, hover, selected), focus rings, even appropriately contrasted fixed palettes for things like tags (red, green, blue...).
Try It Yourself: Theme Generator
Want to see this in action? Use the controls below to set your base color, accent color, and contrast level, then watch how it generates an entire design system automatically.
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 This is Harder Than It Looks - Enter LCH and Contrast
"Okay," you say, "I'll just take my base color and make it 10% lighter for hover, 20% darker for a subdued background..." Stop right there. That's the path to Mudville.
Why? Because simple lightness adjustments in RGB or HSL often mess up the perceived hue and saturation. Making a vibrant blue "lighter" might make it look washed out or shift towards purple. This is where the LCH color space becomes our superhero. LCH stands for Lightness, Chroma (saturation), and Hue. Its magic power? Adjusting Lightness (L) preserves the perceived Chroma and Hue much better than HSL or RGB manipulations.
The second pillar is Contrast. The code doesn't just make things lighter or darker randomly; it calculates adjustment factors based on the target contrast. A higher contrast input means text colors will be pushed further away from background colors, hover states will be more distinct, etc. It's building accessibility and hierarchy right into the color generation.
How It Works (The Gist)
1 | // Calculate contrast-based adjustment factors |
2 | const contrastFactorPrimary = (isLight ? -1 : 1) * themeConfig.contrast / 30; |
Note The system calculates adjustment factors based on the contrast value and whether the theme is light or dark. Notice how the sign flips (-1 vs 1) for light vs dark themes. | |
3 | const contrastFactorText = (isLight ? -1 : 1) * (3 + (100 - themeConfig.contrast) / 70) / 4; |
4 | |
5 | // Derive colors using the factors |
6 | const bgSubColor = adjustColorPrimary(baseColorLCH, { l: 2, c: -0.5 }); |
7 | const labelMutedColor = adjustTextColor(baseColorLCH, { l: -8, c: -2 }); |
Note These helper functions apply the contrast factors to adjust base colors mathematically, creating a consistent relationship between different UI elements. | |
8 | const controlPrimaryHoverColor = adjustColorPrimary(controlPrimaryColor, { l: 3, c: 2 }); |
9 | |
10 | // Store with semantic names |
11 | const generatedColorsLCH = { |
12 | bgSub: bgSubColor, |
13 | bgBase: baseColorLCH, |
Note The derived colors are stored with semantic names, not shade numbers. This makes them easy to use in components based on their purpose. | |
14 | labelMuted: labelMutedColor, |
15 | controlPrimaryHover: controlPrimaryHoverColor, |
16 | // ... and many more |
17 | }; |
Think of the theme generator as a master chef. It takes the raw ingredients (base, accent, contrast) and applies a series of techniques:
- Determine Theme Type: Is it a light or dark theme? Are we near black/white (these need special handling)?
- Calculate Adjustments: Figure out how much to tweak colors based on the desired contrast.
- Derive Colors:
- Need a hover background? Adjust the base color's Lightness (and maybe Chroma) using the primary contrast factor.
- Need muted text? Find the ideal text color first (black/white), then reduce its Lightness using the text contrast factor.
- Need a border? Adjust the base color using the border contrast factor (which is less sensitive).
- Need a primary button? Use the accent color. Need its hover state? Adjust the accent color slightly.
- Semantic Naming: Store every derived color with a name describing
its job:
,bgBase
,bgBorderFaint
. Forget names likelabelMuted
.grey-700-with-a-hint-of-blue
- Format & Package: Convert all those calculated LCH colors into usable CSS (like RGB strings) and bundle them up with metadata and shadow definitions.
Semantic Variables: The Unsung Hero
The generated theme object would then be used to populate CSS custom properties (variables) on your page:
1 | :root { |
2 | --color-bg-base: rgb(20, 20, 22); /* Populated by JS */ |
3 | --color-text-primary: rgb(230, 230, 230); |
4 | --color-button-hover: rgb(100, 90, 210); |
5 | /* ... ~98 variables ... */ |
6 | } |
Your components only use these semantic variables:
1 | <button class="bg-[--color-control-primary] hover:bg-[--color-control-primary-hover] text-[--color-control-primary-label]"> |
2 | Submit |
3 | </button> |
(Or, if using Tailwind, you configure it to map bg-control-primary
var(--color-control-primary)
Bonus Level: Contextual Variations
The system even handles generating theme variants. Need a panel that's slightly lighter than the main background?
1 | const elevatedPanelTheme = currentTheme.elevatedTheme(); |
2 | // Now apply elevatedPanelTheme's variables to that panel's scope. |
The elevatedTheme()
baseTheme: currentTheme
The Payoff: Why Bother?
Is this complex? Absolutely. Is it overkill for your dog's photo blog? Probably. But for a complex application like Linear, the benefits are huge:
- Rock-Solid Consistency: Colors have mathematical relationships. No more guessing if grey-700 contrasts enough with grey-200.
- Rapid Iteration: Designers and developers can tweak the entire UI feel by playing with just 3 core inputs. Adding a new theme becomes trivial.
- Maintainability: Refactoring? Need to change all primary button hovers? Change the generation logic or the semantic variable, not 50 different components.
- Automatic Adaptation: Components inherently adapt to any theme thrown at them.
The Catch
This isn't magic. It requires:
- Serious Color Science Chops: Someone needs to understand LCH, contrast calculations, and how to derive pleasing palettes programmatically.
- Robust Tooling: You need those color utilities (or similar) functions to do the heavy lifting.
- Discipline: Your team needs to commit to using only the semantic variables in components.
- Upfront Investment: Building the generator is non-trivial.
Generating themes programmatically from minimal inputs, using LCH and contrast calculations, and applying them via semantic CSS variables is a powerful, sophisticated approach to UI theming. It trades upfront complexity for massive gains in consistency, speed, and maintainability down the line. It's taking the idea of a design system and pushing it further – defining not just components, but the rules that govern their appearance across any theme. It's painting your UI with math, and for complex applications, it might just be the future.
The function starts by determining if we're dealing with a light or dark theme based on the base color's Lightness value, with special handling for near-white or near-black colors.
The primary contrast factor is used for backgrounds and general shades. Notice how it flips sign based on whether the theme is light or dark.
All the calculated colors are stored with semantic names (like 'bgSub', 'labelTitle') rather than with names indicating their shade level.
The elevatedTheme() function creates a variant with a slightly lighter base color. It doesn't execute immediately - it's lazy-loaded when needed.
Memoization is crucial for performance. It caches the result of theme generation so identical theme configurations don't need to be recalculated.