Light and dark mode is one axis. Many apps want a second: a choice of color themes (say "Ocean", "Forest" and "Rose"), each with its own light and dark palette. Since shadcn/ui reads everything from CSS variables, you can scope a full theme to an attribute and switch it at runtime without rebuilding anything.
1. Scope each theme to a data attribute
Copy the CSS for each theme you want from its page on this site, then rename the selectors. Keep your default theme on :root and .dark, and scope the extras:
/* Default theme */
:root { --primary: oklch(0.205 0 0); /* … */ }
.dark { --primary: oklch(0.922 0 0); /* … */ }
/* Ocean */
[data-theme="ocean"] { --primary: oklch(0.6 0.15 230); /* …all light tokens… */ }
.dark[data-theme="ocean"],
.dark [data-theme="ocean"] { --primary: oklch(0.72 0.13 230); /* …all dark tokens… */ }
/* Rose */
[data-theme="rose"] { --primary: oklch(0.62 0.2 10); /* … */ }
.dark[data-theme="rose"],
.dark [data-theme="rose"] { --primary: oklch(0.72 0.17 10); /* … */ }
The @theme inline block stays the same: it maps utilities to variables, and the variables now change with the attribute.
The .dark[data-theme] selector covers the usual setup where both the class and the attribute sit on <html>. The
descendant version lets you scope a theme to part of the page, such as a preview pane.
2. Store the user's choice
Keep light/dark in next-themes as usual, and manage the color theme with a tiny hook that writes the attribute and remembers it:
"use client"
import { useEffect, useState } from "react"
export const COLOR_THEMES = ["default", "ocean", "rose"] as const
export type ColorTheme = (typeof COLOR_THEMES)[number]
export function useColorTheme() {
const [theme, setTheme] = useState<ColorTheme>("default")
useEffect(() => {
const saved = localStorage.getItem("color-theme") as ColorTheme | null
if (saved && COLOR_THEMES.includes(saved)) setTheme(saved)
}, [])
useEffect(() => {
const root = document.documentElement
if (theme === "default") root.removeAttribute("data-theme")
else root.setAttribute("data-theme", theme)
localStorage.setItem("color-theme", theme)
}, [theme])
return { theme, setTheme }
}
3. Avoid a flash on load
The hook runs after hydration, so a returning visitor would briefly see the default theme. Set the attribute before paint with a small inline script in your root layout:
<head>
<script
dangerouslySetInnerHTML={{
__html: `try{var t=localStorage.getItem("color-theme");if(t&&t!=="default")document.documentElement.setAttribute("data-theme",t)}catch(e){}`,
}}
/>
</head>
4. Build the picker
"use client"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { COLOR_THEMES, useColorTheme, type ColorTheme } from "@/hooks/use-color-theme"
export function ThemePicker() {
const { theme, setTheme } = useColorTheme()
return (
<Select value={theme} onValueChange={(v) => setTheme(v as ColorTheme)}>
<SelectTrigger className="w-36" aria-label="Color theme">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLOR_THEMES.map((t) => (
<SelectItem key={t} value={t} className="capitalize">
{t}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
Ideas
- Per-customer branding in a multi-tenant app: render
data-theme={tenant.theme}on a wrapper on the server, with no client code at all. - Preview before applying: scope a theme to a card with
data-theme, the same trick this site uses for its previews. - Fonts and radius can be scoped the same way, since they're variables too.
Find themes to offer in the gallery or the collections, or design your own in the editor.