Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for multiple attributes (more in depth theme management) #236

Open
DarioCorbinelli opened this issue Nov 14, 2023 · 2 comments
Open

Comments

@DarioCorbinelli
Copy link

It would be great if we could manage multiple attributes controlling different aspects of the theming separately.

An example could be a theme controller component that lets you separately choose the theme (meaning the color palette) and the mode (light, dark o system).

Look shadcn.ui/themes for instance.

It would be enough having the possibility to declare two different attributes (for instance [data-theme="twitter"] and [data-mode="system"]) on the html element to have them both.

This would make building themable web apps and websites using next.js far more easy.

@ajayvignesh01
Copy link

ajayvignesh01 commented Apr 20, 2024

Was able to achieve theme + mode with data-theme attribute using this library, but had to make so many changes that I ended up getting rid of next-themes all together. Here's my current implementation on Nextjs 14.2.1 with tailwind, shadcn ui, and zustand:

✅ Perfect dark mode in 2 lines of code
✅ System setting with prefers-color-scheme
✅ Themed browser UI with color-scheme
✅ Support for Next.js 13 appDir
✅ No flash on load (both SSR and SSG)
✅ Sync theme across tabs and windows
❌ Disable flashing when changing themes - check disableAnimation function to implement
❌ Force pages to specific themes - not implemented
❌ Class or data attribute selector - only data-theme
✅ useTheme hook
Other things missing as well, but this is the general gist

theme.tsx - Replacement for <ThemeProvider> <ThemeProvider />. Add this to your layout.tsx, then set defaultTheme & defaultMode.

'use client'

import { useThemeStore } from '@/lib/stores/use-theme-store'
import { useEffect } from 'react'

interface ThemeProps {
  defaultTheme?: string
  defaultMode?: 'light' | 'dark' | 'system'
}

export function Theme({ defaultTheme = 'light', defaultMode = 'system' }: ThemeProps) {
  const { system, mode, setMode } = useThemeStore()

  // system theme listener
  useEffect(() => {
    function onChange(event: MediaQueryListEvent | MediaQueryList) {
      // !system or !mode or system and ui in same mode
      if (!system || !mode || event.matches === (mode === 'dark')) return
      // system switched to dark mode, ui in light mode
      else if (event.matches) setMode('dark')
      // system switched to light mode, ui in dark mode
      else if (!event.matches) setMode('light')
    }

    const result = matchMedia('(prefers-color-scheme: dark)')
    result.addEventListener('change', onChange)

    return () => result.removeEventListener('change', onChange)
  }, [mode, setMode, system])

  return (
    <script
      suppressHydrationWarning
      dangerouslySetInnerHTML={{
        __html: `(${setInitialTheme.toString()})(${JSON.stringify({ defaultTheme, defaultMode })})`
      }}
    />
  )
}

interface setInitialThemeProps {
  defaultTheme: string
  defaultMode: 'light' | 'dark' | 'system'
}

type Theme = {
  state: {
    theme: string
    mode: 'light' | 'dark'
    system: boolean
  }
  version: number
}

function setInitialTheme({ defaultTheme, defaultMode }: setInitialThemeProps) {
  const cache = localStorage.getItem('themeStore')

  const defaultSystem = defaultMode === 'system'
  const defaultModeResolved = defaultSystem
    ? matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark'
      : 'light'
    : defaultMode

  const themeStore: Theme = cache
    ? JSON.parse(cache)
    : {
        state: { theme: defaultTheme, mode: defaultModeResolved, system: defaultSystem },
        version: 0
      }

  const theme = themeStore.state.theme
  const system = themeStore.state.system
  const mode = system
    ? matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark'
      : 'light'
    : themeStore.state.mode

  document.documentElement.setAttribute('data-theme', `${mode}_${theme}`)
  document.documentElement.style.colorScheme = mode

  if (!cache) localStorage.setItem('themeStore', JSON.stringify(themeStore))
}

use-theme-store.tsx - Replacement for useTheme().

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

type ThemeStore = {
  theme: string | undefined
  setTheme: (value: string) => void
  mode: 'light' | 'dark' | undefined
  setMode: (value: 'light' | 'dark') => void
  system: boolean | undefined
  setSystem: (value: boolean) => void
}

export const useThemeStore = create(
  persist<ThemeStore>(
    (set, get) => ({
      theme: undefined,
      setTheme: (theme) => {
        const mode = get().mode!

        document.documentElement.setAttribute('data-theme', `${mode}_${theme}`)
        document.documentElement.style.colorScheme = mode

        set(() => ({ theme: theme }))
      },

      mode: undefined,
      setMode: (mode) => {
        const theme = get().theme

        document.documentElement.setAttribute('data-theme', `${mode}_${theme}`)
        document.documentElement.style.colorScheme = mode

        set(() => ({ mode: mode }))
      },

      system: undefined,
      setSystem: (system) => {
        const isDark = matchMedia('(prefers-color-scheme: dark)').matches
        const mode = isDark ? 'dark' : 'light'
        const theme = get().theme!

        document.documentElement.setAttribute('data-theme', `${mode}_${theme}`)
        document.documentElement.style.colorScheme = mode

        set(() => ({ system: system, mode: mode }))
      }
    }),
    {
      name: 'themeStore'
    }
  )
)

global.css - Example file. Prefix light mode themes with light_, and dark mode themes with dark_.

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: 0 0% 100%; /* white */
    --foreground: 240 10% 3.9%; /* zinc-950 */

    /* remaining styles */
  }

  html[data-theme='dark_zinc'] {
    --background: 240 10% 3.9%; /* zinc-950 */
    --foreground: 0 0% 98%; /* zinc-50 */

    /* remaining styles */
  }
  
  html[data-theme='light_slate'] {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;

    /* remaining styles */
  }

  html[data-theme='dark_slate'] {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;

    /* remaining styles */
  }
  
}

tailwind.config.ts - Add ['selector', '[data-theme^="dark"]'] to darkMode.

import type { Config } from 'tailwindcss'

const config = {
  darkMode: ['selector', '[data-theme^="dark"]'],
  content: ['./components/**/*.{ts,tsx}', './app/**/*.{ts,tsx}'],
  prefix: '',
  theme: {
    extend: {
      colors: {
        background: 'hsl(var(--background))',
        foreground: 'hsl(var(--foreground))',
        
        // remaining config
      },

      // remaining config
    }
  }
} satisfies Config

export default config

To change the theme, mode or system settings, you can do something like this:

const { theme, setTheme, mode, setMode, system, setSystem } = useThemeStore()

setTheme('slate')

// or
setMode('dark')

// or
setSystem(false)

@trm217
Copy link
Collaborator

trm217 commented May 9, 2024

I think this could easily be solved by simply allowing multiple ThemeProviders to exists.
If we used a factory for creating the ThemeProvider, we could look into generating unique theme-context, and thus handle many different theming requirements within the same page all with next-themes. Might be interesting for v1.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants