It all started with a simple, familiar goal: I wanted to add a dark mode to my new React application. It’s practically a requirement these days. But as I started digging into the latest tools, specifically the Vite-powered React and the newly released Tailwind CSS v4, I realized I was thinking too small.
What if I could go beyond just a light and dark theme? What if users could choose their own accent colors? A deep blue, a vibrant green, maybe even a warm orange?
This post is the story of that journey. I’ll walk you through how I built a flexible, multi-theme system that’s not only powerful but also surprisingly elegant, thanks to the new CSS-first approach in Tailwind v4.
Tailwind v4’s New Philosophy
My first surprise was that the old way of thinking about Tailwind — heavy on the tailwind.config.js
file—has fundamentally changed. With Tailwind v4 and its Vite plugin, the configuration becomes incredibly minimal. The real power has moved directly into my CSS file.
The hero of this new approach is a tool that’s been in our browsers all along: CSS Custom Properties (Variables).
Instead of defining colors in a JavaScript object, I could now define them as native CSS variables. This felt right. It meant my theming logic would live where it belongs — in the CSS — and could be dynamically controlled by my React application.
Laying the Foundation:
Everything starts in src/index.css
. This single file became the heart of the entire theming system.
First, I added a professional-grade font, “Inter,” from Google Fonts to give the UI a clean, modern feel.
Then, I defined all my theme colors inside a @layer base
block.
/* src/index.css */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
@import "tailwindcss";
@layer base {
/* 1. Default (Light) Theme */
:root {
--background: #FFFFFF;
--foreground: #020817;
--card: #FFFFFF;
--card-foreground: #020817;
--primary: #1E40AF;
--primary-foreground: #EFF6FF;
--muted: #F1F5F9;
--muted-foreground: #64748B;
--border: #E2E8F0;
}
/* 2. Dark Theme */
.dark {
--background: #020817;
--foreground: #F8FAFC;
--card: #0F172A;
/* ...and so on for all dark mode variables */
}
/* 3. Accent Themes */
.theme-green {
--primary: #16A34A;
--primary-foreground: #F0FDF4;
}
.dark.theme-green {
--primary: #22C55E;
--primary-foreground: #052E16;
}
}
The logic here is:
:root
holds the default (light theme) variables.The
.dark
class overrides these variables when it's applied to the<html>
tag.Other classes like
.theme-green
can be applied alongside.dark
or.light
to specifically change the--primary
colors, giving me an independent accent system.
The final piece of the CSS puzzle was connecting these variables to Tailwind. This is done with the @theme
directive. It tells Tailwind, "Hey, when you see bg-primary
, I want you to use my --primary
variable."
/* src/index.css (at the bottom) */
@theme {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-primary: var(--primary);
--color-muted: var(--muted);
--color-border: var(--border);
}
The useTheme
Hook
With my CSS engine ready, I needed a way to control it from my React app. I created a custom hook, useTheme
, to act as the central brain for all theme-related logic.
Its jobs are simple but crucial:
Keep track of the current theme (
light
/dark
) and accent color in a React state.Read the user’s last choice from
localStorage
so the theme persists.Apply or remove the correct classes (
.dark
,.theme-green
) on the<html>
element whenever the state changes.
// src/hooks/useTheme.js
import { useState, useEffect } from 'react';
export const useTheme = () => {
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'light');
const [accent, setAccent] = useState(() => localStorage.getItem('accent') || 'theme-blue');
useEffect(() => {
const root = window.document.documentElement;
// Handle light/dark mode
root.classList.remove('light', 'dark');
root.classList.add(theme);
localStorage.setItem('theme', theme);
// Handle accent color
root.classList.remove('theme-blue', 'theme-green'); // Add any other themes here
root.classList.add(accent);
localStorage.setItem('accent', accent);
}, [theme, accent]);
const toggleTheme = () => {
setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'));
};
const changeAccent = (newAccent) => {
setAccent(newAccent);
};
return { theme, toggleTheme, accent, changeAccent };
};
This hook gave me a clean API — toggleTheme()
and changeAccent()
—that I could use anywhere in my app.
Building an Interface
Finally, I wanted to build a UI that truly showcased the power of this system. I designed a simple “Appearance” settings page. Instead of basic buttons, I created custom, interactive components.
The ThemeOption
component is a clickable card that shows a color swatch of the theme and a checkmark when it’s selected.
Here’s a glimpse of the final App.jsx
, where I put everything together:
// src/App.jsx
import { useTheme } from './hooks/useTheme';
import { ThemeOption } from './components/ThemeOption';
// ... other imports
function App() {
const { theme, toggleTheme, accent, changeAccent } = useTheme();
return (
<div className="min-h-screen bg-background text-foreground ...">
<div className="text-center mb-12">
<h1 className="text-5xl font-extrabold ...">Appearance</h1>
<p className="mt-3 text-lg text-muted-foreground ...">
Customize the look and feel of your interface.
</p>
</div>
<div className="rounded-lg border border-border bg-card ...">
{/* Theme Mode Setting */}
<div className="...">
{/* ... UI for toggling light/dark mode ... */}
</div>
{/* Accent Color Setting */}
<div className="...">
<h3 className="text-xl font-semibold ...">Accent Color</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<ThemeOption
themeName="Blue"
accentColor="#60A5FA"
isSelected={accent === 'theme-blue'}
onSelect={() => changeAccent('theme-blue')}
/>
<ThemeOption
themeName="Green"
accentColor="#22C55E"
isSelected={accent === 'theme-green'}
onSelect={() => changeAccent('theme-green')}
/>
</div>
</div>
</div>
</div>
);
}
The result was a clean, responsive, and intuitive settings page where users can instantly see their changes reflected across the entire application.
App Screenshots
Final Thoughts
By leveraging native CSS variables, I created a theming system that is not only scalable and maintainable but also incredibly performant. If you’re starting a new project, I highly encourage you to explore this workflow. It might just change the way you think about styling your applications.
The combination of Vite’s speed, React’s component model, and Tailwind v4’s CSS-first approach makes building complex, dynamic user interfaces more enjoyable than ever.
You can check out the full source code on my GitHub here 👇:
https://github.com/praveen-sripati/tailwind-theme-app
Thanks for reading, and feel free to ask any questions in the comments!
Happy theming!
Want to see a random mix of weekend projects, half-baked ideas, and the occasional useful bit of code? Feel free to follow me on Twitter! https://x.com/praveen_sripati
Top comments (0)