The Problem
When I started building DevType, a typing practice app for programmers, I wanted users to choose from multiple editor themes - not just light and dark, but themes like Dracula, VS Code Dark, GitHub, Monokai, and Nord.
The typical dark: prefix approach in Tailwind doesn't work well here. You'd end up with something like:
<div className="bg-white dark:bg-gray-900 dracula:bg-[#282a36] monokai:bg-[#272822]">
This gets messy fast. I needed a better approach.
The Solution: CSS Variables + Tailwind v4
The key insight is that CSS variables can be updated at runtime, and Tailwind CSS v4 makes it trivial to use them as color values.
Here's the architecture:
┌─────────────────┐ ┌──────────────┐ ┌─────────────┐
│ Theme Definition│ --> │ CSS Variables│ --> │ Tailwind │
│ (TypeScript) │ │ (:root) │ │ (bg-primary)│
└─────────────────┘ └──────────────┘ └─────────────┘
Step 1: Define Theme Types
First, define what colors each theme needs. I split this into two categories:
src/lib/settings/themes.ts
// Site-wide colors (CSS variables)
export type SiteColors = {
background: string;
foreground: string;
card: string;
cardForeground: string;
primary: string;
primaryForeground: string;
secondary: string;
secondaryForeground: string;
muted: string;
mutedForeground: string;
accent: string;
accentForeground: string;
destructive: string;
border: string;
input: string;
ring: string;
// ... more as needed
};
export type EditorTheme = {
id: string;
name: string;
site: SiteColors;
};
This gives you type safety when defining themes. No more typos in color values.
Step 2: Create Theme Definitions
Here's a simplified version of my Dracula theme:
export const EDITOR_THEMES: Record<string, EditorTheme> = {
dracula: {
id: "dracula",
name: "Dracula",
site: {
background: "#282a36",
foreground: "#f8f8f2",
card: "#1e1f29",
cardForeground: "#f8f8f2",
primary: "#bd93f9",
primaryForeground: "#282a36",
secondary: "#44475a",
secondaryForeground: "#f8f8f2",
muted: "#44475a",
mutedForeground: "#8494c9",
accent: "#50fa7b",
accentForeground: "#282a36",
destructive: "#ff5555",
border: "#44475a",
input: "#44475a",
ring: "#bd93f9",
},
},
"github-light": {
id: "github-light",
name: "GitHub Light",
site: {
background: "#ffffff",
foreground: "#24292f",
card: "#f6f8fa",
cardForeground: "#24292f",
primary: "#0969da",
primaryForeground: "#ffffff",
// ...
},
},
// Add more themes...
};
I ended up with 8 themes: Dracula, VS Code Dark, GitHub Dark, GitHub Light, One Light, Monokai, Nord, and Solarized Dark.
Step 3: Apply Theme to CSS Variables
This is where the magic happens. When a user selects a theme, update the CSS variables on :root:
export function applySiteTheme(theme: EditorTheme): void {
const root = document.documentElement;
const { site } = theme;
root.style.setProperty("--background", site.background);
root.style.setProperty("--foreground", site.foreground);
root.style.setProperty("--card", site.card);
root.style.setProperty("--card-foreground", site.cardForeground);
root.style.setProperty("--primary", site.primary);
root.style.setProperty("--primary-foreground", site.primaryForeground);
root.style.setProperty("--secondary", site.secondary);
root.style.setProperty("--secondary-foreground", site.secondaryForeground);
root.style.setProperty("--muted", site.muted);
root.style.setProperty("--muted-foreground", site.mutedForeground);
root.style.setProperty("--accent", site.accent);
root.style.setProperty("--accent-foreground", site.accentForeground);
root.style.setProperty("--destructive", site.destructive);
root.style.setProperty("--border", site.border);
root.style.setProperty("--input", site.input);
root.style.setProperty("--ring", site.ring);
}
That's it. One function call and all colors update instantly.
Step 4: Connect CSS Variables to Tailwind v4
In Tailwind CSS v4, you use @theme to define custom properties. Here's my globals.css:
@import "tailwindcss";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
}
/* Default values (Dracula) */
:root {
--background: #282a36;
--foreground: #f8f8f2;
--card: #1e1f29;
--primary: #bd93f9;
/* ... */
}
Now you can use bg-background, text-foreground, border-border etc. in your components. These colors automatically update when the theme changes.
Step 5: React Context for State Management
Wrap everything in a context to persist settings and apply themes:
"use client";
import { createContext, useContext, useState, useEffect, useCallback } from "react";
import { getTheme, DEFAULT_THEME_ID, applySiteTheme } from "@/lib/settings/themes";
const STORAGE_KEY = "devtype-settings";
export function SettingsProvider({ children }) {
const [themeId, setThemeId] = useState(DEFAULT_THEME_ID);
const [isLoaded, setIsLoaded] = useState(false);
// Load from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const { themeId } = JSON.parse(stored);
setThemeId(themeId || DEFAULT_THEME_ID);
}
setIsLoaded(true);
}, []);
// Apply theme when it changes
useEffect(() => {
if (!isLoaded) return;
const theme = getTheme(themeId);
applySiteTheme(theme);
}, [themeId, isLoaded]);
const setTheme = useCallback((newThemeId: string) => {
setThemeId(newThemeId);
localStorage.setItem(STORAGE_KEY, JSON.stringify({ themeId: newThemeId }));
}, []);
return (
<SettingsContext.Provider value={{ themeId, setTheme, isLoaded }}>
{children}
</SettingsContext.Provider>
);
}
export function useSettings() {
const context = useContext(SettingsContext);
if (!context) throw new Error("useSettings must be used within SettingsProvider");
return context;
}
Usage in Components
Now using themed colors is simple:
// No dark: prefix needed!
<div className="bg-background text-foreground">
<Card className="bg-card border-border">
<h1 className="text-primary">Welcome</h1>
<p className="text-muted-foreground">Some description</p>
<Button className="bg-primary text-primary-foreground">
Click me
</Button>
</Card>
</div>
These colors work correctly whether the user picks Dracula (dark), GitHub Light (light), or any other theme.
Handling Flash of Default Theme
One issue: on page load, users might briefly see the default theme before their saved theme loads. Fix this by applying the theme early:
// In your root layout
<html>
<head>
<script dangerouslySetInnerHTML={{
__html: `
try {
const settings = JSON.parse(localStorage.getItem('devtype-settings'));
if (settings?.themeId) {
document.documentElement.setAttribute('data-theme', settings.themeId);
}
} catch (e) {}
`
}} />
</head>
<body>
<SettingsProvider>{children}</SettingsProvider>
</body>
</html>
Or use a CSS-only approach with the default theme colors in :root.
Adding New Colors
When I needed warning colors for notices, adding them was straightforward:
- Add to the type:
export type SiteColors = {
// ... existing colors
warning: string;
warningForeground: string;
};
- Add to each theme:
// Dark theme
warning: "rgba(251, 191, 36, 0.08)",
warningForeground: "#fcd34d",
// Light theme
warning: "rgba(217, 119, 6, 0.1)",
warningForeground: "#b45309",
- Add to
applySiteTheme:
root.style.setProperty("--warning", site.warning);
root.style.setProperty("--warning-foreground", site.warningForeground);
- Add to
@theme:
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
- Use it:
<div className="bg-warning text-warning-foreground">
Warning message
</div>
Why This Approach Works
- No build-time variants - Colors are runtime CSS variables, not compiled classes
- Type-safe themes - TypeScript ensures all themes have all required colors
-
Easy to add themes - Just add a new object to
EDITOR_THEMES -
Works with Tailwind v4 -
@theme inlinemakes CSS variables available as utilities - Instant switching - No page reload, colors update immediately
- Persisted settings - localStorage keeps the user's preference
Live Demo
Try it yourself at DevType - go to Settings and switch between themes. The entire site updates instantly.
Available themes:
- Dracula
- VS Code Dark
- GitHub Dark
- GitHub Light
- One Light
- Monokai
- Nord
- Solarized Dark
What approach do you use for theming? Let me know in the comments!
Top comments (0)