Design

How to Build a Dynamic Light and Dark Theme in CSS Using HSL and Color Tokens

Build production-ready light and dark themes without JavaScript overhead. Learn how to structure design tokens with HSL, avoid contrast failures, and implement smooth mode transitions.

The Synctoolo Team··10 min read
Code editor displaying dark mode styles and CSS variables

In modern web applications, dark mode is no longer an optional novelty. Operating systems like macOS, Windows, iOS, and Android let users express a global display preference, and users expect web applications to honor that preference seamlessly without blinding them with harsh white backgrounds at night.

Yet many frontend codebases implement dark mode in ways that cause maintainability headaches. Hardcoding hex values across dozens of component CSS classes, writing duplicated stylesheets, or relying on complex JavaScript toggles leads to visual glitches, Flash of Unstyled Content (FOUC), and accessibility contrast failures.

This tutorial shows you how to engineer a production-ready, zero-runtime-overhead theming system using CSS custom properties and HSL color tokens. We also explore how to sample and verify colors using Synctoolo's Color Picker and Color Converter.

Why HSL Beats HEX for Design Tokens

Hexadecimal color codes like #6366F1 are compact, but they are opaque to human intuition and mathematical manipulation in CSS. If you have a primary button with background #6366F1 and need a darker shade for hover and a lighter tint for focus borders, you have to sample and hardcode three distinct hex values.

HSL (Hue, Saturation, Lightness) solves this problem elegantly by separating chromatic color from brightness:

  • Hue (0 to 360): Defines the identity of the color (e.g., 245 is indigo).
  • Saturation (0% to 100%): Defines the vividness of the color.
  • Lightness (0% to 100%): Defines the brightness.

By storing HSL values as independent numbers in CSS custom properties, you can calculate hover, active, and focus states on the fly without declaring separate color tokens:

:root {
  /* Brand color token defined by Hue, Saturation, Lightness */
  --brand-h: 245;
  --brand-s: 82%;
  --brand-l: 67%;
}

.button-primary {
  background-color: hsl(var(--brand-h), var(--brand-s), var(--brand-l));
}

.button-primary:hover {
  /* 8% darker automatically on hover without shifting hue */
  background-color: hsl(var(--brand-h), var(--brand-s), calc(var(--brand-l) - 8%));
}
Architectural interplay of light and dark shadows
Balanced dark mode designs use rich charcoal and slate surfaces rather than harsh pure black. Photo by Daniel von Appen on Unsplash.

The Common Dark Mode Mistake: Pure Black (#000000)

A frequent mistake when designing dark mode is using pure black (#000000) for the canvas background and pure white (#FFFFFF) for body copy. This extreme contrast ratio (21:1) causes eye fatigue and visual halation (glowing effect) around text on OLED screens.

High-polish interfaces (like Linear, Raycast, and Synctoolo) use elevated dark surfaces:

  • Canvas Background: Deep charcoal or slate (e.g., hsl(220, 15%, 8%)).
  • Surface Cards: Slightly elevated slate (e.g., hsl(220, 15%, 13%)) with a subtle 1px border (hsl(220, 15%, 20%)).
  • Body Text: Off-white (e.g., hsl(220, 10%, 90%)) providing a comfortable 12:1 contrast ratio that easily satisfies WCAG AA guidelines.

Complete Implementation Walkthrough

Here is a complete, production-ready theme template supporting automatic OS preferences and optional manual overrides:

/* 1. Base Light Theme */
:root {
  --bg-app: hsl(0, 0%, 100%);
  --bg-surface: hsl(220, 14%, 96%);
  --border-subtle: hsl(220, 13%, 91%);
  --text-main: hsl(220, 20%, 10%);
  --text-muted: hsl(220, 10%, 45%);
  --brand: hsl(262, 83%, 58%);
}

/* 2. Automatic OS Dark Mode */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg-app: hsl(224, 25%, 6%);
    --bg-surface: hsl(224, 20%, 10%);
    --border-subtle: hsl(224, 15%, 18%);
    --text-main: hsl(220, 15%, 95%);
    --text-muted: hsl(220, 10%, 65%);
    --brand: hsl(262, 83%, 65%);
  }
}

/* 3. Explicit Manual Dark Class */
[data-theme="dark"] {
  --bg-app: hsl(224, 25%, 6%);
  --bg-surface: hsl(224, 20%, 10%);
  --border-subtle: hsl(224, 15%, 18%);
  --text-main: hsl(220, 15%, 95%);
  --text-muted: hsl(220, 10%, 65%);
  --brand: hsl(262, 83%, 65%);
}

Testing and Auditing Your Color Tokens

When selecting your palette tokens, check every combination against WCAG 2.1 accessibility benchmarks. Normal text must maintain at least a 4.5:1 contrast ratio against its background. You can sample hex and RGB values visually using our Color Picker and convert existing stylesheets with our Color Converter.

Tools mentioned in this article

FAQ

Why do some dark mode toggles cause a flash of white on reload?+

If your dark mode toggle relies on client-side React or JavaScript running after hydration, the browser renders default light CSS before JavaScript executes. To fix FOUC, read localStorage in an inline blocking script in the HTML head before DOM rendering.

Can you animate theme transitions with CSS?+

Yes. Adding 'transition: background-color 0.2s ease, border-color 0.2s ease' to components creates a smooth transition when switching between light and dark modes.

S
The Synctoolo Team

We build and review free, privacy-first tools at Synctoolo.

Keep reading