Multi-Theme Design System: CSS Variables + Data Attributes
Published on Jan 3, 2026
How I built five switchable themes—each with light/dark variants—using CSS variables and data attributes. Architecture, tradeoffs, and what I'd do differently.
My portfolio needed five themes—not just light/dark mode. Each theme has its own palette, mood, and vibe, but they share the same design language.
Here's the architecture I landed on and the tradeoffs I hit.
The challenge
Supporting multiple themes isn't just about swapping colors. Each theme needed to work across:
- Light and dark modes within the same theme
- Design system utilities that adapt automatically
- Component variants that respect theme boundaries
- Zero flash of unstyled content during theme switches
- Maintainable CSS that doesn't explode in complexity
The five themes:
cloud-dancer– Soft, airy neutrals (default)dusty-jewel– Rich, muted purples and roseswellness-mint– Fresh greens with natural warmththermal-glow– Warm oranges and deep brownsmidnight– Deep blues with bright accents
Architecture: CSS variables + data attributes
I evaluated class-based theming, CSS-in-JS, and Tailwind config overrides. CSS variables controlled by HTML data attributes won.
/* In globals.css */
[data-theme="cloud-dancer"] {
--bg-primary: #faf9f7;
--bg-secondary: #f5f3f0;
--text-primary: #2d2520;
--text-secondary: #5c534a;
--accent: #4a3728;
--border-default: rgba(45, 37, 32, 0.1);
}
[data-theme="dusty-jewel"] {
--bg-primary: #faf8f5;
--bg-secondary: #f2ede6;
--text-primary: #1e1b18;
--text-secondary: #52524e;
--accent: #6b4c5a;
--border-default: rgba(30, 27, 24, 0.1);
}
/* Dark theme as a first-class palette */
[data-theme="midnight"] {
--bg-primary: #0f0f12;
--bg-secondary: #18181c;
--text-primary: #f5f5f5;
--text-secondary: #a0a0a8;
--accent: #24ff2d;
--border-default: rgba(255, 255, 255, 0.08);
}The key insight: dark mode is just another theme. Instead of layering dark variants on top of each palette, I made midnight a first-class dark theme with its own personality—deep blues, neon green accent. Users pick a palette, not a mode.
Design system utilities
Instead of hardcoding colors like bg-blue-500, I created semantic utilities that map to CSS variables:
// Component example
<div className="bg-ds-primary text-ds-primary border border-ds-default">
<h2 className="text-ds-accent">Themed Content</h2>
</div>These utilities live in a @layer utilities block:
@layer utilities {
.bg-ds-primary {
background-color: var(--bg-primary);
}
.bg-ds-secondary {
background-color: var(--bg-secondary);
}
.text-ds-primary {
color: var(--text-primary);
}
.text-ds-secondary {
color: var(--text-secondary);
}
.text-ds-accent {
color: var(--accent);
}
.border-ds-default {
border-color: var(--border-default);
}
}Components don't know which theme is active. They reference semantic tokens, and the CSS cascade handles the rest.
Theme switching without flicker
I use next-themes to handle theme persistence and hydration. It solves the flash problem out of the box.
// In theme-provider.tsx
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
);
}For the multi-theme palette switching, I store the active theme in localStorage and apply the data-theme attribute on mount:
// Custom hook for palette themes
const [palette, setPalette] = useState('cloud-dancer');
useEffect(() => {
const saved = localStorage.getItem('palette') || 'cloud-dancer';
setPalette(saved);
document.documentElement.setAttribute('data-theme', saved);
}, []);The next-themes library handles the light/dark system preference, while the data-theme attribute controls the color palette.
What I gained
Theme extensibility: Adding a sixth theme means defining new CSS variables. No JavaScript changes.
Performance: Theme switches are instant—pure CSS, no re-renders.
Designer-friendly: Designers can experiment with themes by editing CSS, without touching components.
Type safety: TypeScript ensures only valid palette names are used:
type PaletteName = 'cloud-dancer' | 'dusty-jewel' | 'wellness-mint' |
'thermal-glow' | 'midnight';
// next-themes handles light/dark/system separately
type Theme = 'light' | 'dark' | 'system';What I lost
Maintenance overhead: Every new semantic color requires updates across 5 theme definitions. A spreadsheet helps, but it's manual work.
Tooling gaps: Tailwind IntelliSense doesn't autocomplete custom utilities like bg-ds-primary. Had to add them manually to the config.
Migration cost: Refactoring existing components to use design system utilities took weeks. Grep helps:
# Find hardcoded colors
grep -r "bg-blue-" src/components/CSS variable debugging: When a color looks wrong, tracing the cascade through :root and [data-theme] selectors can be painful.
Design tokens across palettes
Each palette defines the full set of semantic tokens. Here's wellness-mint vs midnight:
/* Light palette: wellness-mint */
[data-theme="wellness-mint"] {
--bg-primary: #fafcfa;
--bg-secondary: #f0f4f1;
--text-primary: #1a1d1c;
--text-secondary: #5a6360;
--accent: #6ba38c; /* Mint green */
--border-default: rgba(26, 29, 28, 0.1);
}
/* Dark palette: midnight */
[data-theme="midnight"] {
--bg-primary: #0f0f12;
--bg-secondary: #18181c;
--text-primary: #f5f5f5;
--text-secondary: #a0a0a8;
--accent: #24ff2d; /* Neon green */
--border-default: rgba(255, 255, 255, 0.08);
}The trick is maintaining perceptual contrast across all palettes. A text color that works on a light surface needs a different value on a dark one. I used APCA (Accessible Perceptual Contrast Algorithm) to validate combinations.
Performance notes
CSS variables have negligible runtime cost—they're resolved during style calculation, not paint or composite. A few gotchas:
Specificity conflicts: Avoid !important. When themes conflict, the cascade gets messy fast.
Initial load size: 5 themes × 15 tokens = 75 CSS variable declarations. Adds ~3KB to critical CSS. Acceptable, but worth monitoring.
Hydration mismatches: Server-rendered HTML uses the default theme, but the client might have a different preference stored. next-themes handles this, but custom palette switching needs care.
What I'd do differently
-
Define tokens first: I added tokens ad-hoc, leading to inconsistency. Start with a complete token taxonomy (primary, secondary, accent, surface, etc.).
-
Automate validation: Write tests that ensure every theme defines all required tokens. I caught missing variables only when a component rendered incorrectly.
-
Document token semantics: "What is
--accentsupposed to be used for?" should have a clear answer. -
Try Tailwind v4's
@themedirective: I built this before Tailwind v4 was stable. The new@themesyntax might simplify some of this.
The takeaway
Themes are data, not code. By treating theme definitions as declarative CSS rather than imperative JavaScript, the system becomes easier to reason about and harder to break.
The architecture—CSS variables + data attributes + semantic utilities—strikes a good balance between flexibility and maintainability. Not perfect, but it scales well.