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

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 vs --color-grey-800 , praying the contrast checker passes. Then you add a second dark theme, or maybe user-selectable themes, and the whole house of cards threatens to collapse under the weight of its own CSS variables. There has to be a better way.

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.

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

theme-helper.js
1 // The core theme generation function (before memoization)
2 ThemeHelper.generateThemeUnmemoized = function(themeConfig) {
3 // 1. Extract theme config & determine if light or dark theme
4 const baseColorLCH = themeConfig.base;
5 const accentColorLCH = themeConfig.accent;
Note

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.

6 const isLight = baseColorLCH[0] > 50; // Is base Lightness > 50%?
Note

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.

7 const isVeryLightLowChroma = isLight && baseColorLCH[0] > 98 && baseColorLCH[1] < 8; // Near white?
Note

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.

8 const isVeryDarkLowChroma = !isLight && baseColorLCH[0] < 5 && baseColorLCH[1] < 8; // Near black?
Note

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.

9
10 // 2. Calculate contrast-based adjustment factors
11 // Primary factor (backgrounds, shades)
Note

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.

12 const contrastFactorPrimary = (isLight ? -1 : 1) * themeConfig.contrast / 30;
Note

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.

13 const adjustColorPrimary = (colorLCH, adjustmentFactors) => {
Note

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.

14 return ColorUtils.adjust(colorLCH, {
Note

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.

15 l: adjustmentFactors.l * contrastFactorPrimary,
Note

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.

16 c: adjustmentFactors.c * Math.abs(contrastFactorPrimary) * 0.8,
Note

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.

17 h: adjustmentFactors.h || 0
Note

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.

18 });
19 };
20
21 // Secondary factor (controls)
22 const contrastFactorSecondary = (isLight ? -0.8 : 1) * themeConfig.contrast / 70;
23 const adjustColorSecondary = (colorLCH, adjustmentFactors) => {
24 return ColorUtils.adjust(colorLCH, {
25 l: adjustmentFactors.l * contrastFactorSecondary,
26 c: adjustmentFactors.c * Math.abs(contrastFactorSecondary),
27 h: adjustmentFactors.h || 0
28 });
29 };
30
31 // Border factor (less sensitive)
32 const contrastFactorBorder = (isLight ? -0.9 : 0.8) * (themeConfig.contrast + Math.max(themeConfig.contrast - 30, 0) * 0.1) / 10;
33 const adjustColorBorder = (colorLCH, adjustmentFactors) => {
34 return ColorUtils.adjust(colorLCH, {
35 l: adjustmentFactors.l * contrastFactorBorder,
36 c: adjustmentFactors.c * Math.abs(contrastFactorBorder),
37 h: adjustmentFactors.h || 0
38 });
39 };
40
41 // Text factor (inverse relationship with contrast)
42 const contrastFactorText = (isLight ? -1 : 1) * (3 + (100 - themeConfig.contrast) / 70) / 4;
43 const adjustTextColor = (backgroundColorLCH, adjustmentFactors, targetColorProps) => {
44 const textColor = ColorUtils.getTextColor(backgroundColorLCH, targetColorProps);
45 return ColorUtils.adjust(textColor, {
46 l: adjustmentFactors.l * contrastFactorText,
47 c: adjustmentFactors.c * Math.abs(contrastFactorText),
48 h: adjustmentFactors.h || 0
49 });
50 };
51
52 // Shadow factor
53 const shadowOpacityMultiplier = Math.max(1, 1 + Math.max(themeConfig.contrast - 30, 0) / (isLight ? 50 : 10));
54 const getShadowColorCss = (alpha) => {
55 return `rgba(0, 0, 0, ${alpha * shadowOpacityMultiplier})`;
56 };
57
58 // 3. Generate the color palette with semantic names
59 // Background colors
60 const bgBaseHoverColor = adjustColorPrimary(baseColorLCH, { l: 2, c: -0.5 });
61 const bgSubColor = adjustColorPrimary(baseColorLCH, { l: 8, c: -0.8 });
62 const bgShadeColor = adjustColorPrimary(baseColorLCH, { l: -3, c: -0.5 });
63 const bgSelectedColor = adjustColorPrimary(accentColorLCH, { l: isLight ? 30 : -30, c: -6 });
64 const bgFocusColor = adjustColorPrimary(accentColorLCH, { l: isLight ? 30 : -30, c: -6 });
65
66 // Border colors
67 const bgBorderColor = adjustColorBorder(baseColorLCH, { l: isLight ? -6 : 15, c: -1 });
68 const bgBorderFaintColor = adjustColorBorder(baseColorLCH, { l: isLight ? -3 : 8, c: -0.8 });
69 const bgBorderSolidColor = adjustColorBorder(baseColorLCH, { l: isLight ? -15 : 25, c: 0 });
70
71 // Text colors
72 const labelTitleColor = adjustTextColor(baseColorLCH, { l: 0, c: 0 }, { emphasisMultiplier: 1.1 });
73 const labelBaseColor = adjustTextColor(baseColorLCH, { l: 0, c: 0 });
74 const labelMutedColor = adjustTextColor(baseColorLCH, { l: -8, c: -2 });
75
76 // Control colors (using accent)
77 const controlPrimaryColor = accentColorLCH;
78 const controlPrimaryHoverColor = adjustColorPrimary(controlPrimaryColor, { l: 3, c: 2 });
Note

All the calculated colors are stored with semantic names (like 'bgSub', 'labelTitle') rather than with names indicating their shade level.

79 const controlPrimaryLabelColor = adjustTextColor(controlPrimaryColor, { l: 0, c: 0 });
Note

All the calculated colors are stored with semantic names (like 'bgSub', 'labelTitle') rather than with names indicating their shade level.

80 const controlSecondaryColor = adjustColorSecondary(baseColorLCH, { l: isLight ? -6 : 12, c: isLight ? 0 : 1 });
Note

All the calculated colors are stored with semantic names (like 'bgSub', 'labelTitle') rather than with names indicating their shade level.

81 const controlSecondaryHoverColor = adjustColorSecondary(controlSecondaryColor, { l: isLight ? -4 : 6, c: 1 });
Note

All the calculated colors are stored with semantic names (like 'bgSub', 'labelTitle') rather than with names indicating their shade level.

82 const controlSecondaryLabelColor = adjustTextColor(controlSecondaryColor, { l: 0, c: 0 });
Note

All the calculated colors are stored with semantic names (like 'bgSub', 'labelTitle') rather than with names indicating their shade level.

83
Note

All the calculated colors are stored with semantic names (like 'bgSub', 'labelTitle') rather than with names indicating their shade level.

84 // ... (dozens more derived colors) ...
Note

All the calculated colors are stored with semantic names (like 'bgSub', 'labelTitle') rather than with names indicating their shade level.

85
Note

All the calculated colors are stored with semantic names (like 'bgSub', 'labelTitle') rather than with names indicating their shade level.

86 // Store all the generated colors in LCH format
Note

All the calculated colors are stored with semantic names (like 'bgSub', 'labelTitle') rather than with names indicating their shade level.

87 const generatedColorsLCH = {
88 bgSub: bgSubColor,
89 bgBase: baseColorLCH,
90 bgBaseHover: bgBaseHoverColor,
91 bgShade: bgShadeColor,
92 bgBorder: bgBorderColor,
93 bgBorderFaint: bgBorderFaintColor,
94 bgBorderSolid: bgBorderSolidColor,
95 bgSelected: bgSelectedColor,
96 bgFocus: bgFocusColor,
97
98 labelTitle: labelTitleColor,
99 labelBase: labelBaseColor,
100 labelMuted: labelMutedColor,
101
102 controlPrimary: controlPrimaryColor,
103 controlPrimaryHover: controlPrimaryHoverColor,
104 controlPrimaryLabel: controlPrimaryLabelColor,
105 controlSecondary: controlSecondaryColor,
106 controlSecondaryHover: controlSecondaryHoverColor,
107 controlSecondaryLabel: controlSecondaryLabelColor,
108
109 // ... (and many more) ...
110 };
111
112 // 4. Format colors to target CSS format and calculate shadows
113 const formattedColorsCss = applyAdjustmentMultiplier(
114 generatedColorsLCH,
115 colorLCH => ColorUtils.toCss(themeConfig.colorFormat, colorLCH)
116 );
117
118 // Calculate shadow CSS strings with opacity
119 const shadowColor04 = getShadowColorCss(0.04);
120 const shadowColor08 = getShadowColorCss(0.08);
121 const shadowColor125 = getShadowColorCss(0.125);
122 const shadowColor16 = getShadowColorCss(0.16);
123 const shadowColor25 = getShadowColorCss(0.25);
124
125 // 5. Initialize memoized variables for theme variants (lazy generation)
126 let memoizedElevatedTheme;
127 let memoizedSubTheme;
128 let memoizedSidebarTheme;
129 let memoizedMenuTheme;
Note

The elevatedTheme() function creates a variant with a slightly lighter base color. It doesn't execute immediately - it's lazy-loaded when needed.

130 let memoizedSelectedTheme;
Note

The elevatedTheme() function creates a variant with a slightly lighter base color. It doesn't execute immediately - it's lazy-loaded when needed.

131 let memoizedFocusTheme;
Note

The elevatedTheme() function creates a variant with a slightly lighter base color. It doesn't execute immediately - it's lazy-loaded when needed.

132
Note

The elevatedTheme() function creates a variant with a slightly lighter base color. It doesn't execute immediately - it's lazy-loaded when needed.

133 // 6. Assemble and return the final theme object
Note

The elevatedTheme() function creates a variant with a slightly lighter base color. It doesn't execute immediately - it's lazy-loaded when needed.

134 const finalThemeObject = {
Note

The elevatedTheme() function creates a variant with a slightly lighter base color. It doesn't execute immediately - it's lazy-loaded when needed.

135 // Core metadata
Note

The elevatedTheme() function creates a variant with a slightly lighter base color. It doesn't execute immediately - it's lazy-loaded when needed.

136 focusColor: formattedColorsCss.focusColor,
Note

The elevatedTheme() function creates a variant with a slightly lighter base color. It doesn't execute immediately - it's lazy-loaded when needed.

137 contrast: themeConfig.contrast,
Note

The elevatedTheme() function creates a variant with a slightly lighter base color. It doesn't execute immediately - it's lazy-loaded when needed.

138 colorFormat: themeConfig.colorFormat,
Note

The elevatedTheme() function creates a variant with a slightly lighter base color. It doesn't execute immediately - it's lazy-loaded when needed.

139 isDark: !isLight,
140
141 // CSS Shadow Strings
142 shadowLow: isLight ? "0 1px 2px rgba(0,0,0,0.04)" : "0 1px 2px rgba(0,0,0,0.125)",
143 shadowMedium: "0 2px 4px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.16)",
144 shadowHigh: `0 4px 16px ${shadowColor16}, 0 8px 24px ${shadowColor25}`,
145
146 // Direct style values (examples)
147 inputPadding: "6px 12px",
148 inputBackground: formattedColorsCss.bgBase,
149
150 // The map of CSS color strings
151 color: formattedColorsCss,
152
153 // --- Functions to generate related themes ---
154 elevatedTheme: () => (
155 memoizedElevatedTheme || (
156 memoizedElevatedTheme = ThemeHelper.generateTheme({
157 base: ColorUtils.adjust(baseColorLCH, { l: isLight ? 3 : 6 }),
158 accent: accentColorLCH,
159 contrast: themeConfig.contrast,
160 colorFormat: themeConfig.colorFormat,
161 baseTheme: finalThemeObject,
162 elevation: (themeConfig.elevation || 0) + 1
163 })
164 )
165 ),
166
167 subTheme: () => (
168 memoizedSubTheme || (
169 memoizedSubTheme = ThemeHelper.generateTheme({
170 base: generatedColorsLCH.bgSub,
171 accent: accentColorLCH,
172 contrast: themeConfig.contrast,
173 colorFormat: themeConfig.colorFormat,
174 baseTheme: finalThemeObject
175 })
176 )
177 ),
178
179 sidebarTheme: () => {
180 // Complex logic with fallback to subTheme and overrides
181 if (!themeConfig.sidebarInput) {
182 return finalThemeObject.subTheme();
183 }
184
185 if (!memoizedSidebarTheme) {
186 const sidebarBase = themeConfig.sidebarInput.base || generatedColorsLCH.bgSub;
187 memoizedSidebarTheme = ThemeHelper.generateTheme({
188 base: sidebarBase,
189 accent: themeConfig.sidebarInput.accent || accentColorLCH,
190 contrast: themeConfig.contrast,
Note

Memoization is crucial for performance. It caches the result of theme generation so identical theme configurations don't need to be recalculated.

191 colorFormat: themeConfig.colorFormat,
Note

Memoization is crucial for performance. It caches the result of theme generation so identical theme configurations don't need to be recalculated.

192 baseTheme: finalThemeObject
Note

Memoization is crucial for performance. It caches the result of theme generation so identical theme configurations don't need to be recalculated.

193 });
Note

Memoization is crucial for performance. It caches the result of theme generation so identical theme configurations don't need to be recalculated.

194
Note

Memoization is crucial for performance. It caches the result of theme generation so identical theme configurations don't need to be recalculated.

195 // Override specific control colors if configured
196 if (themeConfig.sidebarInput.controlOverrides) {
197 const overrides = themeConfig.sidebarInput.controlOverrides;
198 if (overrides.primary) {
199 memoizedSidebarTheme.color.controlPrimary = formattedColorsCss.controlPrimary;
200 memoizedSidebarTheme.color.controlPrimaryHover = formattedColorsCss.controlPrimaryHover;
201 memoizedSidebarTheme.color.controlPrimaryLabel = formattedColorsCss.controlPrimaryLabel;
202 }
203 }
204 }
205
206 return memoizedSidebarTheme;
207 },
208
209 // Other theme variants...
210 menuTheme: () => { /* Similar to subTheme but with adjustments for menus */ },
211 selectedTheme: () => { /* Generate theme using bgSelected as base */ },
212 focusTheme: () => { /* Generate theme using bgFocus as base */ },
213
214 // Link back to parent theme if this is a variant
215 baseTheme: themeConfig.baseTheme
216 };
217
218 return finalThemeObject;
219 };
220
221 // Apply memoization for performance
222 ThemeHelper.generateTheme = memoize(
223 ThemeHelper.generateThemeUnmemoized,
224 // Cache key generator based on config values
225 (config) => Object.values(config).join("_") + Object.values(config.sidebarInput || {}).join("_")
226 );

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:

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

contrast-factor-helpers.js
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 isLight ? -1 : 1 pattern appears frequently. Dark themes usually need positive lightness adjustments to get lighter variations, while light themes need negative ones.
  • 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 adjustColor* 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.
  • Smart Text Handling: The adjustTextColor 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.

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:

color-palette-generation.js
1 // Background colors
Note

For background variants, it uses the primary contrast factor to derive hover states, subdued backgrounds, etc.

2 const bgBaseHoverColor = adjustColorPrimary(baseColorLCH, { l: 2, c: -0.5 });
Note

For background variants, it uses the primary contrast factor to derive hover states, subdued backgrounds, etc.

3 const bgSubColor = adjustColorPrimary(baseColorLCH, { l: 8, c: -0.8 });
Note

For background variants, it uses the primary contrast factor to derive hover states, subdued backgrounds, etc.

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 });
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.

8 const bgBorderFaintColor = adjustColorBorder(baseColorLCH, { l: isLight ? -3 : 8, c: -0.8 });
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.

9
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.

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 });
Note

For controls, it starts with the accent color, then derives hover states and appropriate labels.

13 const labelMutedColor = adjustTextColor(baseColorLCH, { l: -8, c: -2 });
Note

For controls, it starts with the accent color, then derives hover states and appropriate labels.

14
Note

For controls, it starts with the accent color, then derives hover states and appropriate labels.

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 });
Note

All colors are stored with semantic names describing their function in the UI, not abstract names like "grey-700".

19
Note

All colors are stored with semantic names describing their function in the UI, not abstract names like "grey-700".

20 // Store all the generated colors in LCH format
Note

All colors are stored with semantic names describing their function in the UI, not abstract names like "grey-700".

21 const generatedColorsLCH = {
Note

All colors are stored with semantic names describing their function in the UI, not abstract names like "grey-700".

22 bgSub: bgSubColor,
Note

All colors are stored with semantic names describing their function in the UI, not abstract names like "grey-700".

23 bgBase: baseColorLCH,
Note

All colors are stored with semantic names describing their function in the UI, not abstract names like "grey-700".

24 bgBaseHover: bgBaseHoverColor,
Note

All colors are stored with semantic names describing their function in the UI, not abstract names like "grey-700".

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:

theme-assembly.js
1 // Convert LCH values to the target CSS format (e.g., "RGB")
Note

All the calculated LCH colors are converted to the target format (like RGB strings) for use in CSS.

2 const formattedColorsCss = applyAdjustmentMultiplier(
Note

All the calculated LCH colors are converted to the target format (like RGB strings) for use in CSS.

3 generatedColorsLCH,
Note

All the calculated LCH colors are converted to the target format (like RGB strings) for use in CSS.

4 colorLCH => ColorUtils.toCss(themeConfig.colorFormat, colorLCH)
Note

All the calculated LCH colors are converted to the target format (like RGB strings) for use in CSS.

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,
Note

Shadow definitions are generated with opacity based on the contrast settings, creating a complete design system.

16 isDark: !isLight,
Note

Shadow definitions are generated with opacity based on the contrast settings, creating a complete design system.

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,
Note

The theme object includes functions to generate related theme variants, which we'll discuss next.

26
Note

The theme object includes functions to generate related theme variants, which we'll discuss next.

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:

theme-variants.js
1 // Initialize memoized variables
2 let memoizedElevatedTheme;
3 let memoizedSubTheme;
4
5 // In the final theme object:
6 elevatedTheme: () => (
Note

The elevatedTheme function uses a slightly lighter base color. It doesn't calculate until needed, and it caches the result.

7 memoizedElevatedTheme || (
Note

The elevatedTheme function uses a slightly lighter base color. It doesn't calculate until needed, and it caches the result.

8 memoizedElevatedTheme = ThemeHelper.generateTheme({
Note

The elevatedTheme function uses a slightly lighter base color. It doesn't calculate until needed, and it caches the result.

9 base: ColorUtils.adjust(baseColorLCH, { l: isLight ? 3 : 6 }),
Note

The elevatedTheme function uses a slightly lighter base color. It doesn't calculate until needed, and it caches the result.

10 accent: accentColorLCH,
Note

The elevatedTheme function uses a slightly lighter base color. It doesn't calculate until needed, and it caches the result.

11 contrast: themeConfig.contrast,
Note

The elevatedTheme function uses a slightly lighter base color. It doesn't calculate until needed, and it caches the result.

12 colorFormat: themeConfig.colorFormat,
Note

The elevatedTheme function uses a slightly lighter base color. It doesn't calculate until needed, and it caches the result.

13 baseTheme: finalThemeObject,
Note

The elevatedTheme function uses a slightly lighter base color. It doesn't calculate until needed, and it caches the result.

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
Note

The subTheme function uses the already calculated bgSub color as its new base, creating a contextually appropriate variant.

19 subTheme: () => (
Note

The subTheme function uses the already calculated bgSub color as its new base, creating a contextually appropriate variant.

20 memoizedSubTheme || (
Note

The subTheme function uses the already calculated bgSub color as its new base, creating a contextually appropriate variant.

21 memoizedSubTheme = ThemeHelper.generateTheme({
Note

The subTheme function uses the already calculated bgSub color as its new base, creating a contextually appropriate variant.

22 base: generatedColorsLCH.bgSub,
Note

The subTheme function uses the already calculated bgSub color as its new base, creating a contextually appropriate variant.

23 accent: accentColorLCH,
Note

The subTheme function uses the already calculated bgSub color as its new base, creating a contextually appropriate variant.

24 contrast: themeConfig.contrast,
Note

The subTheme function uses the already calculated bgSub color as its new base, creating a contextually appropriate variant.

25 colorFormat: themeConfig.colorFormat,
Note

The subTheme function uses the already calculated bgSub color as its new base, creating a contextually appropriate variant.

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() , etc., are fascinating:

  • 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 generateTheme() function again, but with modified inputs.
  • 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 baseTheme: finalThemeObject to create a connection back to the parent theme.

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() function shows even more sophistication, with logic to override specific control colors based on whether a custom sidebar input was given.

7. Memoization (Performance is Key!)

The final piece of the puzzle is performance optimization:

theme-memoization.js
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() wrapper ensures that if generateTheme() is called again with the same inputs, the cached result is returned instantly.

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.
theme-generator.js
1 const darkThemeRefresh = ThemeHelper.generateTheme({
2 base: [12, 2, 270], // LCH: Dark bluish-grey
Note

Linear's theme system uses just three core inputs:

  1. Base color in LCH format: [Lightness, Chroma, Hue]
  2. Accent color in LCH format
  3. Contrast level controlling visual separation

From just these three variables, the theme generator derives dozens of semantic colors.

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

Linear's theme system uses just three core inputs:

  1. Base color in LCH format: [Lightness, Chroma, Hue]
  2. Accent color in LCH format
  3. Contrast level controlling visual separation

From just these three variables, the theme generator derives dozens of semantic colors.

4 contrast: 33,
Note

Linear's theme system uses just three core inputs:

  1. Base color in LCH format: [Lightness, Chroma, Hue]
  2. Accent color in LCH format
  3. Contrast level controlling visual separation

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.

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

Secondary Controls

For less prominent interactive 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

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!

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)

theme-generation-logic.js
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
Note

These helper functions apply the contrast factors to adjust base colors mathematically, creating a consistent relationship between different UI elements.

6 const bgSubColor = adjustColorPrimary(baseColorLCH, { l: 2, c: -0.5 });
Note

These helper functions apply the contrast factors to adjust base colors mathematically, creating a consistent relationship between different UI elements.

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
Note

The derived colors are stored with semantic names, not shade numbers. This makes them easy to use in components based on their purpose.

11 const generatedColorsLCH = {
Note

The derived colors are stored with semantic names, not shade numbers. This makes them easy to use in components based on their purpose.

12 bgSub: bgSubColor,
Note

The derived colors are stored with semantic names, not shade numbers. This makes them easy to use in components based on their purpose.

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:

  1. Determine Theme Type: Is it a light or dark theme? Are we near black/white (these need special handling)?
  2. Calculate Adjustments: Figure out how much to tweak colors based on the desired contrast.
  3. 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.
  4. Semantic Naming: Store every derived color with a name describing its job: bgBase , bgBorderFaint , labelMuted . Forget names like grey-700-with-a-hint-of-blue .
  5. 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:

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

button.html
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 to var(--color-control-primary) ). Now, if you generate a completely different theme (say, high-contrast light mode) and update the CSS variables, the button Just Works™️, adapting its look perfectly without code changes.

Bonus Level: Contextual Variations

The system even handles generating theme variants. Need a panel that's slightly lighter than the main background?

theme-variants.js
1 const elevatedPanelTheme = currentTheme.elevatedTheme();
2 // Now apply elevatedPanelTheme's variables to that panel's scope.

The elevatedTheme() function calls the main generator again, but tells it to use a slightly lighter base color than the current theme and links back (baseTheme: currentTheme ). This recursive generation allows for contextually appropriate, yet consistently derived, styles. Memoization ensures this doesn't kill performance.

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.