DEV Community

Cover image for Building a Theme System with Next.js 15 and Tailwind CSS v4 (Without dark: Prefix)
mukitaro
mukitaro

Posted on

Building a Theme System with Next.js 15 and Tailwind CSS v4 (Without dark: Prefix)

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]">
Enter fullscreen mode Exit fullscreen mode

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)│
└─────────────────┘     └──────────────┘     └─────────────┘
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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:

  1. Add to the type:
export type SiteColors = {
  // ... existing colors
  warning: string;
  warningForeground: string;
};
Enter fullscreen mode Exit fullscreen mode
  1. 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",
Enter fullscreen mode Exit fullscreen mode
  1. Add to applySiteTheme:
root.style.setProperty("--warning", site.warning);
root.style.setProperty("--warning-foreground", site.warningForeground);
Enter fullscreen mode Exit fullscreen mode
  1. Add to @theme:
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
Enter fullscreen mode Exit fullscreen mode
  1. Use it:
<div className="bg-warning text-warning-foreground">
  Warning message
</div>
Enter fullscreen mode Exit fullscreen mode

Why This Approach Works

  1. No build-time variants - Colors are runtime CSS variables, not compiled classes
  2. Type-safe themes - TypeScript ensures all themes have all required colors
  3. Easy to add themes - Just add a new object to EDITOR_THEMES
  4. Works with Tailwind v4 - @theme inline makes CSS variables available as utilities
  5. Instant switching - No page reload, colors update immediately
  6. 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)