← Back to DevBytes

Implementing CSS Color Adjustment in Modern Web Applications

Understanding CSS Color Adjustment

CSS color adjustment encompasses a collection of modern CSS features that give developers fine-grained control over how colors are rendered across different devices, user preferences, and media contexts. At its core, color adjustment is the browser's ability to adapt an element's colors based on environmental factors — whether that's a user's operating system theme, a high-contrast accessibility mode, or a printer's output settings.

The specification defines several interrelated properties and media queries that work together: color-scheme, accent-color, print-color-adjust, the prefers-color-scheme media query, the forced-colors media query, and the newer light-dark() color function. Together, they form a robust system for building applications that feel native and accessible to every user, regardless of their environment.

Why Color Adjustment Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Modern users expect applications to seamlessly integrate with their device preferences. When a user switches their operating system to dark mode at dusk, they expect your web application to follow suit automatically. Similarly, users relying on Windows High Contrast Mode or other assistive technologies need your interface to remain legible and functional without losing critical visual information.

Beyond user preference, color adjustment solves practical problems: printing a page with dark backgrounds can waste ink and produce unreadable results; form controls that don't match your brand feel disjointed; and hard-coded color values break when viewed in forced-colors mode. Implementing proper color adjustment demonstrates respect for accessibility, reduces user friction, and future-proofs your application against evolving platform standards.

Core CSS Color Adjustment Features

1. The color-scheme Property

The color-scheme CSS property tells the browser which color schemes an element is designed to work with. When you declare color-scheme: dark on an element, the browser applies its built-in dark theme styles to native form controls, scrollbars, and other system-rendered UI within that element. This is the foundational property that enables automatic color adjustment.

The property accepts these values: normal (the default, meaning no specific scheme preference), light, dark, light dark (both are supported), and only light or only dark (which prevents the browser from overriding color values even when a different scheme is active).

/* Apply to the root element for page-wide scheme */
:root {
  color-scheme: light dark;
}

/* A component that only works in light mode */
.light-only-card {
  color-scheme: only light;
  background-color: #ffffff;
  color: #1a1a1a;
}

/* A component designed exclusively for dark mode */
.dark-panel {
  color-scheme: only dark;
  background-color: #0d0d0d;
  color: #f0f0f0;
}

When you set color-scheme: light dark on the root element, you're telling the browser that your page supports both modes. The browser will then automatically render native widgets — checkboxes, radio buttons, select dropdowns, date pickers, and scrollbars — in the appropriate color scheme that matches the user's operating system preference. This happens without any JavaScript intervention.

2. Using prefers-color-scheme for Dark/Light Mode

While color-scheme handles native controls, the prefers-color-scheme media query allows you to write custom styles that respond to the user's system preference. This is the cornerstone of implementing a complete dark mode in your application.

The media query accepts two values: light and dark. You can nest your theme-specific styles inside these queries to override your default color palette.

/* Default (light) theme variables */
:root {
  --bg-primary: #ffffff;
  --bg-secondary: #f5f5f5;
  --text-primary: #1a1a1a;
  --text-secondary: #666666;
  --border-color: #e0e0e0;
  --accent: #3b82f6;
  --accent-hover: #2563eb;
  color-scheme: light dark;
}

/* Dark theme overrides */
@media (prefers-color-scheme: dark) {
  :root {
    --bg-primary: #121212;
    --bg-secondary: #1e1e1e;
    --text-primary: #f5f5f5;
    --text-secondary: #a0a0a0;
    --border-color: #333333;
    --accent: #60a5fa;
    --accent-hover: #93c5fd;
  }
}

/* Apply the variables */
body {
  background-color: var(--bg-primary);
  color: var(--text-primary);
  font-family: system-ui, sans-serif;
}

.card {
  background-color: var(--bg-secondary);
  border: 1px solid var(--border-color);
  border-radius: 8px;
  padding: 1.5rem;
}

.button-primary {
  background-color: var(--accent);
  color: #ffffff;
  border: none;
  padding: 0.75rem 1.5rem;
  border-radius: 6px;
  cursor: pointer;
}

.button-primary:hover {
  background-color: var(--accent-hover);
}

This approach uses CSS custom properties as a theming bridge. The default block defines light theme values, and the media query overrides them when dark mode is active. Every component references the variables, so the entire application switches themes automatically without duplicating style rules.

3. The light-dark() Color Function

Introduced in the CSS Color Level 5 specification, the light-dark() function provides an elegant shorthand for specifying two colors — one for light mode and one for dark mode — in a single declaration. The browser chooses which color to apply based on the element's resolved color-scheme.

:root {
  color-scheme: light dark;
}

/* Using light-dark() for concise dual-theme colors */
.callout-box {
  background-color: light-dark(#f0f7ff, #1a2a3a);
  color: light-dark(#1a3a5a, #d0e0f0);
  border: 1px solid light-dark(#c0d0e0, #2a3a4a);
  padding: 1rem;
  border-radius: 8px;
}

.status-badge {
  background-color: light-dark(#e6ffe6, #1a3a1a);
  color: light-dark(#2d5a2d, #a0d0a0);
  padding: 0.25rem 0.75rem;
  border-radius: 999px;
  font-size: 0.875rem;
}

The function takes exactly two arguments: the first is the color for the light scheme, the second for the dark scheme. The browser resolves which to use based on the computed color-scheme of the element, not the user's system preference directly — meaning if an element has color-scheme: only light, it will always use the first argument regardless of the user's system setting. This gives you precise, element-level control without writing media queries for every color property.

Browser support note: As of early 2025, light-dark() is supported in Chrome 123+, Firefox 120+, and Safari 17.5+. For production applications targeting older browsers, provide fallback values using @media (prefers-color-scheme: dark).

4. Controlling Print Colors with print-color-adjust

The print-color-adjust property (which replaces the deprecated color-adjust) determines whether the browser should adjust colors when printing a page. By default, browsers may modify background colors, text colors, and other visual properties to optimize for printer output — often stripping dark backgrounds to save ink.

/* Preserve your exact colors when printing */
@media print {
  body {
    print-color-adjust: exact;
    -webkit-print-color-adjust: exact; /* Safari support */
  }
}

/* A data table that must retain its color coding in print */
.data-table {
  print-color-adjust: exact;
}

.data-table th {
  background-color: #2c3e50;
  color: #ffffff;
}

.data-table .highlight-row {
  background-color: #fff3cd;
}

/* Allow browser adjustments for a mostly-text article */
.article-content {
  print-color-adjust: economy; /* Default behavior */
}

The exact value instructs the browser to preserve your specified colors precisely as rendered on screen. The economy value (the default) allows the browser to make ink-saving adjustments. Always include the -webkit-print-color-adjust prefixed version for Safari compatibility. Apply this property within a @media print block to avoid affecting screen rendering.

5. Form Control Styling with accent-color

The accent-color property allows you to tint native form controls — checkboxes, radio buttons, range sliders, and progress bars — with your brand color. This is a lightweight way to achieve visual consistency without building custom control components from scratch.

/* Apply a brand accent to all form controls */
input[type="checkbox"],
input[type="radio"],
input[type="range"],
progress {
  accent-color: #6c5ce7;
}

/* Different accents for different form sections */
.payment-section input[type="radio"] {
  accent-color: #00b894;
}

.terms-section input[type="checkbox"] {
  accent-color: #0984e3;
}

/* Complete form example with themed controls */


/* The CSS that styles them */
.signup-form {
  accent-color: #ff6b6b;
  display: flex;
  flex-direction: column;
  gap: 1rem;
  max-width: 400px;
}

The browser applies your accent color to the checked state of checkboxes and radio buttons, the filled portion of range sliders and progress bars, and focus rings on these controls. The property automatically derives appropriate contrasting colors for the control's internal elements, so you don't need to calculate hover or active states manually.

6. Supporting forced-colors Mode

The forced-colors media query detects when a user has enabled a forced color palette, such as Windows High Contrast Mode. In this mode, the browser replaces author-specified colors with system-defined colors, and certain properties like box-shadow and background-image may be suppressed. Proper support ensures your application remains usable for people with visual impairments.

/* Detect forced-colors active mode */
@media (forced-colors: active) {
  /* Use system colors for reliable contrast */
  .button-primary {
    background-color: ButtonFace;
    color: ButtonText;
    border: 2px solid ButtonBorder;
    /* box-shadow is stripped in forced-colors, use border for emphasis */
  }

  .button-primary:hover {
    background-color: Highlight;
    color: HighlightText;
  }

  /* Ensure icons remain visible */
  .icon {
    /* Use currentColor so it inherits the system text color */
    fill: currentColor;
    forced-color-adjust: none;
  }

  /* Preserve critical decorative elements */
  .brand-logo {
    forced-color-adjust: none;
  }

  /* Replace background-image with visible borders */
  .card {
    border: 1px solid ButtonBorder;
    /* background-image would be removed by the browser */
  }

  /* Links should remain distinguishable */
  a {
    color: LinkText;
    text-decoration: underline;
  }

  a:visited {
    color: VisitedText;
  }
}

The forced-color-adjust property controls whether the browser is allowed to adjust colors on a specific element. Setting forced-color-adjust: none on an element preserves your authored colors even in forced-colors mode — use this sparingly and only on elements where color is essential to meaning, such as brand logos or color-coded status indicators.

The system color keywords (ButtonFace, ButtonText, Highlight, HighlightText, LinkText, Canvas, CanvasText, etc.) are invaluable in forced-colors mode because they automatically map to the user's configured system palette, guaranteeing adequate contrast.

Bringing It All Together: A Complete Theming System

Here is a production-ready theming system that combines all the color adjustment features discussed above. It supports light and dark modes, respects system preferences, handles forced-colors gracefully, and provides a manual theme toggle for users who want to override their system setting.

/* styles/themes.css */

/* Step 1: Define light theme variables (default) */
:root {
  /* Tell the browser we support both schemes */
  color-scheme: light dark;

  /* Light theme palette */
  --color-bg: #ffffff;
  --color-bg-secondary: #f8fafc;
  --color-surface: #ffffff;
  --color-text: #0f172a;
  --color-text-muted: #64748b;
  --color-border: #e2e8f0;
  --color-accent: #6366f1;
  --color-accent-text: #ffffff;
  --color-danger: #ef4444;
  --color-success: #22c55e;
  --color-warning: #f59e0b;

  /* Spacing & sizing (theme-independent) */
  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 16px;
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
}

/* Step 2: Dark theme overrides via media query */
@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #0f172a;
    --color-bg-secondary: #1e293b;
    --color-surface: #1e293b;
    --color-text: #f1f5f9;
    --color-text-muted: #94a3b8;
    --color-border: #334155;
    --color-accent: #818cf8;
    --color-accent-text: #0f172a;
    --color-danger: #f87171;
    --color-success: #4ade80;
    --color-warning: #fbbf24;
    --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
    --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
  }
}

/* Step 3: Manual dark mode override class (takes precedence) */
:root.dark-mode-manual {
  --color-bg: #0f172a;
  --color-bg-secondary: #1e293b;
  --color-surface: #1e293b;
  --color-text: #f1f5f9;
  --color-text-muted: #94a3b8;
  --color-border: #334155;
  --color-accent: #818cf8;
  --color-accent-text: #0f172a;
  --color-danger: #f87171;
  --color-success: #4ade80;
  --color-warning: #fbbf24;
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
  --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
}

/* Step 4: Base application styles using variables */
body {
  background-color: var(--color-bg);
  color: var(--color-text);
  font-family: system-ui, -apple-system, sans-serif;
  line-height: 1.6;
  margin: 0;
  transition: background-color 0.2s ease, color 0.2s ease;
}

/* Step 5: Component styles */
.app-header {
  background-color: var(--color-surface);
  border-bottom: 1px solid var(--color-border);
  padding: 1rem 2rem;
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.card {
  background-color: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: 1.5rem;
  box-shadow: var(--shadow-sm);
}

.button {
  background-color: var(--color-accent);
  color: var(--color-accent-text);
  border: none;
  border-radius: var(--radius-sm);
  padding: 0.625rem 1.25rem;
  font-weight: 600;
  cursor: pointer;
  transition: opacity 0.15s ease;
}

.button:hover {
  opacity: 0.9;
}

.button--outline {
  background-color: transparent;
  color: var(--color-accent);
  border: 2px solid var(--color-accent);
}

.input-field {
  background-color: var(--color-bg-secondary);
  color: var(--color-text);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  padding: 0.625rem 1rem;
  font-size: 1rem;
}

.input-field:focus {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}

/* Step 6: Accent color for native form controls */
input[type="checkbox"],
input[type="radio"],
input[type="range"],
progress {
  accent-color: var(--color-accent);
}

/* Step 7: Print styles */
@media print {
  body {
    print-color-adjust: exact;
    -webkit-print-color-adjust: exact;
    background-color: #ffffff;
    color: #000000;
  }

  .app-header,
  .button {
    /* Simplify for print */
    background-color: #f0f0f0;
    color: #000000;
    border: 1px solid #cccccc;
  }
}

/* Step 8: Forced-colors support */
@media (forced-colors: active) {
  .button {
    background-color: ButtonFace;
    color: ButtonText;
    border: 2px solid ButtonBorder;
  }

  .button:hover {
    background-color: Highlight;
    color: HighlightText;
  }

  .card {
    border: 1px solid ButtonBorder;
    box-shadow: none;
  }

  .input-field {
    border: 1px solid ButtonBorder;
    background-color: Canvas;
    color: CanvasText;
  }

  .input-field:focus {
    outline: 2px solid Highlight;
  }

  /* Preserve the brand logo's colors */
  .brand-logo {
    forced-color-adjust: none;
  }

  /* Icons should inherit text color */
  .icon {
    fill: currentColor;
    forced-color-adjust: none;
  }
}

And here is the accompanying JavaScript for the manual theme toggle, which persists the user's choice in localStorage:

// theme-toggle.js

class ThemeManager {
  constructor() {
    this.toggleButton = document.getElementById('theme-toggle');
    this.rootElement = document.documentElement;
    this.manualKey = 'app-theme-preference';
    this.init();
  }

  init() {
    const savedPreference = localStorage.getItem(this.manualKey);

    if (savedPreference === 'dark') {
      this.rootElement.classList.add('dark-mode-manual');
      this.updateToggleLabel('dark');
    } else if (savedPreference === 'light') {
      this.rootElement.classList.remove('dark-mode-manual');
      this.updateToggleLabel('light');
    } else {
      // No saved preference: follow system setting
      this.rootElement.classList.remove('dark-mode-manual');
      this.updateToggleLabel('system');
    }

    this.toggleButton.addEventListener('click', () => this.toggle());
  }

  toggle() {
    const currentManual = localStorage.getItem(this.manualKey);
    const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

    if (currentManual === 'dark') {
      // Switch to light mode manually
      localStorage.setItem(this.manualKey, 'light');
      this.rootElement.classList.remove('dark-mode-manual');
      this.updateToggleLabel('light');
    } else if (currentManual === 'light') {
      // Switch to system default
      localStorage.removeItem(this.manualKey);
      this.rootElement.classList.remove('dark-mode-manual');
      this.updateToggleLabel('system');
    } else {
      // Currently following system, switch to dark manually
      localStorage.setItem(this.manualKey, 'dark');
      this.rootElement.classList.add('dark-mode-manual');
      this.updateToggleLabel('dark');
    }
  }

  updateToggleLabel(mode) {
    const labels = {
      system: '☀️ System Theme',
      light: '☀️ Light Mode',
      dark: '🌙 Dark Mode',
    };
    this.toggleButton.setAttribute('aria-label', labels[mode]);
    this.toggleButton.textContent = labels[mode];
  }
}

// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
  new ThemeManager();
});

The HTML for the toggle button sits in your header:

<header class="app-header">
  <h1>My Application</h1>
  <button id="theme-toggle" class="button button--outline">
    ☀️ System Theme
  </button>
</header>

This system handles every scenario: the user's system preference is respected by default, the manual toggle overrides it, the choice persists across sessions, native form controls match the theme, printing preserves essential colors, and forced-colors mode keeps everything accessible.

Best Practices for Implementing Color Adjustment

/* Respect motion preferences during theme transitions */
@media (prefers-reduced-motion: no-preference) {
  body {
    transition: background-color 0.2s ease, color 0.2s ease;
  }
}

@media (prefers-reduced-motion: reduce) {
  body {
    transition: none;
  }
}

Conclusion

CSS color adjustment is no longer an optional enhancement — it's a fundamental part of building respectful, accessible, and platform-native web applications. By combining color-scheme, prefers-color-scheme, light-dark(), accent-color, print-color-adjust, and forced-colors support into a cohesive theming system, you create an experience that adapts gracefully to every user's environment and preferences. The investment in understanding and implementing these features pays dividends in user satisfaction, accessibility compliance, and long-term maintainability. Start with color-scheme: light dark on your root element, build your palette with custom properties, and layer on each additional feature incrementally — your users will notice the care you've put into making your application feel like a natural part of their device ecosystem.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles