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
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; |
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:
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
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:
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:
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:
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:
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.
1 | const darkThemeRefresh = ThemeHelper.generateTheme({ |
2 | base: [12, 2, 270], // LCH: Dark bluish-grey |
Note Linear's theme system uses just three core inputs:
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:
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:
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
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.
Buttons
Form Elements
Elevated Card
This card uses a slightly elevated background (bgSub). Cards help organize related content and actions.
Progress Indicators
Alerts
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 |
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:
- 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
,labelMuted
. Forget names likegrey-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
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?
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.
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.