Setting Up Dark Theme with Emotion & Next.js
Define Theme Colors
Setting up light and dark theme palettes upfront makes it easier to manage and switch between them later. I usually keep a themes.ts
file to centralize color definitions.
1const commonTheme = {2 primary: 'hsl(245deg, 100%, 60%)',3}45export const darkTheme = {6 ...commonTheme,7 background: 'hsl(210deg, 30%, 8%)',8 text: 'hsl(0deg, 0%, 100%)',9}1011export const lightTheme = {12 ...commonTheme,13 background: 'hsl(0deg, 0%, 100%)',14 text: 'hsl(222deg, 22%, 5%)',15}
I’ve come to realize that using HSL values instead of hex makes tweaking themes much more intuitive — especially when adjusting lightness for dark mode.
More on that here.
Define the Theme Type
To get proper type safety with Emotion in a TypeScript setup, I extend the default theme declaration like this:
1import '@emotion/react'23declare module '@emotion/react' {4 export interface Theme {5 background: string6 text: string7 primary: string8 }9}
Detect User Preference
Respecting user preferences is key for a good UX. I want the theme to default to light mode if the user prefers it, but also allow them to switch manually.
I use localStorage
to save their choice, so it persists across sessions. If they haven’t chosen yet, I check their system preference using window.matchMedia
.
1useEffect(() => {2 const savedTheme = localStorage.getItem('theme')3 const prefersLight =4 window.matchMedia &&5 window.matchMedia('(prefers-color-scheme: light)').matches67 if (savedTheme) {8 setTheme(savedTheme === 'dark' ? 'dark' : 'light')9 } else if (prefersLight) {10 setTheme('light')11 }12}, [])
Add ThemeProvider to _app.tsx
Here’s the core of the setup. The theme logic wraps the app with both CacheProvider
and ThemeProvider
, which makes it available everywhere!
1import { useState, useEffect } from 'react'2import { ThemeProvider, CacheProvider } from '@emotion/react'3import { cache } from '@emotion/css'4import type { AppProps } from 'next/app'5import { lightTheme, darkTheme } from '@/components/Styles/themes'67export type ThemeContext = {8 theme: 'light' | 'dark'9 setTheme: (theme: 'light' | 'dark') => void10}1112function MyApp({ Component, pageProps }: AppProps) {13 const [theme, setTheme] = useState<ThemeContext['theme']>('dark')1415 useEffect(() => {16 const savedTheme = localStorage.getItem('theme')17 const prefersLight =18 window.matchMedia &&19 window.matchMedia('(prefers-color-scheme: light)').matches2021 if (savedTheme) {22 setTheme(savedTheme === 'dark' ? 'dark' : 'light')23 } else if (prefersLight) {24 setTheme('light')25 }26 }, [])2728 return (29 <CacheProvider value={cache}>30 <ThemeProvider theme={theme === 'dark' ? darkTheme : lightTheme}>31 <Component {...pageProps} />32 <ThemeToggler theme={theme} setTheme={setTheme} />33 </ThemeProvider>34 </CacheProvider>35 )36}3738export default MyApp
Use the Theme in Components
Styled components pick up the theme automatically, which is why defining keys like text
and background
helps make things more reusable.
1import styled from '@emotion/styled'23const Text = styled.span`4 color: ${({ theme }) => theme.text};5`67export const MyComponent = () => {8 return <Text>Hello, theme world!</Text>9}
Theme Toggle Button
This little button toggles the theme and saves the choice to localStorage
, so it persists across page reloads.
1import type { ThemeContext } from '@/pages/_app'23export const ThemeToggler = ({ theme, setTheme }: ThemeContext) => {4 const toggleTheme = () => {5 const newTheme = theme === 'light' ? 'dark' : 'light'6 setTheme(newTheme)7 localStorage.setItem('theme', newTheme)8 }910 return <button onClick={toggleTheme}>{theme === 'dark' ? '🌙' : '☀️'}</button>11}
I usually place it inside _app.tsx
or in the header layout so users can switch anytime.
Done!
With this setup, the theme switches instantly and respects both system and user preferences. Everything stays clean and predictable — plus it opens the door for adding smooth transitions or dark-mode-specific UI tweaks later.