Skip to content

Commit 3e4b8b8

Browse files
Implement Dark/Light Theme Toggle with Persistence and Accessibility (#38)
2 parents 00ce793 + 4fcf439 commit 3e4b8b8

4 files changed

Lines changed: 133 additions & 79 deletions

File tree

src/app/globals.css

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,39 @@
8181
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
8282
}
8383

84+
/* Dark mode variables */
85+
.dark {
86+
--background: #0a0a0a;
87+
--foreground: #ededed;
88+
--muted: #1a1a1a;
89+
--muted-foreground: #a1a1aa;
90+
--popover: #0a0a0a;
91+
--popover-foreground: #ededed;
92+
--card: #0a0a0a;
93+
--card-foreground: #ededed;
94+
--border: #27272a;
95+
--input: #27272a;
96+
--primary: #ededed;
97+
--primary-foreground: #0a0a0a;
98+
--secondary: #1a1a1a;
99+
--secondary-foreground: #ededed;
100+
--accent: #1a1a1a;
101+
--accent-foreground: #ededed;
102+
--destructive: #ef4444;
103+
--destructive-foreground: #ffffff;
104+
--success: #10b981;
105+
--success-foreground: #ffffff;
106+
--warning: #f59e0b;
107+
--warning-foreground: #ffffff;
108+
--ring: #ededed;
109+
110+
/* Dark mode shadows */
111+
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
112+
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
113+
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3);
114+
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.4), 0 8px 10px -6px rgb(0 0 0 / 0.3);
115+
}
116+
84117
@theme inline {
85118
--color-background: var(--background);
86119
--color-foreground: var(--foreground);
@@ -106,34 +139,6 @@
106139
--border-radius: var(--radius);
107140
}
108141

109-
@media (prefers-color-scheme: dark) {
110-
:root {
111-
--background: #0a0a0a;
112-
--foreground: #ededed;
113-
--muted: #1a1a1a;
114-
--muted-foreground: #a1a1aa;
115-
--popover: #0a0a0a;
116-
--popover-foreground: #ededed;
117-
--card: #0a0a0a;
118-
--card-foreground: #ededed;
119-
--border: #27272a;
120-
--input: #27272a;
121-
--primary: #ededed;
122-
--primary-foreground: #0a0a0a;
123-
--secondary: #1a1a1a;
124-
--secondary-foreground: #ededed;
125-
--accent: #1a1a1a;
126-
--accent-foreground: #ededed;
127-
--destructive: #ef4444;
128-
--destructive-foreground: #ffffff;
129-
--success: #10b981;
130-
--success-foreground: #ffffff;
131-
--warning: #f59e0b;
132-
--warning-foreground: #ffffff;
133-
--ring: #ededed;
134-
}
135-
}
136-
137142
body {
138143
background: var(--background);
139144
color: var(--foreground);

src/app/layout.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import "./globals.css";
44
import { Header } from "@/components/layout/header";
55
import { Footer } from "@/components/layout/footer";
66
import { SkipLinks } from "@/components/ui/skip-links";
7+
import { ThemeProvider } from "@/lib/theme-context";
78

89
const geistSans = Geist({
910
variable: "--font-geist-sans",
@@ -55,12 +56,14 @@ export default function RootLayout({
5556
<body
5657
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
5758
>
58-
<SkipLinks />
59-
<div className="flex min-h-screen flex-col">
60-
<Header />
61-
<main id="main-content" className="flex-1">{children}</main>
62-
<Footer />
63-
</div>
59+
<ThemeProvider>
60+
<SkipLinks />
61+
<div className="flex min-h-screen flex-col">
62+
<Header />
63+
<main id="main-content" className="flex-1">{children}</main>
64+
<Footer />
65+
</div>
66+
</ThemeProvider>
6467
</body>
6568
</html>
6669
);

src/hooks/use-theme.ts

Lines changed: 3 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,9 @@
11
"use client"
22

3-
import { useState, useEffect } from 'react'
3+
import { useThemeContext } from '@/lib/theme-context'
44

5-
export type Theme = 'light' | 'dark'
5+
export type { Theme } from '@/lib/theme-context'
66

77
export function useTheme() {
8-
const [theme, setTheme] = useState<Theme>('light')
9-
const [mounted, setMounted] = useState(false)
10-
11-
useEffect(() => {
12-
setMounted(true)
13-
14-
// Check for stored theme preference or default to system preference
15-
const stored = localStorage.getItem('theme') as Theme | null
16-
if (stored) {
17-
setTheme(stored)
18-
document.documentElement.classList.toggle('dark', stored === 'dark')
19-
} else {
20-
// Check system preference
21-
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
22-
const systemTheme: Theme = prefersDark ? 'dark' : 'light'
23-
setTheme(systemTheme)
24-
document.documentElement.classList.toggle('dark', systemTheme === 'dark')
25-
}
26-
}, [])
27-
28-
const toggleTheme = () => {
29-
if (!mounted) return
30-
31-
const newTheme: Theme = theme === 'light' ? 'dark' : 'light'
32-
setTheme(newTheme)
33-
localStorage.setItem('theme', newTheme)
34-
document.documentElement.classList.toggle('dark', newTheme === 'dark')
35-
}
36-
37-
const setThemeValue = (newTheme: Theme) => {
38-
if (!mounted) return
39-
40-
setTheme(newTheme)
41-
localStorage.setItem('theme', newTheme)
42-
document.documentElement.classList.toggle('dark', newTheme === 'dark')
43-
}
44-
45-
return {
46-
theme,
47-
toggleTheme,
48-
setTheme: setThemeValue,
49-
mounted,
50-
}
8+
return useThemeContext()
519
}

src/lib/theme-context.tsx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"use client"
2+
3+
import React, { createContext, useContext, useState, useEffect } from 'react'
4+
5+
export type Theme = 'light' | 'dark'
6+
7+
interface ThemeContextType {
8+
theme: Theme
9+
toggleTheme: () => void
10+
setTheme: (theme: Theme) => void
11+
mounted: boolean
12+
}
13+
14+
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
15+
16+
interface ThemeProviderProps {
17+
children: React.ReactNode
18+
}
19+
20+
export function ThemeProvider({ children }: ThemeProviderProps) {
21+
const [theme, setThemeState] = useState<Theme>('light')
22+
const [mounted, setMounted] = useState(false)
23+
24+
// Initialize theme on mount
25+
useEffect(() => {
26+
setMounted(true)
27+
28+
// Check for stored theme preference or default to system preference
29+
const stored = localStorage.getItem('theme') as Theme | null
30+
if (stored && (stored === 'light' || stored === 'dark')) {
31+
setThemeState(stored)
32+
applyTheme(stored)
33+
} else {
34+
// Check system preference
35+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
36+
const systemTheme: Theme = prefersDark ? 'dark' : 'light'
37+
setThemeState(systemTheme)
38+
applyTheme(systemTheme)
39+
}
40+
}, [])
41+
42+
const applyTheme = (newTheme: Theme) => {
43+
const root = document.documentElement
44+
if (newTheme === 'dark') {
45+
root.classList.add('dark')
46+
} else {
47+
root.classList.remove('dark')
48+
}
49+
}
50+
51+
const toggleTheme = () => {
52+
if (!mounted) return
53+
54+
const newTheme: Theme = theme === 'light' ? 'dark' : 'light'
55+
setThemeState(newTheme)
56+
localStorage.setItem('theme', newTheme)
57+
applyTheme(newTheme)
58+
}
59+
60+
const setTheme = (newTheme: Theme) => {
61+
if (!mounted) return
62+
63+
setThemeState(newTheme)
64+
localStorage.setItem('theme', newTheme)
65+
applyTheme(newTheme)
66+
}
67+
68+
const value: ThemeContextType = {
69+
theme,
70+
toggleTheme,
71+
setTheme,
72+
mounted,
73+
}
74+
75+
return (
76+
<ThemeContext.Provider value={value}>
77+
{children}
78+
</ThemeContext.Provider>
79+
)
80+
}
81+
82+
export function useThemeContext(): ThemeContextType {
83+
const context = useContext(ThemeContext)
84+
if (context === undefined) {
85+
throw new Error('useThemeContext must be used within a ThemeProvider')
86+
}
87+
return context
88+
}

0 commit comments

Comments
 (0)