DEV Community

Praveen Sripati
Praveen Sripati

Posted on

How I Built a Multi-Theme System using New Tailwind CSS v4 & React

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.

Screenshot of App

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Keep track of the current theme (light/dark) and accent color in a React state.

  2. Read the user’s last choice from localStorage so the theme persists.

  3. 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 };
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

The result was a clean, responsive, and intuitive settings page where users can instantly see their changes reflected across the entire application.

App Screenshots

Screenshot 1

Screenshot 2

Screenshot 3

Screenshot 4

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)