MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

CSS Relative Colors and Color Functions

Use CSS relative color syntax: color-mix(), light-dark(), and relative color adjustments.

CSSColorsFrontendStyling

By MinhVo

Introduction

CSS color handling has evolved dramatically. For years, developers relied on preprocessors like Sass to manipulate colors — darkening a brand color for hover states, lightening a background for cards, or creating accessible color pairs. These operations required build tools and added complexity to the development workflow. With the arrival of relative color syntax, color-mix(), and the light-dark() function, CSS can now handle all color manipulation natively in the browser.

Relative color syntax lets you take any existing color and modify its channels — increase lightness, adjust hue, boost saturation — without knowing the original values. The color-mix() function blends two colors in any color space, enabling palette generation, opacity adjustments, and accessible contrast pairing. The light-dark() function provides a clean way to define light and dark theme colors without media queries or class toggling. Together, these functions eliminate the need for CSS preprocessors for color manipulation, reduce stylesheet complexity, and enable dynamic theming that responds to user preferences in real time.

This guide covers every modern CSS color function, demonstrates practical patterns for design systems and theming, and shows how to build complete color palettes using nothing but native CSS.

CSS Color functions illustration

Understanding Color Functions: Core Concepts

Relative Color Syntax

Relative color syntax takes an origin color and transforms it. The syntax uses the from keyword:

/* Lighten a color by increasing its lightness */
.brand-light {
  color: hsl(from var(--brand) h s calc(l + 20%));
}
 
/* Create a complementary color by shifting hue 180° */
.brand-complement {
  color: hsl(from var(--brand) calc(h + 180) s l);
}
 
/* Desaturate for muted tones */
.brand-muted {
  color: hsl(from var(--brand) h calc(s - 40%) l);
}

The from keyword references the origin color, and you can access its channels as variables: h (hue), s (saturation), l (lightness) for HSL; r, g, b for RGB; l, a, b for Lab; and so on.

You can also convert between color spaces:

/* Take an HSL color and output in oklch */
.brand-oklch {
  color: oklch(from var(--brand) l c h);
}

The color-mix() Function

color-mix() blends two colors in a specified color space:

/* Mix brand color with white at 70% brand / 30% white */
.brand-tint {
  color: color-mix(in srgb, var(--brand) 70%, white);
}
 
/* Mix with transparency */
.brand-faded {
  color: color-mix(in srgb, var(--brand) 50%, transparent);
}

The in keyword specifies the color space for mixing: srgb, display-p3, oklch, lab, hsl, and others. Different color spaces produce different blending results — oklch produces perceptually uniform blends, while srgb is the most familiar.

The light-dark() Function

light-dark() returns one of two colors based on the current color scheme:

:root {
  color-scheme: light dark;
}
 
body {
  background: light-dark(#ffffff, #1a1a1a);
  color: light-dark(#1a1a1a, #f0f0f0);
}
 
.card {
  border-color: light-dark(#e5e7eb, #374151);
  box-shadow: light-dark(
    0 2px 8px rgba(0, 0, 0, 0.1),
    0 2px 8px rgba(0, 0, 0, 0.4)
  );
}

The function respects the color-scheme property and the user's OS-level preference. No media queries, no JavaScript, no class toggling.

Color space diagram

Architecture and Design Patterns

Pattern 1: Complete Color Palette from a Single Brand Color

:root {
  --brand: oklch(65% 0.25 265);
 
  /* Lightness scale */
  --brand-50:  oklch(from var(--brand) 97% 0.02 h);
  --brand-100: oklch(from var(--brand) 93% 0.04 h);
  --brand-200: oklch(from var(--brand) 85% 0.08 h);
  --brand-300: oklch(from var(--brand) 75% 0.15 h);
  --brand-400: oklch(from var(--brand) 68% 0.22 h);
  --brand-500: var(--brand);
  --brand-600: oklch(from var(--brand) 55% 0.22 h);
  --brand-700: oklch(from var(--brand) 45% 0.18 h);
  --brand-800: oklch(from var(--brand) 35% 0.12 h);
  --brand-900: oklch(from var(--brand) 25% 0.08 h);
  --brand-950: oklch(from var(--brand) 15% 0.04 h);
}

Pattern 2: Semantic Color Tokens with light-dark()

:root {
  color-scheme: light dark;
 
  --surface: light-dark(#ffffff, #0f172a);
  --surface-raised: light-dark(#f8fafc, #1e293b);
  --text-primary: light-dark(#0f172a, #f1f5f9);
  --text-secondary: light-dark(#64748b, #94a3b8);
  --border: light-dark(#e2e8f0, #334155);
  --accent: light-dark(oklch(65% 0.25 265), oklch(75% 0.25 265));
}

Pattern 3: Accessible Color Pairs with color-mix()

:root {
  --brand: #3b82f6;
 
  /* Ensure text on brand background meets WCAG AA */
  --brand-text: color-mix(in oklch, var(--brand) 15%, white);
  /* For dark text on light brand tint */
  --brand-bg: color-mix(in oklch, var(--brand) 10%, white);
}

Pattern 4: Interactive State Colors

.button {
  --base: var(--brand);
  background: var(--base);
  color: white;
  transition: background 0.2s;
}
 
.button:hover {
  background: oklch(from var(--base) calc(l - 0.08) c h);
}
 
.button:active {
  background: oklch(from var(--base) calc(l - 0.15) c h);
}
 
.button:focus-visible {
  outline: 3px solid oklch(from var(--base) l c h / 0.5);
  outline-offset: 2px;
}

Step-by-Step Implementation

Step 1: Define Your Base Colors

Start with your brand color(s) in a perceptually uniform color space like OKLCH:

:root {
  --brand: oklch(65% 0.25 265);
  --success: oklch(65% 0.2 145);
  --warning: oklch(75% 0.18 85);
  --error: oklch(60% 0.25 25);
}

Step 2: Generate Lightness Scales

Use relative color syntax to create a full scale from each base:

:root {
  --brand-50:  oklch(from var(--brand) 97% 0.02 h);
  --brand-500: var(--brand);
  --brand-900: oklch(from var(--brand) 25% 0.08 h);
}

Step 3: Create Semantic Tokens with light-dark()

:root {
  color-scheme: light dark;
 
  --bg-page: light-dark(var(--brand-50), #0f172a);
  --bg-card: light-dark(white, #1e293b);
  --text-main: light-dark(#111827, #f9fafb);
}

Step 4: Mix Colors for UI States

.tag {
  background: color-mix(in oklch, var(--brand) 15%, transparent);
  color: var(--brand);
  border: 1px solid color-mix(in oklch, var(--brand) 30%, transparent);
}
 
.tag:hover {
  background: color-mix(in oklch, var(--brand) 25%, transparent);
}

Step 5: Create Accessible Text Colors

/* Automatically ensure sufficient contrast */
.text-on-brand {
  /* Mix toward white or black based on brand lightness */
  color: oklch(from var(--brand) calc(l > 0.6 ? 20% : 95%) c h);
}

Step 6: Responsive Color Scheme

:root {
  color-scheme: light dark;
}
 
/* Force light mode */
@media (prefers-color-scheme: light) {
  :root { color-scheme: light; }
}
 
/* Force dark mode */
@media (prefers-color-scheme: dark) {
  :root { color-scheme: dark; }
}

Implementation workflow

Real-World Use Cases

Use Case 1: Design System Color Tokens

A complete design system that generates all colors from three base values:

:root {
  --primary: oklch(65% 0.25 265);
  --neutral: oklch(50% 0.01 265);
  --accent: oklch(70% 0.2 45);
 
  color-scheme: light dark;
}
 
/* Primary scale */
--primary-subtle: light-dark(
  oklch(from var(--primary) 95% 0.05 h),
  oklch(from var(--primary) 20% 0.1 h)
);
--primary-base: var(--primary);
--primary-emphasis: light-dark(
  oklch(from var(--primary) 45% 0.2 h),
  oklch(from var(--primary) 80% 0.2 h)
);

Use Case 2: Dynamic Chart Colors

Generate distinguishable chart colors from a single hue:

.chart {
  --base-hue: 220;
 
  --series-1: oklch(65% 0.2 var(--base-hue));
  --series-2: oklch(65% 0.2 calc(var(--base-hue) + 60));
  --series-3: oklch(65% 0.2 calc(var(--base-hue) + 120));
  --series-4: oklch(65% 0.2 calc(var(--base-hue) + 180));
  --series-5: oklch(65% 0.2 calc(var(--base-hue) + 240));
}

Use Case 3: Accessible Alert Colors

.alert-success {
  background: color-mix(in oklch, var(--success) 12%, white);
  border-color: color-mix(in oklch, var(--success) 30%, white);
  color: oklch(from var(--success) 35% c h);
}
 
.alert-error {
  background: color-mix(in oklch, var(--error) 12%, white);
  border-color: color-mix(in oklch, var(--error) 30%, white);
  color: oklch(from var(--error) 35% c h);
}

Best Practices for Production

  1. Use OKLCH for perceptually uniform results: HSL and RGB produce inconsistent perceptual lightness. OKLCH ensures that calc(l + 20%) produces a visually consistent lightening across all hues.

  2. Start with semantic tokens, not raw colors: Define --surface, --text-primary, --accent and derive everything from them. This makes theming trivial.

  3. Use light-dark() instead of media queries: It is shorter, more maintainable, and respects the color-scheme property which can be toggled per-element.

  4. Mix toward transparent for tints: color-mix(in oklch, var(--brand) 15%, transparent) is the cleanest way to create background tints.

  5. Test in both color schemes: Use DevTools to toggle between light and dark mode and verify all light-dark() values produce sufficient contrast.

  6. Use color-mix() for opacity: Instead of rgba(var(--brand-rgb), 0.5), use color-mix(in srgb, var(--brand) 50%, transparent). This works with any color format.

  7. Document your color scale: Add comments explaining the lightness range (50 = lightest, 950 = darkest) and when to use each level.

  8. Provide fallbacks for older browsers: Use @supports to detect color function support and fall back to static values.

Common Pitfalls and Solutions

PitfallImpactSolution
Using HSL for relative colorsInconsistent perceptual lightness across huesUse OKLCH for relative color syntax
Forgetting color-scheme declarationlight-dark() always returns light valueAdd color-scheme: light dark to :root
Mixing color spaces in color-mix()Unexpected blend resultsSpecify the color space explicitly: in oklch
Using calc() with wrong unitsPercentage vs. number mismatchUse <number> for hue, <percentage> for s/l
Not testing dark mode separatelyHidden contrast issuesAlways test both schemes
Overusing relative color syntaxPerformance impact with many computed valuesPre-compute static palettes where possible

Performance Optimization

Color functions are resolved at computed-value time, meaning the browser calculates the final color once and reuses it. There is no ongoing performance cost. However, deeply nested relative color calculations (e.g., a relative color of a relative color of a relative color) can slow initial style computation:

/* Acceptable: one level of relative color */
--brand-light: oklch(from var(--brand) calc(l + 0.1) c h);
 
/* Avoid: deeply nested relative colors */
--brand-lighter: oklch(from oklch(from oklch(from var(--brand) ...) ...) ...);

For static palettes, consider pre-computing the values and using them directly.

Comparison with Alternatives

FeatureCSS Color FunctionsSass Color FunctionsJavaScriptCSS Variables (static)
Runtime themingYesNo (build-time)YesNo
Color space controlOKLCH, Lab, Display P3HSL onlyAnyN/A
light-dark() supportNativeNoManualNo
Bundle size impact0 KBAdds to build outputVaries0 KB
Browser support95%+ (2024)UniversalUniversalUniversal
Perceptual uniformityOKLCH defaultNoDependsN/A

Advanced Patterns

Generating Complementary and Analogous Colors

:root {
  --brand: oklch(65% 0.25 265);
 
  /* Complementary */
  --complement: oklch(from var(--brand) l c calc(h + 180));
 
  /* Analogous */
  --analogous-1: oklch(from var(--brand) l c calc(h + 30));
  --analogous-2: oklch(from var(--brand) l c calc(h - 30));
 
  /* Triadic */
  --triadic-1: oklch(from var(--brand) l c calc(h + 120));
  --triadic-2: oklch(from var(--brand) l c calc(h + 240));
}

Container-Aware Color Adjustments

.card {
  background: var(--surface);
}
 
.card[data-variant="highlight"] {
  background: color-mix(in oklch, var(--accent) 8%, var(--surface));
}

Gradient with Relative Colors

.hero {
  background: linear-gradient(
    135deg,
    var(--brand),
    oklch(from var(--brand) calc(l + 0.15) c calc(h + 40))
  );
}

Testing Strategies

import { test, expect } from '@playwright/test';
 
test('light-dark returns correct value for dark scheme', async ({ page }) => {
  await page.emulateMedia({ colorScheme: 'dark' });
  await page.goto('/demo');
 
  const bg = await page.locator('body').evaluate(
    el => getComputedStyle(el).backgroundColor
  );
  expect(bg).toBe('rgb(15, 23, 42)'); // Dark mode background
});
 
test('relative color produces correct hover state', async ({ page }) => {
  await page.goto('/demo');
  const button = page.locator('.button');
  
  const normalBg = await button.evaluate(
    el => getComputedStyle(el).backgroundColor
  );
  
  await button.hover();
  const hoverBg = await button.evaluate(
    el => getComputedStyle(el).backgroundColor
  );
  
  expect(hoverBg).not.toBe(normalBg);
});

Future Outlook

The CSS Color Level 4 specification is now fully implemented in all major browsers. Upcoming additions in Color Level 5 include contrast-color() for automatic contrast adjustment and color-layers() for compositing multiple color values. The light-dark() function is also being extended to support additional themes beyond light and dark. As Display P3 monitors become standard, the ability to specify colors in display-p3 color space will become increasingly important for vibrant, saturated brand colors that exceed sRGB gamut.

Cross-Browser Testing Strategy

Modern CSS features often have varying levels of browser support, making a systematic cross-browser testing strategy essential. Before using any CSS feature in production, verify its support status and implement appropriate fallbacks for browsers that haven't yet implemented it.

Progressive Enhancement with @supports

Use the @supports at-rule to provide fallback styles for browsers that don't support specific CSS features:

/* Base styles for all browsers */
.grid-container {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
}
 
.grid-container > * {
  flex: 1 1 300px;
}
 
/* Enhanced layout for browsers supporting CSS Grid */
@supports (display: grid) {
  .grid-container {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
    gap: 1.5rem;
  }
 
  .grid-container > * {
    flex: none;
  }
}
 
/* Further enhancement with subgrid support */
@supports (grid-template-columns: subgrid) {
  .grid-container {
    grid-template-columns: subgrid;
  }
}

Visual Regression Testing

Implement visual regression testing to catch unintended layout shifts and styling changes. Tools like Percy, Chromatic, or Playwright's screenshot comparison can detect visual differences across browsers and screen sizes:

const { test, expect } = require('@playwright/test');
 
test('responsive layout matches design', async ({ page }) => {
  await page.goto('/components/dashboard');
 
  // Test at multiple viewport sizes
  for (const viewport of [
    { width: 375, height: 812, name: 'mobile' },
    { width: 768, height: 1024, name: 'tablet' },
    { width: 1440, height: 900, name: 'desktop' },
  ]) {
    await page.setViewportSize({ width: viewport.width, height: viewport.height });
    await expect(page).toHaveScreenshot(
      `dashboard-${viewport.name}.png`,
      { maxDiffPixels: 100 }
    );
  }
});

Browser Compatibility Testing Matrix

Maintain a testing matrix that covers the browsers and versions your users actually use. Use analytics data to determine your browser support baseline, then configure tools like Browserslist to automatically handle polyfilling and prefixing:

{
  "browserslist": [
    "> 0.5%",
    "last 2 versions",
    "not dead",
    "not ie 11"
  ]
}

This data-driven approach ensures you're spending testing effort where it matters most, rather than trying to support every possible browser configuration.

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

Modern CSS color functions eliminate the need for preprocessors for color manipulation. With relative color syntax, color-mix(), and light-dark(), you can build complete, themeable color systems using nothing but native CSS.

Key takeaways:

  1. Use OKLCH for relative color syntax — it provides perceptually uniform lightness adjustments.
  2. Generate complete palettes from a single brand color using oklch(from var(--brand) l c h) with varying lightness.
  3. Use light-dark() for theme colors instead of media queries — it is shorter and respects color-scheme.
  4. Use color-mix() for tints, shades, and opacity — color-mix(in oklch, var(--brand) 15%, transparent) is the cleanest approach.
  5. Define semantic color tokens (--surface, --text-primary, --accent) and derive everything from them.
  6. Test in both light and dark modes to catch contrast issues early.

Start by replacing one Sass color function with its CSS equivalent. The instant feedback of runtime color manipulation will convince you to go all-in on native CSS color functions.