← All posts
May 17, 2025·5 min read

Adding Multiple Color Themes on Top of next-themes

How to have a color theme system on top of next-themes to support multiple palettes alongside the standard light/dark toggle.

nextjsreactnext-themestypescriptcss
Adding Multiple Color Themes on Top of next-themes

Most theming setups stop at light and dark mode. You wrap your app in next-themes, toggle a class on <html>, and call it done. But I wanted to go further — multiple named color themes, each with its own palette, all working alongside the existing light/dark toggle. Think the kind of theme picker you see in shadcn's registry or VS Code.

A lot of the inspiration here came from tweakcn, a tool for generating and customizing shadcn/ui themes. I used it to generate the actual color values for each of my themes — it outputs the CSS variables directly so you don't have to hand-tune every token. Well worth bookmarking if you're building anything with shadcn.

The challenge is that next-themes is only designed for the light/dark toggle. It has no concept of a separate color dimension. So I built a thin layer on top of it.

Two Separate Toggles

The key insight is that light/dark and color theme are two separate things that don't need to know about each other:

  • Light/dark — controls whether backgrounds are light or dark. Managed by next-themes, stored in localStorage under theme, applied as the dark class on <html>.
  • Color theme — controls the hue palette (primary, accent, border colors). Managed manually, stored under color-theme, applied as a theme-* class on <html>.

Because they're separate classes on the same element, they compose naturally. A user can be in dark mode with the supabase color theme, or light mode with bubblegum, and both classes coexist without conflict:

<html class="dark theme-supabase"></html>

Defining Color Themes in CSS

Each theme overrides a set of CSS custom properties. The default theme doesn't need a class — it's just the baseline variables defined on :root. Every other theme scopes its overrides under its class. I generated all of these color values using tweakcn, which lets you visually design a shadcn theme and then exports the full set of CSS variable overrides:

:root {
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0.002 247.839);
--accent: oklch(0.967 0.001 286.375);
/* ... */
}
.theme-bubblegum {
--primary: oklch(0.65 0.25 350);
--primary-foreground: oklch(0.98 0.01 350);
--accent: oklch(0.92 0.08 340);
/* ... */
}
.theme-supabase {
--primary: oklch(0.72 0.19 155);
--primary-foreground: oklch(0.98 0.01 155);
--accent: oklch(0.9 0.06 155);
/* ... */
}

Because Tailwind's utility classes reference these custom properties (e.g. bg-primary maps to var(--primary)), swapping the theme class instantly re-skins every component that uses semantic color tokens. No component-level changes needed.

The useTheme Hook

Rather than scattering theme logic across the app, I wrapped both toggles into a single useTheme hook that replaces the direct useTheme import from next-themes:

'use client';
import * as React from 'react';
import { useTheme as useNextTheme } from 'next-themes';
export const colorThemes = [
Add your own theme names here
'default',
'daggerheart-brews',
'bubblegum',
'monochrome',
'supabase',
'twitter',
'vercel',
] as const;
export type ColorTheme = (typeof colorThemes)[number];
export type Theme = 'system' | 'light' | 'dark';
export const useTheme = () => {
const { setTheme, resolvedTheme } = useNextTheme();
const [colorTheme, setColorTheme] = React.useState<ColorTheme>('default');
React.useEffect(() => {
if (typeof window === 'undefined') return;
const saved = localStorage.getItem('color-theme') as ColorTheme | null;
if (saved) {
setColorTheme(saved);
document.documentElement.classList.add(`theme-${saved}`);
}
}, []);
const updateColorTheme = React.useCallback((next: ColorTheme) => {
if (typeof window === 'undefined') return;
document.documentElement.classList.remove(
...colorThemes.map((c) => `theme-${c}`),
);
if (next !== 'default') {
document.documentElement.classList.add(`theme-${next}`);
}
localStorage.setItem('color-theme', next);
setColorTheme(next);
}, []);
return {
setTheme,
theme: resolvedTheme as Theme,
colorTheme,
setColorTheme: updateColorTheme,
};
};

A few things worth noting:

Initialization reads from localStorage on mount. The useState starts as 'default' to avoid a hydration mismatch (the server doesn't know what's in localStorage), and then the useEffect corrects it client-side and applies the class. This is the same pattern next-themes itself uses.

Switching clears all theme classes before adding the new one. This is important — if you just add the new class without removing the old one, both palettes stack and the result is undefined. Removing all known theme classes first ensures a clean transition.

'default' doesn't get a class added. Since the default palette is defined on :root with no class, adding theme-default would do nothing — and skipping it means we don't need to define a .theme-default selector just to reset to the baseline.

Building a Theme Picker

With the hook in place, a theme picker is straightforward:

const ThemePicker = () => {
const { colorTheme, setColorTheme } = useTheme();
return (
<div className='flex flex-wrap gap-2'>
{colorThemes.map((t) => (
<button
key={t}
onClick={() => setColorTheme(t)}
className={cn(
'rounded-full border px-3 py-1 text-sm capitalize transition-colors',
colorTheme === t
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background text-muted-foreground hover:border-primary',
)}
>
{t}
</button>
))}
</div>
);
};

Because the component uses semantic tokens (bg-primary, text-primary-foreground), it automatically re-skins itself when the theme changes — no special casing needed.

Adding a New Theme

The process for adding a new theme is:

  1. Add the name to the colorThemes array in the hook
  2. Define .theme-<name> in your CSS with overrides for whatever variables you want to change

That's it. The hook, the picker, and the persistence all work without any changes. I find this to be one of the cleanest parts of the approach — the theme registry is just a TypeScript const array, so it's type-safe and easy to extend.

Why Not a Theme Provider?

You could wrap this in a context provider to avoid prop-drilling the hook values, but in practice I haven't needed it. useTheme is cheap — it reads from a React state that's co-located with the localStorage effect — and the hook can be called in any component that needs it without meaningful overhead. If your app has deeply nested theme-aware components, a provider is a reasonable addition, but for most cases the hook is enough.

The full hook is available in my registry if you want to drop it directly into your project.