Kit ships with 9 pre-built color themes that change the entire look of your application through a single
COLOR_THEME environment variable. Each theme provides both light and dark mode variants, powered by next-themes for system preference detection and flicker-free theme switching.This page covers theme selection, dark mode, CSS custom properties, and creating your own themes. For component styling, see UI Components. For Tailwind configuration, see Customization.
How It Works
The theme system flows from an environment variable through CSS custom properties to Tailwind utility classes:
COLOR_THEME env variable
|
v
getActiveTheme() — validates env var, falls back to 'default'
|
v
data-theme="ocean" on <html> — set in root layout at build time
|
v
CSS selectors [data-theme='ocean'] — activate the matching theme CSS
|
v
CSS Custom Properties (--primary, --background, ...)
|
v
Tailwind classes (bg-primary, text-foreground, ...)
|
v
Dark mode toggle — adds .dark class → [data-theme='ocean'].dark selectors
The color theme (which palette) and the dark mode (light vs dark variant) are independent systems. Switching from
ocean to forest changes the color palette. Toggling dark mode switches between the light and dark variants of whichever theme is active.The theme system is pure CSS — no JavaScript runs to switch color palettes. The
data-theme attribute is set at build time in the root layout, and CSS selectors do the rest. This means zero runtime overhead, no layout shift, and no flash of unstyled content. All 9 themes are designed with WCAG-compliant contrast ratios between foreground and background colors in both light and dark variants.Available Themes
The theme registry defines all 9 available themes:
src/styles/themes/themes.ts — Available Themes
export const AVAILABLE_THEMES = [
'default',
'ocean',
'forest',
'sunset',
'midnight',
'coral',
'slate',
'aurora',
'crimson',
] as const
Each theme is designed for a specific use case:
| Theme | Color | Best For |
|---|---|---|
default | Blue | Professional SaaS, general-purpose applications |
ocean | Teal | Maritime, tech startups, developer tools |
forest | Green | Eco-friendly, sustainability, health & wellness |
sunset | Orange | Creative tools, design platforms, marketplaces |
midnight | Purple | Premium enterprise, fintech, luxury products |
coral | Pink | Consumer apps, social platforms, lifestyle brands |
slate | Gray | Business tools, B2B platforms, analytics dashboards |
aurora | Cyan | Tech platforms, AI products, cutting-edge startups |
crimson | Red | Performance tools, analytics, gaming platforms |
Switching Themes
To change your application's theme, set the
COLOR_THEME environment variable in your apps/boilerplate/.env.local:bash
# apps/boilerplate/.env.local
COLOR_THEME=ocean
The
getActiveTheme() function reads this variable and validates it against the list of available themes. If the value is missing or invalid, it falls back to default:src/styles/themes/themes.ts — getActiveTheme()
export function getActiveTheme(): ThemeName {
const envTheme =
process.env.COLOR_THEME || process.env.NEXT_PUBLIC_COLOR_THEME
if (envTheme && AVAILABLE_THEMES.includes(envTheme as ThemeName)) {
return envTheme as ThemeName
}
// Fallback to default theme
return 'default'
}
The root layout calls this function and applies the result as a
data-theme attribute on the <html> element:src/app/layout.tsx — Theme Application
// Get active color theme from environment variable
const colorTheme = getActiveTheme()
Since
COLOR_THEME is a server-side environment variable, you need to restart your dev server after changing it. Run pnpm dev:boilerplate again to see the new theme.Dark Mode
Dark mode is handled separately from the color theme. It uses next-themes to toggle a
.dark class on the <html> element, which activates the dark variant of the current color theme.ThemeProvider
The
ThemeProvider wraps the application and manages dark mode state. It detects system preference, persists user choice to localStorage, and applies the .dark class:src/providers/theme-provider.tsx
'use client'
import * as React from 'react'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem={true}
disableTransitionOnChange
{...props}
>
{children}
</NextThemesProvider>
)
}
The provider is configured in the root layout inside the provider hierarchy:
src/app/layout.tsx — Provider Hierarchy with ThemeProvider
const content = (
<html lang="en" suppressHydrationWarning data-theme={colorTheme}>
<body className={`${inter.className} ${jetbrainsMono.variable}`}>
<MSWProvider>
<QueryProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<DemoProvider>
{children}
<Toaster richColors position="top-center" duration={8000} />
{process.env.VERCEL_ENV === 'production' && <Analytics />}
{process.env.VERCEL_ENV === 'production' && <SpeedInsights />}
</DemoProvider>
</ThemeProvider>
</QueryProvider>
</MSWProvider>
</body>
</html>
)
ThemeToggle
The
ThemeToggle component provides a sun/moon button for users to switch between light and dark modes:packages/ui/src/theme-toggle.tsx — Dark Mode Toggle
'use client'
import * as React from 'react'
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from './button'
export function ThemeToggle() {
// HYDRATION-FIX: Use resolvedTheme instead of theme
// `theme` can be 'system' which doesn't tell us the actual theme
// `resolvedTheme` always returns 'light' or 'dark' after hydration
const { resolvedTheme, setTheme } = useTheme()
const [mounted, setMounted] = React.useState(false)
// useEffect only runs on the client, so now we can safely show the UI
React.useEffect(() => {
setMounted(true)
}, [])
const toggleTheme = () => {
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
}
if (!mounted) {
return (
<Button variant="ghost" size="icon" disabled className="border-0 bg-transparent text-foreground/70 hover:bg-transparent hover:text-foreground">
<Moon className="h-[1.2rem] w-[1.2rem]" />
<span className="sr-only">Toggle theme</span>
</Button>
)
}
return (
<Button variant="ghost" size="icon" onClick={toggleTheme} className="border-0 bg-transparent text-foreground/70 hover:bg-transparent hover:border-0 hover:text-foreground">
{resolvedTheme === 'dark' ? (
<Moon className="transition-all" />
) : (
<Sun className="transition-all" />
)}
<span className="sr-only">Toggle theme</span>
</Button>
)
}
Key implementation details:
resolvedThemeis used instead ofthemebecausethemecan return'system'which doesn't tell you the actual resolved modemountedstate prevents hydration mismatch — the toggle renders a disabled placeholder during SSR and only shows the interactive version after client-side hydration- The toggle calls
setTheme()from next-themes, which adds or removes the.darkclass on<html>
When
defaultTheme is set to "system" and enableSystem is true, the application automatically matches the user's OS preference (light/dark). Users can override this with the toggle, and their choice is persisted in localStorage.CSS Custom Properties
Each theme defines its colors as CSS custom properties using HSL values without the
hsl() wrapper. This is the foundation that makes the entire color system work with Tailwind's opacity modifiers.Here is the default theme's light mode variables:
src/styles/themes/default.css — Light Mode Variables
[data-theme='default'] {
/* Light Mode */
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 221 83% 53%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221 83% 53%;
--gradient-start: 221 83% 53%;
--gradient-end: 199 89% 48%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
/* Status Colors */
--success: 142 76% 36%;
--success-foreground: 0 0% 100%;
--warning: 32 95% 44%;
--warning-foreground: 0 0% 100%;
--info: 199 89% 48%;
--info-foreground: 0 0% 100%;
/* Footer Colors */
--footer-background: 222 47% 11%;
--footer-foreground: 210 40% 98%;
}
And the dark mode variant:
src/styles/themes/default.css — Dark Mode Variables
[data-theme='default'].dark {
/* Dark Mode */
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--gradient-start: 217 91% 60%;
--gradient-end: 199 89% 48%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
/* Status Colors */
--success: 142 76% 50%;
--success-foreground: 0 0% 100%;
--warning: 32 95% 54%;
--warning-foreground: 0 0% 100%;
--info: 199 89% 58%;
--info-foreground: 0 0% 100%;
/* Footer Colors */
--footer-background: 222 84% 3%;
--footer-foreground: 210 40% 98%;
}
Variable Reference
| Variable | Purpose | Example Usage |
|---|---|---|
--background | Page background | bg-background |
--foreground | Primary text color | text-foreground |
--card | Card/surface background | bg-card |
--card-foreground | Text on cards | text-card-foreground |
--primary | Brand/accent color | bg-primary, text-primary |
--primary-foreground | Text on primary backgrounds | text-primary-foreground |
--secondary | Secondary elements | bg-secondary |
--muted | Subtle backgrounds | bg-muted |
--muted-foreground | Subdued text | text-muted-foreground |
--accent | Hover/focus highlights | bg-accent |
--destructive | Error/danger states | bg-destructive |
--border | Borders and dividers | border-border |
--input | Form input borders | border-input |
--ring | Focus ring color | ring-ring |
--gradient-start / --gradient-end | Brand gradient | bg-gradient-brand |
--chart-1 through --chart-5 | Chart/data visualization | Chart components |
--radius | Base border radius | rounded-lg, rounded-md |
CSS variables use raw HSL values like
221 83% 53% — not hsl(221, 83%, 53%). This is required for Tailwind's opacity modifier syntax to work. When you write bg-primary/50, Tailwind generates hsl(221 83% 53% / 0.5). If you wrap the value in hsl(), opacity modifiers will break.Creating a Custom Theme
1
Create a CSS file
Add a new file in
apps/boilerplate/src/styles/themes/ with your theme's color variables. Use the default theme as a starting point:css
/* apps/boilerplate/src/styles/themes/ruby.css */
[data-theme='ruby'] {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--primary: 346 77% 49%;
--primary-foreground: 0 0% 98%;
/* ... define all variables */
}
[data-theme='ruby'].dark {
--background: 346 30% 6%;
--foreground: 0 0% 98%;
--primary: 346 77% 59%;
--primary-foreground: 0 0% 9%;
/* ... define all dark mode variables */
}
2
Register the theme
Add your theme name to the
AVAILABLE_THEMES array and THEME_METADATA object in apps/boilerplate/src/styles/themes/themes.ts:typescript
export const AVAILABLE_THEMES = [
'default',
'ocean',
// ... existing themes
'ruby', // Add your new theme
] as const
3
Import in globals.css
Add the import at the top of
apps/boilerplate/src/app/globals.css, alongside the other theme imports:css
@import '../styles/themes/ruby.css';
4
Set the environment variable
Update your
apps/boilerplate/.env.local to use the new theme:bash
COLOR_THEME=ruby
Restart your dev server and the new theme is active.
Key Files
| File | Purpose |
|---|---|
apps/boilerplate/src/styles/themes/themes.ts | Theme registry — available themes, metadata, getActiveTheme() |
apps/boilerplate/src/styles/themes/default.css | Default blue theme CSS variables (light + dark) |
apps/boilerplate/src/styles/themes/*.css | All 9 theme CSS files (ocean, forest, sunset, etc.) |
apps/boilerplate/src/app/layout.tsx | Root layout — applies data-theme attribute to <html> |
apps/boilerplate/src/providers/theme-provider.tsx | ThemeProvider wrapper for next-themes |
packages/ui/src/theme-toggle.tsx | Dark mode toggle button component |
apps/boilerplate/src/app/globals.css | Global styles — imports all theme CSS files |