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:
:rootholds the default (light theme) variables.The
.darkclass overrides these variables when it's applied to the<html>tag.Other classes like
.theme-greencan be applied alongside.darkor.lightto specifically change the--primarycolors, 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
localStorageso 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)