CSS Color Variables Guide

阅读时间 8 分钟更新于 2026-06-10
📘 本文内容为英文原文,提供最准确的技术信息。中文解读和实操指南正在完善中。你也可以使用页面顶部的翻译工具。

CSS custom properties changed color management on the web more than any feature since the introduction of hex codes. Before them, changing a brand color meant find-and-replace across hundreds of files, recompiling Sass, and hoping nothing broke. Now it's one variable on :root.

But here's the problem: most teams use CSS variables wrong. They dump 47 color variables into :root with names like --blue-500 and --gray-200, then wonder why their system is unmaintainable. Naming a variable after its appearance defeats the purpose — rebrand from blue to purple and every variable name is a lie.

The solution is a three-layer architecture: palette tokens (raw hex values), semantic tokens (what the color does), and component tokens (where the color appears). This is how Stripe, Vercel, Linear, and every serious design system structures their colors. And in 2026, with color-mix(), light-dark(), @property, and OKLCH all reaching full browser support, CSS custom properties aren't just convenient — they're the most powerful color system available on any platform.

This guide covers the architecture, the naming conventions, the dark mode patterns, and the modern CSS functions that make custom properties genuinely powerful — not just syntactic sugar.

基础原理

The Three-Layer Token Architecture

Every production-grade color system uses three layers. Each references the layer below it. This separation is what makes 100,000-line codebases maintainable.

Layer 1: Palette Tokens — Raw color values. The actual hex/HSL/OKLCH codes. These are the source of truth, and components NEVER reference them directly.

Layer 2: Semantic Tokens — Purpose-based names that map to palette values. --color-background, --color-text, --color-primary, --color-accent, --color-danger. Every name carries intent. "Primary" means "the main brand action color." "Danger" means "something destructive or irreversible."

Layer 3: Component Tokens — Scoped to specific UI elements. --btn-bg references --color-primary. --card-border references --color-neutral. Change layer 1, and everything flows through. Change layer 2, and all components using that role update. Change layer 3, and only that component is affected.

Why Sass Variables Lost

SCSS variables compile away at build time. They're replaced by static hex values in your output CSS. You can't change them at runtime. You can't scope them to a component. You can't toggle dark mode without compiling two separate stylesheets. CSS custom properties are live values in the browser — they inherit through the DOM, respond to media queries, and can be manipulated by JavaScript without a rebuild.

Stripe's Internal Approach. Stripe enforces HSL-only color definitions in their design tokens. HEX and RGB are banned. This lets them compute accessibility scores, generate dark mode variants, and handle opacity programmatically — all without conversion bugs. Their internal tooling auto-generates all 11 Tailwind-style stops (50-950) from a single HSL base value using a logarithmic lightness curve.

Vercel's Token System. Vercel extends this pattern with OKLCH. Their brand blue (#0070F3) becomes a full 11-shade scale, and all shades are derived from the OKLCH lightness channel rather than HSL — because OKLCH produces perceptually uniform steps. "Blue-300 looks exactly halfway between blue-100 and blue-500" is only true in OKLCH, not HSL.

Linear's Scoped Properties. Linear uses component-level custom properties extensively. Each component defines its own --component-bg, --component-text, --component-border tokens that reference the global semantic tokens. This means a "Button" component can be restyled by overriding just 3 variables, without touching global state.

The @property Revolution. CSS @property (supported in all major browsers since 2024) lets you register custom properties with types, initial values, and inheritance rules. This is massive for color: you can now animate between color values smoothly, enforce that a variable only accepts types, and provide default fallbacks that the browser validates at parse time. Before @property, animating --my-color from red to blue was impossible — the browser treated it as a string swap, not a color transition.

Dark Mode: The Right Way vs The Wrong Way

The wrong way: duplicate every color variable inside a @media (prefers-color-scheme: dark) block. You end up maintaining 2x the variables and they drift apart over time.

The right way: use the light-dark() function. One declaration handles both modes: color: light-dark(#1a1a1a, #f5f5f5). The browser switches automatically based on OS preference. Combined with color-scheme: light dark on :root, this eliminates media queries entirely for color.

For more granular control, override semantic tokens in a [data-theme="dark"] selector. This approach (used by Tailwind, Radix UI, and shadcn/ui) lets users toggle themes via JavaScript while respecting system preferences as the default.

color-mix() Replaces Your Entire Sass Library

color-mix(in srgb, var(--primary) 85%, black) creates a hover darkening effect in pure CSS. No Sass darken() function. No PostCSS plugin. No build step. color-mix() works with any color space — srgb, oklch, hsl, lab — and produces better results than Sass because it operates in perceptually uniform color spaces rather than naive RGB interpolation.

Practical patterns: hover states (mix with black 10-15%), disabled states (mix with gray 50%), tints for backgrounds (mix with white 90%), and generating accessible color pairs by mixing until contrast ratio exceeds 4.5:1.

Performance Characteristics

CSS custom properties have near-zero performance cost for declaration and lookup. The browser resolves var() references during the cascade — the same phase that processes inheritance. Changing a custom property on :root triggers a repaint of affected elements, but NOT a reflow. This makes runtime theme switching essentially free. In benchmarks, swapping 50 custom properties on :root takes <1ms on modern hardware.

The exception: don't animate custom properties without @property registration. Without type registration, the browser can't interpolate between values — it does a discrete swap at 50% of the animation, causing a visual "jump" instead of a smooth transition.

Production-Ready CSS Color Variable System

/* ===== Layer 1: Palette Tokens (raw values) ===== */
:root {
  --palette-blue-50: oklch(0.97 0.01 250);
  --palette-blue-500: oklch(0.55 0.20 260);
  --palette-blue-700: oklch(0.40 0.18 260);
  --palette-blue-900: oklch(0.25 0.12 260);
  --palette-gray-50: oklch(0.98 0.005 260);
  --palette-gray-200: oklch(0.90 0.005 260);
  --palette-gray-800: oklch(0.25 0.005 260);
  --palette-gray-950: oklch(0.13 0.005 260);
  --palette-red-500: oklch(0.55 0.22 25);
  --palette-green-500: oklch(0.60 0.18 150);
}

/* ===== Layer 2: Semantic Tokens (purpose-based) ===== */
:root {
  color-scheme: light dark;
  --color-bg: light-dark(var(--palette-gray-50), var(--palette-gray-950));
  --color-surface: light-dark(white, var(--palette-gray-800));
  --color-text: light-dark(var(--palette-gray-800), var(--palette-gray-50));
  --color-text-muted: light-dark(var(--palette-gray-200), var(--palette-gray-200));
  --color-primary: light-dark(var(--palette-blue-500), var(--palette-blue-500));
  --color-primary-hover: color-mix(in oklch, var(--color-primary) 85%, black);
  --color-danger: var(--palette-red-500);
  --color-success: var(--palette-green-500);
  --color-border: light-dark(var(--palette-gray-200), var(--palette-gray-800));
}

/* ===== Layer 3: Component Tokens ===== */
.button {
  --btn-bg: var(--color-primary);
  --btn-text: white;
  --btn-hover: var(--color-primary-hover);
  background: var(--btn-bg);
  color: var(--btn-text);
}
.button:hover { background: var(--btn-hover); }

/* ===== @property for Animated Colors ===== */
@property --gradient-start {
  syntax: '<color>';
  inherits: false;
  initial-value: oklch(0.55 0.20 260);
}
@property --gradient-end {
  syntax: '<color>';
  inherits: false;
  initial-value: oklch(0.55 0.20 320);
}
.animated-gradient {
  background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
  transition: --gradient-start 0.5s, --gradient-end 0.5s;
}
.animated-gradient:hover {
  --gradient-start: oklch(0.65 0.25 280);
  --gradient-end: oklch(0.55 0.22 340);
}

复制粘贴到项目即可使用。

真实案例

The Three-Layer Token Architecture

Every production-grade color system uses three layers. Each references the layer below it. This separation is what makes 100,000-line codebases maintainable.

Layer 1: Palette Tokens — Raw color values. The actual hex/HSL/OKLCH codes. These are the source of truth, and components NEVER reference them directly.

Layer 2: Semantic Tokens — Purpose-based names that map to palette values. --color-background, --color-text, --color-primary, --color-accent, --color-danger. Every name carries intent. "Primary" means "the main brand action color." "Danger" means "something destructive or irreversible."

Layer 3: Component Tokens — Scoped to specific UI elements. --btn-bg references --color-primary. --card-border references --color-neutral. Change layer 1, and everything flows through. Change layer 2, and all components using that role update. Change layer 3, and only that component is affected.

Why Sass Variables Lost

SCSS variables compile away at build time. They're replaced by static hex values in your output CSS. You can't change them at runtime. You can't scope them to a component. You can't toggle dark mode without compiling two separate stylesheets. CSS custom properties are live values in the browser — they inherit through the DOM, respond to media queries, and can be manipulated by JavaScript without a rebuild.

Stripe's Internal Approach. Stripe enforces HSL-only color definitions in their design tokens. HEX and RGB are banned. This lets them compute accessibility scores, generate dark mode variants, and handle opacity programmatically — all without conversion bugs. Their internal tooling auto-generates all 11 Tailwind-style stops (50-950) from a single HSL base value using a logarithmic lightness curve.

Vercel's Token System. Vercel extends this pattern with OKLCH. Their brand blue (#0070F3) becomes a full 11-shade scale, and all shades are derived from the OKLCH lightness channel rather than HSL — because OKLCH produces perceptually uniform steps. "Blue-300 looks exactly halfway between blue-100 and blue-500" is only true in OKLCH, not HSL.

Linear's Scoped Properties. Linear uses component-level custom properties extensively. Each component defines its own --component-bg, --component-text, --component-border tokens that reference the global semantic tokens. This means a "Button" component can be restyled by overriding just 3 variables, without touching global state.

The @property Revolution. CSS @property (supported in all major browsers since 2024) lets you register custom properties with types, initial values, and inheritance rules. This is massive for color: you can now animate between color values smoothly, enforce that a variable only accepts types, and provide default fallbacks that the browser validates at parse time. Before @property, animating --my-color from red to blue was impossible — the browser treated it as a string swap, not a color transition.

Dark Mode: The Right Way vs The Wrong Way

The wrong way: duplicate every color variable inside a @media (prefers-color-scheme: dark) block. You end up maintaining 2x the variables and they drift apart over time.

The right way: use the light-dark() function. One declaration handles both modes: color: light-dark(#1a1a1a, #f5f5f5). The browser switches automatically based on OS preference. Combined with color-scheme: light dark on :root, this eliminates media queries entirely for color.

For more granular control, override semantic tokens in a [data-theme="dark"] selector. This approach (used by Tailwind, Radix UI, and shadcn/ui) lets users toggle themes via JavaScript while respecting system preferences as the default.

color-mix() Replaces Your Entire Sass Library

color-mix(in srgb, var(--primary) 85%, black) creates a hover darkening effect in pure CSS. No Sass darken() function. No PostCSS plugin. No build step. color-mix() works with any color space — srgb, oklch, hsl, lab — and produces better results than Sass because it operates in perceptually uniform color spaces rather than naive RGB interpolation.

Practical patterns: hover states (mix with black 10-15%), disabled states (mix with gray 50%), tints for backgrounds (mix with white 90%), and generating accessible color pairs by mixing until contrast ratio exceeds 4.5:1.

Performance Characteristics

CSS custom properties have near-zero performance cost for declaration and lookup. The browser resolves var() references during the cascade — the same phase that processes inheritance. Changing a custom property on :root triggers a repaint of affected elements, but NOT a reflow. This makes runtime theme switching essentially free. In benchmarks, swapping 50 custom properties on :root takes <1ms on modern hardware.

The exception: don't animate custom properties without @property registration. Without type registration, the browser can't interpolate between values — it does a discrete swap at 50% of the animation, causing a visual "jump" instead of a smooth transition.

行业案例

The Three-Layer Token Architecture

Every production-grade color system uses three layers. Each references the layer below it. This separation is what makes 100,000-line codebases maintainable.

Layer 1: Palette Tokens — Raw color values. The actual hex/HSL/OKLCH codes. These are the source of truth, and components NEVER reference them directly.

Layer 2: Semantic Tokens — Purpose-based names that map to palette values. --color-background, --color-text, --color-primary, --color-accent, --color-danger. Every name carries intent. "Primary" means "the main brand action color." "Danger" means "something destructive or irreversible."

Layer 3: Component Tokens — Scoped to specific UI elements. --btn-bg references --color-primary. --card-border references --color-neutral. Change layer 1, and everything flows through. Change layer 2, and all components using that role update. Change layer 3, and only that component is affected.

Why Sass Variables Lost

SCSS variables compile away at build time. They're replaced by static hex values in your output CSS. You can't change them at runtime. You can't scope them to a component. You can't toggle dark mode without compiling two separate stylesheets. CSS custom properties are live values in the browser — they inherit through the DOM, respond to media queries, and can be manipulated by JavaScript without a rebuild.

Stripe's Internal Approach. Stripe enforces HSL-only color definitions in their design tokens. HEX and RGB are banned. This lets them compute accessibility scores, generate dark mode variants, and handle opacity programmatically — all without conversion bugs. Their internal tooling auto-generates all 11 Tailwind-style stops (50-950) from a single HSL base value using a logarithmic lightness curve.

Vercel's Token System. Vercel extends this pattern with OKLCH. Their brand blue (#0070F3) becomes a full 11-shade scale, and all shades are derived from the OKLCH lightness channel rather than HSL — because OKLCH produces perceptually uniform steps. "Blue-300 looks exactly halfway between blue-100 and blue-500" is only true in OKLCH, not HSL.

Linear's Scoped Properties. Linear uses component-level custom properties extensively. Each component defines its own --component-bg, --component-text, --component-border tokens that reference the global semantic tokens. This means a "Button" component can be restyled by overriding just 3 variables, without touching global state.

The @property Revolution. CSS @property (supported in all major browsers since 2024) lets you register custom properties with types, initial values, and inheritance rules. This is massive for color: you can now animate between color values smoothly, enforce that a variable only accepts types, and provide default fallbacks that the browser validates at parse time. Before @property, animating --my-color from red to blue was impossible — the browser treated it as a string swap, not a color transition.

Dark Mode: The Right Way vs The Wrong Way

The wrong way: duplicate every color variable inside a @media (prefers-color-scheme: dark) block. You end up maintaining 2x the variables and they drift apart over time.

The right way: use the light-dark() function. One declaration handles both modes: color: light-dark(#1a1a1a, #f5f5f5). The browser switches automatically based on OS preference. Combined with color-scheme: light dark on :root, this eliminates media queries entirely for color.

For more granular control, override semantic tokens in a [data-theme="dark"] selector. This approach (used by Tailwind, Radix UI, and shadcn/ui) lets users toggle themes via JavaScript while respecting system preferences as the default.

color-mix() Replaces Your Entire Sass Library

color-mix(in srgb, var(--primary) 85%, black) creates a hover darkening effect in pure CSS. No Sass darken() function. No PostCSS plugin. No build step. color-mix() works with any color space — srgb, oklch, hsl, lab — and produces better results than Sass because it operates in perceptually uniform color spaces rather than naive RGB interpolation.

Practical patterns: hover states (mix with black 10-15%), disabled states (mix with gray 50%), tints for backgrounds (mix with white 90%), and generating accessible color pairs by mixing until contrast ratio exceeds 4.5:1.

Performance Characteristics

CSS custom properties have near-zero performance cost for declaration and lookup. The browser resolves var() references during the cascade — the same phase that processes inheritance. Changing a custom property on :root triggers a repaint of affected elements, but NOT a reflow. This makes runtime theme switching essentially free. In benchmarks, swapping 50 custom properties on :root takes <1ms on modern hardware.

The exception: don't animate custom properties without @property registration. Without type registration, the browser can't interpolate between values — it does a discrete swap at 50% of the animation, causing a visual "jump" instead of a smooth transition.

💡 高手技巧

工具推荐

The Three-Layer Token Architecture

Every production-grade color system uses three layers. Each references the layer below it. This separation is what makes 100,000-line codebases maintainable.

Layer 1: Palette Tokens — Raw color values. The actual hex/HSL/OKLCH codes. These are the source of truth, and components NEVER reference them directly.

Layer 2: Semantic Tokens — Purpose-based names that map to palette values. --color-background, --color-text, --color-primary, --color-accent, --color-danger. Every name carries intent. "Primary" means "the main brand action color." "Danger" means "something destructive or irreversible."

Layer 3: Component Tokens — Scoped to specific UI elements. --btn-bg references --color-primary. --card-border references --color-neutral. Change layer 1, and everything flows through. Change layer 2, and all components using that role update. Change layer 3, and only that component is affected.

Why Sass Variables Lost

SCSS variables compile away at build time. They're replaced by static hex values in your output CSS. You can't change them at runtime. You can't scope them to a component. You can't toggle dark mode without compiling two separate stylesheets. CSS custom properties are live values in the browser — they inherit through the DOM, respond to media queries, and can be manipulated by JavaScript without a rebuild.

Stripe's Internal Approach. Stripe enforces HSL-only color definitions in their design tokens. HEX and RGB are banned. This lets them compute accessibility scores, generate dark mode variants, and handle opacity programmatically — all without conversion bugs. Their internal tooling auto-generates all 11 Tailwind-style stops (50-950) from a single HSL base value using a logarithmic lightness curve.

Vercel's Token System. Vercel extends this pattern with OKLCH. Their brand blue (#0070F3) becomes a full 11-shade scale, and all shades are derived from the OKLCH lightness channel rather than HSL — because OKLCH produces perceptually uniform steps. "Blue-300 looks exactly halfway between blue-100 and blue-500" is only true in OKLCH, not HSL.

Linear's Scoped Properties. Linear uses component-level custom properties extensively. Each component defines its own --component-bg, --component-text, --component-border tokens that reference the global semantic tokens. This means a "Button" component can be restyled by overriding just 3 variables, without touching global state.

The @property Revolution. CSS @property (supported in all major browsers since 2024) lets you register custom properties with types, initial values, and inheritance rules. This is massive for color: you can now animate between color values smoothly, enforce that a variable only accepts types, and provide default fallbacks that the browser validates at parse time. Before @property, animating --my-color from red to blue was impossible — the browser treated it as a string swap, not a color transition.

Dark Mode: The Right Way vs The Wrong Way

The wrong way: duplicate every color variable inside a @media (prefers-color-scheme: dark) block. You end up maintaining 2x the variables and they drift apart over time.

The right way: use the light-dark() function. One declaration handles both modes: color: light-dark(#1a1a1a, #f5f5f5). The browser switches automatically based on OS preference. Combined with color-scheme: light dark on :root, this eliminates media queries entirely for color.

For more granular control, override semantic tokens in a [data-theme="dark"] selector. This approach (used by Tailwind, Radix UI, and shadcn/ui) lets users toggle themes via JavaScript while respecting system preferences as the default.

color-mix() Replaces Your Entire Sass Library

color-mix(in srgb, var(--primary) 85%, black) creates a hover darkening effect in pure CSS. No Sass darken() function. No PostCSS plugin. No build step. color-mix() works with any color space — srgb, oklch, hsl, lab — and produces better results than Sass because it operates in perceptually uniform color spaces rather than naive RGB interpolation.

Practical patterns: hover states (mix with black 10-15%), disabled states (mix with gray 50%), tints for backgrounds (mix with white 90%), and generating accessible color pairs by mixing until contrast ratio exceeds 4.5:1.

Performance Characteristics

CSS custom properties have near-zero performance cost for declaration and lookup. The browser resolves var() references during the cascade — the same phase that processes inheritance. Changing a custom property on :root triggers a repaint of affected elements, but NOT a reflow. This makes runtime theme switching essentially free. In benchmarks, swapping 50 custom properties on :root takes <1ms on modern hardware.

The exception: don't animate custom properties without @property registration. Without type registration, the browser can't interpolate between values — it does a discrete swap at 50% of the animation, causing a visual "jump" instead of a smooth transition.

免费工具推荐

用这些免费工具实操你学到的知识: