Dark Mode System — Low-Level Design
A dark mode system persists a user’s theme preference, propagates it across devices, and resolves conflicts between user preference and system OS setting. This appears in interviews at Apple, Google, and Meta where cross-platform UX consistency is a core concern.
Core Data Model
UserPreference
user_id BIGINT PK
theme TEXT NOT NULL DEFAULT 'system' -- 'light', 'dark', 'system'
updated_at TIMESTAMPTZ NOT NULL
-- 'system' means: follow the OS-level dark/light preference
-- 'light' / 'dark' means: override OS preference explicitly
Theme Resolution Priority
function resolveTheme(userPreference, osPreference) {
// Priority: explicit user choice > OS setting > fallback to light
if (userPreference === 'light' || userPreference === 'dark') {
return userPreference;
}
// userPreference === 'system'
if (osPreference === 'dark') return 'dark';
return 'light';
}
// On the web: OS preference via CSS media query
// prefers-color-scheme: dark
const osPreference = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light';
// Listen for OS changes
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', e => {
if (currentUserPreference === 'system') {
applyTheme(e.matches ? 'dark' : 'light');
}
});
Avoiding Flash of Incorrect Theme (FOIT)
The biggest dark mode UX problem: page loads briefly in light mode before JS runs and applies dark mode, causing a visible flash.
<!-- Inline script in — executes before any rendering -->
(function() {
var pref = localStorage.getItem('theme') || 'system';
var osIsDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = (pref === 'system') ? (osIsDark ? 'dark' : 'light') : pref;
document.documentElement.setAttribute('data-theme', theme);
})();
/* CSS: theme applied via data attribute on */
[data-theme='dark'] {
--bg-color: #1a1a1a;
--text-color: #e8e8e8;
--border-color: #333;
}
[data-theme='light'] {
--bg-color: #ffffff;
--text-color: #1a1a1a;
--border-color: #e0e0e0;
}
The inline script runs synchronously before the browser paints. It reads localStorage (no network call) and sets data-theme on before any CSS is applied.
Syncing Preference Across Devices
-- Save to server on change
PATCH /users/me/preferences
{ "theme": "dark" }
-- Server:
UPDATE UserPreference
SET theme=%(theme)s, updated_at=NOW()
WHERE user_id=%(user_id)s;
-- On login: load preference and set localStorage
GET /users/me/preferences
→ { "theme": "dark", "updated_at": "2024-01-15T10:30:00Z" }
localStorage.setItem('theme', preference.theme);
applyTheme(resolveTheme(preference.theme, osPreference));
CSS Architecture for Theming
/* Option 1: CSS custom properties (recommended) */
:root {
--color-bg: white;
--color-text: black;
--color-primary: #0070f3;
}
[data-theme='dark'] {
--color-bg: #111;
--color-text: #eee;
--color-primary: #3b82f6;
}
/* Option 2: Tailwind dark mode */
/* tailwind.config.js: darkMode: 'class' */
/* Apply: className="bg-white dark:bg-gray-900 text-black dark:text-white" */
/* Option 3: CSS-in-JS (styled-components, Emotion) */
const theme = {
light: { bg: 'white', text: 'black' },
dark: { bg: '#111', text: '#eee' }
};
Image and Media Handling
/* For SVG icons: use currentColor to inherit text color */
.icon { fill: currentColor; }
/* For photos: slight brightness reduction in dark mode */
[data-theme='dark'] img:not([data-no-invert]) {
filter: brightness(0.9);
}
Server-Side Rendering Considerations
// On SSR (Next.js, etc.): read preference from cookie
// Cookie set on the client alongside localStorage
document.cookie = `theme=${theme}; path=/; max-age=31536000; SameSite=Lax`;
// Server reads cookie to render the correct theme class server-side
// This avoids hydration mismatch between server HTML and client JS
export async function getServerSideProps({ req }) {
const theme = req.cookies.theme || 'system';
return { props: { initialTheme: theme } };
}
Key Interview Points
- Three states, not two: Always model light, dark, and system. “System” follows OS preference and must update live when the user changes their OS setting at runtime.
- Prevent flash via inline script: The only reliable way to prevent flash on page load is a synchronous inline script in <head> that reads localStorage before first paint.
- CSS custom properties > class toggling: Variables propagate automatically to all components without class changes on every element. One attribute on <html> drives the whole theme.
- Cookie for SSR: localStorage is not available server-side. Set a cookie alongside localStorage so the server can render the correct theme and avoid hydration mismatch.
{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you prevent the flash of wrong theme on page load?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Place a synchronous inline script in the HTML <head> that reads the theme preference from localStorage and sets data-theme on <html> before the browser renders any content. This script runs before CSS is applied, so there is no visible flash. The key constraint: it must be synchronous (not deferred or async) and must execute before any stylesheet or body content. On first visit (no localStorage key), read the OS preference via window.matchMedia("(prefers-color-scheme: dark)").matches to pick the correct default without a flash.”}},{“@type”:”Question”,”name”:”What are the three states a dark mode preference should support?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Light (explicit): always use light theme regardless of OS setting. Dark (explicit): always use dark theme regardless of OS setting. System (follow OS): use the OS dark/light preference and update live if the user changes their OS setting. "System" is the default and respects accessibility preferences. The system value requires listening to the prefers-color-scheme media query change event at runtime so the page updates without a reload when the user switches their OS from light to dark at dusk.”}},{“@type”:”Question”,”name”:”How do you sync dark mode preference across devices?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Save the preference to the server on change (PATCH /users/me/preferences with {theme: "dark"}). On login, fetch the preference and write it to localStorage. This way, a user who sets dark mode on their phone sees dark mode when they open a browser on their laptop (after login). Use optimistic UI: apply the change locally immediately, then sync to the server in the background. If the server update fails, roll back the localStorage value and show an error.”}},{“@type”:”Question”,”name”:”How do CSS custom properties enable theming?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Define color variables on :root for the light theme (the default). Override them on [data-theme="dark"] or .dark. Every component references the variable (var(–color-bg)) rather than a literal color (#ffffff). When you toggle the data-theme attribute on <html>, all variables update in a single repaint — no class changes needed on individual elements. This scales to hundreds of components: add a new component using existing variables and it automatically supports both themes without any theme-specific code.”}},{“@type”:”Question”,”name”:”How do you handle dark mode in server-side rendered pages?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”The server cannot read localStorage. Two options: (1) Cookie-based: write document.cookie = "theme=dark; path=/; SameSite=Lax" alongside localStorage on preference change. The server reads this cookie on each request and sets the data-theme attribute in the rendered HTML, matching what the inline script would set. This eliminates hydration mismatch in React/Next.js. (2) Accept the flash for unauthenticated users; for authenticated users, store the preference in the database and include it in the SSR context via the session.”}}]}
Dark mode and theme system design is discussed in Apple system design interview questions.
Dark mode and cross-platform preference sync design is covered in Google system design interview preparation.
Dark mode and UI theming system design is discussed in Meta system design interview guide.