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.

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%));
}
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.
We build and review free, privacy-first tools at Synctoolo.
Keep reading

Build accessible, consistent color systems in CSS. Understand why OKLCH fixes HSL brightness distortions and enables wide-gamut Display P3 web palettes.

Design compliant, high-readability web interfaces. Learn WCAG 2.2 contrast thresholds, the mathematical formula behind relative luminance, and the APCA standard.