Logo

Setting Up Dark Theme with Emotion & Next.js

css in 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}
4
5export const darkTheme = {
6 ...commonTheme,
7 background: 'hsl(210deg, 30%, 8%)',
8 text: 'hsl(0deg, 0%, 100%)',
9}
10
11export 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'
2
3declare module '@emotion/react' {
4 export interface Theme {
5 background: string
6 text: string
7 primary: string
8 }
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)').matches
6
7 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'
6
7export type ThemeContext = {
8 theme: 'light' | 'dark'
9 setTheme: (theme: 'light' | 'dark') => void
10}
11
12function MyApp({ Component, pageProps }: AppProps) {
13 const [theme, setTheme] = useState<ThemeContext['theme']>('dark')
14
15 useEffect(() => {
16 const savedTheme = localStorage.getItem('theme')
17 const prefersLight =
18 window.matchMedia &&
19 window.matchMedia('(prefers-color-scheme: light)').matches
20
21 if (savedTheme) {
22 setTheme(savedTheme === 'dark' ? 'dark' : 'light')
23 } else if (prefersLight) {
24 setTheme('light')
25 }
26 }, [])
27
28 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}
37
38export 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'
2
3const Text = styled.span`
4 color: ${({ theme }) => theme.text};
5`
6
7export 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'
2
3export const ThemeToggler = ({ theme, setTheme }: ThemeContext) => {
4 const toggleTheme = () => {
5 const newTheme = theme === 'light' ? 'dark' : 'light'
6 setTheme(newTheme)
7 localStorage.setItem('theme', newTheme)
8 }
9
10 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.