Dark mode went from a niche developer preference to a mainstream expectation in the span of three years. Every major operating system supports it. Every major website offers it. Users expect it. And yet implementing dark mode correctly -- with system preference detection, persistent user choice, smooth transitions, and consistent application across every component -- remains surprisingly difficult.
Sessions 251 and 254 built FLIN's complete theme system: three modes (light, dark, system), automatic system preference detection, persistent user preference via localStorage, smooth CSS transitions, and automatic application to all 365+ FlinUI components without any per-component configuration.
Three Modes, One API
flintheme = "system" // "light", "dark", or "system"
<ThemeToggle value={theme} onChange={t => theme = t} />The ThemeToggle component renders a three-state toggle: light (sun icon), dark (moon icon), and system (monitor icon). Clicking cycles through the modes. The current mode is stored in the theme variable and persisted to localStorage.
The three modes:
- Light. White backgrounds, dark text, light borders. The default for users who have not expressed a preference.
- Dark. Dark backgrounds, light text, subtle borders. Reduces eye strain in low-light conditions and saves battery on OLED screens.
- System. Follows the operating system's preference. If the user sets their OS to dark mode, the application follows. If they switch to light mode, the application follows. This is the default mode for FlinUI.
System Preference Detection
The "system" mode uses the operating system's color scheme preference. On the web, this is detected through the prefers-color-scheme media query:
flin// In the ThemeProvider
fn get_system_theme() {
// Browser environment: use media query
matches_dark = media_query_matches("(prefers-color-scheme: dark)")
{if matches_dark}
return "dark"
{else}
return "light"
{/if}
}The ThemeProvider listens for changes to the media query. When the user changes their OS theme (switching from light to dark in macOS System Settings, for example), the application detects the change and updates immediately.
Persistent Preference
When the user explicitly chooses a theme (light or dark), their choice is saved to localStorage:
flin// Save preference
fn set_theme(new_theme: text) {
theme = new_theme
{if new_theme != "system"}
local_storage_set("flin-theme", new_theme)
{else}
local_storage_remove("flin-theme")
{/if}
}
// Load preference on startup
fn load_theme() {
saved = local_storage_get("flin-theme")
{if saved != none}
return saved // "light" or "dark"
{else}
return "system" // Default to system preference
{/if}
}The logic is intentional: - If the user chose "light" or "dark," save it and always use it - If the user chose "system," remove the saved preference and follow the OS - On first visit (no saved preference), default to "system"
This means the application respects the user's choice across sessions while defaulting to the most adaptive mode for new users.
How Theme Changes Propagate
When the theme changes, the ThemeProvider updates CSS custom properties on the root element:
flin// ThemeProvider.flin
active_theme = resolve_theme(props.theme)
fn apply_theme(theme: text) {
{if theme == "dark"}
set_css_var("--flin-bg-primary", "#1a1a2e")
set_css_var("--flin-bg-secondary", "#16213e")
set_css_var("--flin-bg-surface", "#0f3460")
set_css_var("--flin-text-primary", "#e2e8f0")
set_css_var("--flin-text-secondary", "#a0aec0")
set_css_var("--flin-border-color", "#2d3748")
set_css_var("--flin-shadow-sm", "0 1px 3px rgba(0,0,0,0.3)")
// ... all theme tokens
{else}
set_css_var("--flin-bg-primary", "#ffffff")
set_css_var("--flin-bg-secondary", "#f8f9fa")
set_css_var("--flin-bg-surface", "#ffffff")
set_css_var("--flin-text-primary", "#212529")
set_css_var("--flin-text-secondary", "#6c757d")
set_css_var("--flin-border-color", "#dee2e6")
set_css_var("--flin-shadow-sm", "0 1px 3px rgba(0,0,0,0.1)")
// ... all theme tokens
{/if}
}CSS custom properties (variables) propagate through the entire DOM tree. When --flin-bg-primary changes from #ffffff to #1a1a2e, every element that uses var(--flin-bg-primary) in its styles updates automatically. The browser handles this natively -- no JavaScript iteration over elements, no virtual DOM diffing, no component re-rendering.
This is why FlinUI's design tokens map to CSS custom properties. The theme system changes 20-30 CSS variables, and the browser applies the changes to every element that references them. For a page with 500 elements, this is dramatically faster than re-rendering 500 components through FLIN's reactivity system.
Smooth Transitions
Abrupt theme switches are jarring. A page that flashes from white to black in a single frame feels like an error. FlinUI applies CSS transitions to all theme-dependent properties:
css/* Applied to the root element */
* {
transition: background-color 200ms ease-in-out,
color 200ms ease-in-out,
border-color 200ms ease-in-out,
box-shadow 200ms ease-in-out;
}This makes the theme transition smooth -- backgrounds fade from white to dark blue, text fades from black to light gray, borders subtly shift. The 200ms duration is fast enough to feel responsive but slow enough to be perceptible as an intentional transition rather than a glitch.
The universal selector (*) applies the transition to every element. This has minimal performance impact because modern browsers optimize transitions efficiently. The alternative -- adding transition properties to each component's CSS -- would be more verbose and harder to maintain.
Flash of Wrong Theme Prevention
A common dark mode bug: the page loads with the light theme, flickers, and then switches to dark. This "flash of wrong theme" happens because JavaScript needs to execute before the theme preference can be read and applied.
FlinUI prevents this with an inline script in the HTML <head>:
html<script>
// This runs before the page renders, preventing FOWT
(function() {
var saved = localStorage.getItem('flin-theme');
var theme = saved || (
window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light'
);
document.documentElement.setAttribute('data-theme', theme);
})();
</script>This inline script runs synchronously before the browser renders any content. It reads the saved preference (or detects the system preference), sets a data-theme attribute on the root element, and CSS rules that match [data-theme="dark"] apply immediately. The page never renders with the wrong theme.
Component Integration
FlinUI components use CSS custom properties for all theme-dependent values. This means they integrate with the theme system automatically, without any per-component theme logic:
flin// Card.flin -- uses CSS variables, no theme logic
<style>
.card {
background: var(--flin-bg-surface);
border: 1px solid var(--flin-border-color);
border-radius: var(--flin-radius-lg);
box-shadow: var(--flin-shadow-sm);
color: var(--flin-text-primary);
}
</style>When the theme changes from light to dark:
- --flin-bg-surface changes from #ffffff to #0f3460
- --flin-border-color changes from #dee2e6 to #2d3748
- --flin-shadow-sm changes from light shadows to darker shadows
- --flin-text-primary changes from #212529 to #e2e8f0
The Card component does not know that a theme change occurred. It just uses CSS variables. The browser applies the new variable values automatically.
This is why the design token system (article 085) was built before any components. By making every component reference tokens instead of hardcoded values, the theme system works for all 365+ components without any component modification.
Building a Theme Switcher
A complete theme switcher with visual feedback:
flintheme = load_theme()
active = resolve_theme(theme)
<Stack direction="horizontal" gap={2} align="center">
<Button
variant={active == "light" ? "primary" : "default"}
size="sm"
click={set_theme("light")}
>
<Icon name="sun" size={16} /> Light
</Button>
<Button
variant={active == "dark" ? "primary" : "default"}
size="sm"
click={set_theme("dark")}
>
<Icon name="moon" size={16} /> Dark
</Button>
<Button
variant={theme == "system" ? "primary" : "default"}
size="sm"
click={set_theme("system")}
>
<Icon name="monitor" size={16} /> System
</Button>
</Stack>Three buttons. The active button is highlighted with the primary variant. Clicking a button calls set_theme, which updates the CSS variables, saves the preference, and updates the button highlights. All of this -- theme detection, persistence, CSS variable updates, and UI feedback -- happens in about 30 lines of FLIN code.
Theme-Aware Images and Media
Some images need different versions for light and dark modes (logos, illustrations, diagrams):
flinactive = resolve_theme(theme)
<Image
src={active == "dark" ? "/logo-white.svg" : "/logo-dark.svg"}
alt="Company Logo"
/>For more complex cases, the ThemeImage component encapsulates the pattern:
flin// ThemeImage.flin
active = resolve_theme(get_current_theme())
<picture>
{if active == "dark"}
<img src={props.dark} alt={props.alt} />
{else}
<img src={props.light} alt={props.alt} />
{/if}
</picture>
// Usage
<ThemeImage
light="/charts/revenue-light.png"
dark="/charts/revenue-dark.png"
alt="Revenue Chart"
/>The Complete Theme Architecture
User clicks theme toggle
|
ThemeToggle component calls set_theme("dark")
|
set_theme saves to localStorage
|
ThemeProvider updates CSS custom properties
|
Browser applies new variable values to all elements
|
CSS transitions animate the visual change
|
All 365+ FlinUI components update automatically
|
Total time: ~200ms (transition duration)
Total JavaScript: ~5 lines (set CSS variables)
Total re-renders: 0 (CSS handles everything)Zero component re-renders. The browser's CSS engine does all the work. The theme system is fast because it uses the browser's native capabilities instead of fighting against them.
Lessons From Dark Mode Implementation
Building a theme system across 365+ components taught us several lessons that apply broadly to UI library design.
Tokens must come first. We built the design token system before any components. If we had built components first and then tried to retrofit tokens, every component would have needed modification. Tokens-first meant every component was theme-ready from its first line of code.
CSS variables are faster than JavaScript. Our initial prototype used FLIN's reactivity system to propagate theme changes: change a FLIN variable, re-render every component that references it. This worked but was slow -- hundreds of components re-rendering on a theme toggle caused a visible flicker. Switching to CSS custom properties eliminated the flicker entirely. The browser's CSS engine is optimized for exactly this use case.
System preference is the right default. Many applications default to light mode and require the user to opt into dark mode. This forces every dark-mode user to take an action on first visit. Defaulting to "system" respects the user's existing preference without any interaction. Users who want a specific theme can still choose one, but most users never need to touch the toggle.
Dark mode is not just inverted colors. Inverting a light theme (white to black, black to white) produces a harsh, high-contrast interface that is unpleasant to use. Proper dark mode uses dark grays (not pure black) for backgrounds, slightly dimmed whites for text, and adjusted shadows and borders. The dark token set in FlinUI was hand-tuned for comfort, not generated algorithmically.
Transitions prevent user disorientation. An instant theme switch is disorienting -- the user's eyes are adapted to one brightness level, and the sudden change causes a reflexive squint or blink. The 200ms CSS transition gives the user's visual system time to adjust, making the switch feel smooth rather than jarring.
These lessons are not FLIN-specific. They apply to any application that supports theme switching. But building the system for a 365-component library forced us to confront every edge case that smaller applications can ignore.
This is Part 93 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO built a zero-configuration theme system with dark mode support.
Series Navigation: - [92] Attribute Reactivity - [93] Theme Toggle and Dark Mode (you are here) - [94] The Raw Tag: Escape Hatch for HTML - [95] 151 FlinUI Components Built by AI Agents