Implementing Dark Mode Themes in Payload Projects
Dark mode has become an essential feature for modern web applications, reducing eye strain and providing a sleek, professional appearance. In Payload CMS projects, implementing dark mode involves both customizing the admin interface and creating theme-aware frontends. This guide covers everything you need to know to implement dark mode in your Payload projects.
Understanding Payload's Admin Theming
Payload CMS comes with a built-in admin interface that can be customized with your own themes. The admin uses CSS custom properties, making it easy to create dark mode variants.
Default Admin Theme Structure
:root {
--theme-bg: #fafafa;
--theme-elevation-50: #ffffff;
--theme-elevation-100: #f5f5f5;
--theme-elevation-150: #eeeeee;
--theme-text: #333333;
--theme-text-dim: #666666;
}
Customizing Admin Interface for Dark Mode
1. Creating Custom Admin Styles
First, create custom styles for the admin interface:
// styles/admin.scss
:root {
// Light theme variables
--theme-bg: #ffffff;
--theme-elevation-50: #fafafa;
--theme-elevation-100: #f5f5f5;
--theme-text: #1a1a1a;
--theme-success: #22c55e;
--theme-warning: #f59e0b;
--theme-error: #ef4444;
}
[data-theme="dark"] {
// Dark theme variables
--theme-bg: #0a0a0a;
--theme-elevation-50: #1a1a1a;
--theme-elevation-100: #2a2a2a;
--theme-text: #ffffff;
--theme-success: #16a34a;
--theme-warning: #d97706;
--theme-error: #dc2626;
}
// Custom admin components
.payload-admin {
.nav {
background: var(--theme-elevation-50);
border-color: var(--theme-elevation-100);
}
.card {
background: var(--theme-elevation-50);
border-color: var(--theme-elevation-100);
color: var(--theme-text);
}
}
2. Configuring Admin Bundle
// payload.config.ts
import { buildConfig } from 'payload/config'
import { webpackBundler } from '@payloadcms/bundler-webpack'
export default buildConfig({
admin: {
bundler: webpackBundler(),
css: './styles/admin.scss',
meta: {
titleSuffix: '- Admin',
favicon: '/favicon.ico',
},
},
// ... rest of config
})
3. Theme Toggle Component
Create a theme toggle component for the admin:
// components/admin/ThemeToggle.tsx
import React, { useState, useEffect } from 'react'
const ThemeToggle: React.FC = () => {
const [isDark, setIsDark] = useState(false)
useEffect(() => {
const stored = localStorage.getItem('admin-theme')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const initialDark = stored === 'dark' || (!stored && prefersDark)
setIsDark(initialDark)
document.documentElement.setAttribute('data-theme', initialDark ? 'dark' : 'light')
}, [])
const toggleTheme = () => {
const newTheme = !isDark
setIsDark(newTheme)
const themeValue = newTheme ? 'dark' : 'light'
document.documentElement.setAttribute('data-theme', themeValue)
localStorage.setItem('admin-theme', themeValue)
}
return (
<button
onClick={toggleTheme}
className="theme-toggle"
aria-label="Toggle theme"
>
{isDark ? '☀️' : '🌙'}
</button>
)
}
export default ThemeToggle
Frontend Dark Mode Implementation
1. Theme Provider Setup
// lib/contexts/ThemeContext.tsx
import React, { createContext, useContext, useEffect, useState } from 'react'
type Theme = 'light' | 'dark'
interface ThemeContextType {
theme: Theme
toggleTheme: () => void
setTheme: (theme: Theme) => void
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('light')
const [mounted, setMounted] = useState(false)
useEffect(() => {
const stored = localStorage.getItem('theme') as Theme
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const initialTheme = stored || (prefersDark ? 'dark' : 'light')
setTheme(initialTheme)
setMounted(true)
}, [])
useEffect(() => {
if (mounted) {
document.documentElement.className = theme
localStorage.setItem('theme', theme)
}
}, [theme, mounted])
const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light')
}
if (!mounted) {
return null
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within ThemeProvider')
}
return context
}
2. CSS Custom Properties for Themes
/* styles/themes.css */
:root {
--color-background: #ffffff;
--color-foreground: #000000;
--color-card: #ffffff;
--color-border: #e5e5e5;
--color-muted: #f5f5f5;
--color-muted-foreground: #737373;
--color-primary: #0066cc;
--color-secondary: #f1f5f9;
}
.dark {
--color-background: #0a0a0a;
--color-foreground: #ffffff;
--color-card: #1a1a1a;
--color-border: #262626;
--color-muted: #1a1a1a;
--color-muted-foreground: #a3a3a3;
--color-primary: #3b82f6;
--color-secondary: #1e293b;
}
/* Component styles using custom properties */
.card {
background-color: var(--color-card);
color: var(--color-foreground);
border-color: var(--color-border);
}
.button {
background-color: var(--color-primary);
color: var(--color-background);
}
3. Theme Toggle Component
// components/ThemeToggle.tsx
import React from 'react'
import { useTheme } from '@/lib/contexts/ThemeContext'
import { Button } from '@/components/ui/button'
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
return (
<Button
variant="ghost"
size="sm"
onClick={toggleTheme}
aria-label="Toggle theme"
className="w-10 h-10 p-0"
>
{theme === 'dark' ? (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
</Button>
)
}
Advanced Theme Management
1. Payload Collection for Theme Settings
// collections/ThemeSettings.ts
import { CollectionConfig } from 'payload/types'
export const ThemeSettings: CollectionConfig = {
slug: 'theme-settings',
labels: {
singular: 'Theme Setting',
plural: 'Theme Settings',
},
admin: {
useAsTitle: 'name',
},
fields: [
{
name: 'name',
type: 'text',
required: true,
},
{
name: 'colors',
type: 'group',
fields: [
{
name: 'primary',
type: 'text',
admin: {
components: {
Field: 'colorPicker',
},
},
},
{
name: 'secondary',
type: 'text',
admin: {
components: {
Field: 'colorPicker',
},
},
},
{
name: 'background',
type: 'text',
admin: {
components: {
Field: 'colorPicker',
},
},
},
],
},
{
name: 'typography',
type: 'group',
fields: [
{
name: 'fontFamily',
type: 'select',
options: [
{ label: 'Inter', value: 'Inter' },
{ label: 'Roboto', value: 'Roboto' },
{ label: 'Open Sans', value: 'Open Sans' },
],
},
{
name: 'fontSize',
type: 'select',
options: [
{ label: 'Small', value: 'small' },
{ label: 'Medium', value: 'medium' },
{ label: 'Large', value: 'large' },
],
},
],
},
],
}
2. Dynamic Theme Loading
// lib/theme-loader.ts
import { useEffect, useState } from 'react'
interface ThemeConfig {
colors: {
primary: string
secondary: string
background: string
}
typography: {
fontFamily: string
fontSize: string
}
}
export function useThemeConfig() {
const [themeConfig, setThemeConfig] = useState<ThemeConfig | null>(null)
useEffect(() => {
async function loadTheme() {
try {
const response = await fetch('/api/theme-settings')
const config = await response.json()
setThemeConfig(config)
// Apply theme to CSS custom properties
if (config) {
const root = document.documentElement
root.style.setProperty('--color-primary', config.colors.primary)
root.style.setProperty('--color-secondary', config.colors.secondary)
root.style.setProperty('--color-background', config.colors.background)
root.style.setProperty('--font-family', config.typography.fontFamily)
}
} catch (error) {
console.error('Failed to load theme config:', error)
}
}
loadTheme()
}, [])
return themeConfig
}
Best Practices for Dark Mode
1. Accessibility Considerations
// hooks/usePreferredTheme.ts
import { useEffect, useState } from 'react'
export function usePreferredTheme() {
const [prefersDark, setPrefersDark] = useState(false)
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
setPrefersDark(mediaQuery.matches)
const handleChange = (e: MediaQueryListEvent) => {
setPrefersDark(e.matches)
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [])
return prefersDark
}
2. Smooth Theme Transitions
/* styles/transitions.css */
* {
transition:
background-color 0.2s ease-in-out,
border-color 0.2s ease-in-out,
color 0.2s ease-in-out;
}
/* Disable transitions during theme change to prevent flashing */
.theme-transitioning * {
transition: none !important;
}
3. Theme Persistence
// lib/theme-persistence.ts
export class ThemeManager {
private static instance: ThemeManager
private theme: 'light' | 'dark' = 'light'
static getInstance(): ThemeManager {
if (!ThemeManager.instance) {
ThemeManager.instance = new ThemeManager()
}
return ThemeManager.instance
}
getTheme(): 'light' | 'dark' {
if (typeof window === 'undefined') return 'light'
const stored = localStorage.getItem('theme')
if (stored === 'light' || stored === 'dark') {
return stored
}
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
return prefersDark ? 'dark' : 'light'
}
setTheme(theme: 'light' | 'dark'): void {
this.theme = theme
if (typeof window !== 'undefined') {
localStorage.setItem('theme', theme)
document.documentElement.className = theme
}
}
toggleTheme(): void {
const newTheme = this.theme === 'light' ? 'dark' : 'light'
this.setTheme(newTheme)
}
}
Conclusion
Implementing dark mode in Payload CMS projects involves customizing both the admin interface and frontend components. By using CSS custom properties, React context, and proper theme management, you can create a seamless dark mode experience that respects user preferences and provides excellent usability.
Key takeaways:
- Use CSS custom properties for consistent theming
- Implement proper theme persistence with localStorage
- Respect user's system preferences
- Ensure smooth transitions between themes
- Consider accessibility in your theme implementation
Next: Learn how to manage global layouts, headers, footers, and SEO in Payload CMS projects.