Payload CMSDark ModeThemesCSSUser Experience

Implementing Dark Mode Themes in Payload Projects

Learn how to implement beautiful dark mode themes in your Payload CMS projects, including admin interface customization and frontend theme switching.

ByDevelopment Team
7 min read

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.

Tagged with:Payload CMSDark ModeThemesCSSUser Experience
More Articles