Skip to content

Commit e2e27e7

Browse files
NickHodgesclaude
andcommitted
Add theme preference persistence with system sync option
- Add dropdown menu to theme toggle with light/dark/system options - Implement system preference sync toggle - Store theme preferences in localStorage - Add visual indicators for active theme - Center header and main content layout - Add TypeScript interface for theme utilities - Fix CSS animations for dropdown menu 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 48f4dbf commit e2e27e7

File tree

7 files changed

+460
-287
lines changed

7 files changed

+460
-287
lines changed

src/components/ThemeProvider.astro

Lines changed: 85 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,85 @@
1-
{/* Inlined to avoid FOUC. */}
2-
<script is:inline>
3-
const lightModePref = window.matchMedia('(prefers-color-scheme: light)');
4-
5-
function getUserPref() {
6-
const storedTheme = typeof localStorage !== 'undefined' && localStorage.getItem('theme');
7-
return storedTheme || (lightModePref.matches ? 'light' : 'dark');
8-
}
9-
10-
function setTheme(newTheme) {
11-
if (newTheme !== 'light' && newTheme !== 'dark') {
12-
return console.warn(`Invalid theme value '${newTheme}' received. Expected 'light' or 'dark'.`);
13-
}
14-
15-
const root = document.documentElement;
16-
17-
// root already set to newTheme, exit early
18-
if (newTheme === root.getAttribute('data-theme')) {
19-
return;
20-
}
21-
22-
root.setAttribute('data-theme', newTheme);
23-
24-
const colorThemeMetaTag = document.querySelector("meta[name='theme-color']");
25-
const bgColour = getComputedStyle(document.body).getPropertyValue('--theme-bg');
26-
colorThemeMetaTag.setAttribute('content', `hsl(${bgColour})`);
27-
if (typeof localStorage !== 'undefined') {
28-
localStorage.setItem('theme', newTheme);
29-
}
30-
}
31-
32-
// initial setup
33-
setTheme(getUserPref());
34-
35-
// View Transitions hook to restore theme
36-
document.addEventListener('astro:after-swap', () => setTheme(getUserPref()));
37-
38-
// listen for theme-change custom event, fired in src/components/ThemeToggle.astro
39-
document.addEventListener('theme-change', (e) => {
40-
setTheme(e.detail.theme);
41-
});
42-
43-
// listen for prefers-color-scheme change.
44-
lightModePref.addEventListener('change', (e) => setTheme(e.matches ? 'light' : 'dark'));
45-
</script>
1+
{/* Inlined to avoid FOUC. */}
2+
<script is:inline>
3+
const lightModePref = window.matchMedia('(prefers-color-scheme: light)');
4+
const THEME_KEY = 'theme';
5+
const THEME_SYNC_KEY = 'theme-sync-with-os';
6+
7+
function getUserPref() {
8+
if (typeof localStorage === 'undefined') {
9+
return lightModePref.matches ? 'light' : 'dark';
10+
}
11+
12+
// Check if we should sync with system preferences
13+
const syncWithOS = localStorage.getItem(THEME_SYNC_KEY) === 'true';
14+
15+
if (syncWithOS) {
16+
return lightModePref.matches ? 'light' : 'dark';
17+
}
18+
19+
// Use stored theme or fall back to system preference
20+
const storedTheme = localStorage.getItem(THEME_KEY);
21+
return storedTheme || (lightModePref.matches ? 'light' : 'dark');
22+
}
23+
24+
function setTheme(newTheme, syncWithOS = false) {
25+
if (newTheme !== 'light' && newTheme !== 'dark') {
26+
return console.warn(`Invalid theme value '${newTheme}' received. Expected 'light' or 'dark'.`);
27+
}
28+
29+
const root = document.documentElement;
30+
31+
// root already set to newTheme, exit early
32+
if (newTheme === root.getAttribute('data-theme')) {
33+
return;
34+
}
35+
36+
root.setAttribute('data-theme', newTheme);
37+
38+
const colorThemeMetaTag = document.querySelector("meta[name='theme-color']");
39+
const bgColour = getComputedStyle(document.body).getPropertyValue('--theme-bg');
40+
colorThemeMetaTag.setAttribute('content', `hsl(${bgColour})`);
41+
42+
if (typeof localStorage !== 'undefined') {
43+
localStorage.setItem(THEME_KEY, newTheme);
44+
localStorage.setItem(THEME_SYNC_KEY, String(syncWithOS));
45+
}
46+
}
47+
48+
// initial setup
49+
setTheme(getUserPref());
50+
51+
// View Transitions hook to restore theme
52+
document.addEventListener('astro:after-swap', () => setTheme(getUserPref()));
53+
54+
// listen for theme-change custom event, fired in src/components/ThemeToggle.astro
55+
document.addEventListener('theme-change', (e) => {
56+
setTheme(e.detail.theme, e.detail.syncWithOS || false);
57+
});
58+
59+
// listen for prefers-color-scheme change
60+
lightModePref.addEventListener('change', (e) => {
61+
// Only auto-update if sync with OS is enabled
62+
if (typeof localStorage !== 'undefined' && localStorage.getItem(THEME_SYNC_KEY) === 'true') {
63+
setTheme(e.matches ? 'light' : 'dark', true);
64+
}
65+
});
66+
67+
// Expose theme functions to window
68+
window.themeUtils = {
69+
getTheme: () => document.documentElement.getAttribute('data-theme'),
70+
setTheme,
71+
isSyncingWithOS: () => localStorage.getItem(THEME_SYNC_KEY) === 'true',
72+
toggleSyncWithOS: () => {
73+
const currentSync = localStorage.getItem(THEME_SYNC_KEY) === 'true';
74+
const newSync = !currentSync;
75+
localStorage.setItem(THEME_SYNC_KEY, String(newSync));
76+
77+
// If enabling sync, immediately sync with system
78+
if (newSync) {
79+
setTheme(lightModePref.matches ? 'light' : 'dark', true);
80+
}
81+
82+
return newSync;
83+
}
84+
};
85+
</script>

src/components/ThemeToggle.astro

Lines changed: 183 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,183 @@
1-
<theme-toggle class="ms-2 sm:ms-4">
2-
<button class="relative h-9 w-9 rounded-md p-2 ring-zinc-400 transition-all hover:ring-2" type="button">
3-
<span class="sr-only">Dark Theme</span>
4-
<svg aria-hidden="true" class="absolute start-1/2 top-1/2 h-7 w-7 -translate-x-1/2 -translate-y-1/2 scale-100 opacity-100 transition-all dark:scale-0 dark:opacity-0" fill="none" focusable="false" id="sun-svg" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
5-
<path d="M12 18C15.3137 18 18 15.3137 18 12C18 8.68629 15.3137 6 12 6C8.68629 6 6 8.68629 6 12C6 15.3137 8.68629 18 12 18Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
6-
<path d="M22 12L23 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
7-
<path d="M12 2V1" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
8-
<path d="M12 23V22" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
9-
<path d="M20 20L19 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
10-
<path d="M20 4L19 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
11-
<path d="M4 20L5 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
12-
<path d="M4 4L5 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
13-
<path d="M1 12L2 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
14-
</svg>
15-
<svg aria-hidden="true" class="absolute start-1/2 top-1/2 h-7 w-7 -translate-x-1/2 -translate-y-1/2 scale-0 opacity-0 transition-all dark:scale-100 dark:opacity-100" fill="none" focusable="false" id="moon-svg" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
16-
<path d="M0 0h24v24H0z" fill="none" stroke="none"></path>
17-
<path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z"></path>
18-
<path d="M17 4a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2"></path>
19-
<path d="M19 11h2m-1 -1v2"></path>
20-
</svg>
21-
</button>
22-
</theme-toggle>
23-
24-
<script>
25-
// Note that if you fire the theme-change event outside of this component, it will not be reflected in the button's aria-checked attribute
26-
import { rootInDarkMode } from '@utils';
27-
28-
class ThemeToggle extends HTMLElement {
29-
#controller: AbortController | undefined;
30-
31-
connectedCallback() {
32-
const button = this.querySelector('button')!;
33-
// set aria role value
34-
button.setAttribute('role', 'switch');
35-
button.setAttribute('aria-checked', String(rootInDarkMode()));
36-
// Abort signal
37-
const { signal } = (this.#controller = new AbortController());
38-
39-
// button event
40-
button.addEventListener(
41-
'click',
42-
() => {
43-
// invert theme
44-
let themeChangeEvent = new CustomEvent('theme-change', {
45-
detail: {
46-
theme: rootInDarkMode() ? 'light' : 'dark',
47-
},
48-
});
49-
// dispatch event -> ThemeProvider.astro
50-
document.dispatchEvent(themeChangeEvent);
51-
52-
// set the aria-checked attribute
53-
button.setAttribute('aria-checked', String(rootInDarkMode()));
54-
},
55-
{ signal },
56-
);
57-
}
58-
59-
disconnectedCallback() {
60-
this.#controller?.abort();
61-
}
62-
}
63-
64-
customElements.define('theme-toggle', ThemeToggle);
65-
</script>
1+
<theme-toggle class="ms-2 sm:ms-4">
2+
<div class="relative">
3+
<button class="relative h-9 w-9 rounded-md p-2 ring-zinc-400 transition-all hover:ring-2" type="button" id="theme-button" aria-label="Toggle theme" aria-haspopup="true">
4+
<svg aria-hidden="true" class="absolute start-1/2 top-1/2 h-7 w-7 -translate-x-1/2 -translate-y-1/2 scale-100 opacity-100 transition-all dark:scale-0 dark:opacity-0" fill="none" focusable="false" id="sun-svg" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
5+
<path d="M12 18C15.3137 18 18 15.3137 18 12C18 8.68629 15.3137 6 12 6C8.68629 6 6 8.68629 6 12C6 15.3137 8.68629 18 12 18Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
6+
<path d="M22 12L23 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
7+
<path d="M12 2V1" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
8+
<path d="M12 23V22" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
9+
<path d="M20 20L19 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
10+
<path d="M20 4L19 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
11+
<path d="M4 20L5 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
12+
<path d="M4 4L5 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
13+
<path d="M1 12L2 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
14+
</svg>
15+
<svg aria-hidden="true" class="absolute start-1/2 top-1/2 h-7 w-7 -translate-x-1/2 -translate-y-1/2 scale-0 opacity-0 transition-all dark:scale-100 dark:opacity-100" fill="none" focusable="false" id="moon-svg" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
16+
<path d="M0 0h24v24H0z" fill="none" stroke="none"></path>
17+
<path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z"></path>
18+
<path d="M17 4a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2"></path>
19+
<path d="M19 11h2m-1 -1v2"></path>
20+
</svg>
21+
</button>
22+
23+
<div id="theme-dropdown" class="absolute right-0 mt-1 hidden w-48 rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 dark:bg-zinc-800" style="transform-origin: top right;">
24+
<div class="px-4 py-2 text-sm text-gray-700 dark:text-gray-200">Theme settings</div>
25+
<div class="border-t border-gray-200 dark:border-gray-700"></div>
26+
27+
<button id="light-theme-option" class="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-zinc-700">
28+
<span class="flex items-center">
29+
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
30+
<circle cx="12" cy="12" r="5" stroke-width="2"></circle>
31+
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" stroke-width="2"></path>
32+
</svg>
33+
Light
34+
</span>
35+
</button>
36+
37+
<button id="dark-theme-option" class="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-zinc-700">
38+
<span class="flex items-center">
39+
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
40+
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" stroke-width="2"></path>
41+
</svg>
42+
Dark
43+
</span>
44+
</button>
45+
46+
<div class="border-t border-gray-200 dark:border-gray-700"></div>
47+
48+
<button id="system-sync-option" class="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-zinc-700">
49+
<span class="flex items-center">
50+
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
51+
<path d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" stroke-width="2"></path>
52+
</svg>
53+
<span id="system-sync-text">Sync with system</span>
54+
</span>
55+
<span id="system-sync-indicator" class="ml-2 inline-block h-4 w-4 rounded-full"></span>
56+
</button>
57+
</div>
58+
</div>
59+
</theme-toggle>
60+
61+
<script>
62+
// Note that if you fire the theme-change event outside of this component, it will not be reflected in the button's aria-checked attribute
63+
import { rootInDarkMode } from '@utils';
64+
65+
class ThemeToggle extends HTMLElement {
66+
#controller: AbortController | undefined;
67+
#button: HTMLButtonElement;
68+
#dropdown: HTMLElement;
69+
#lightOption: HTMLButtonElement;
70+
#darkOption: HTMLButtonElement;
71+
#systemOption: HTMLButtonElement;
72+
#systemText: HTMLElement;
73+
#systemIndicator: HTMLElement;
74+
75+
connectedCallback() {
76+
this.#button = this.querySelector('#theme-button')!;
77+
this.#dropdown = this.querySelector('#theme-dropdown')!;
78+
this.#lightOption = this.querySelector('#light-theme-option')!;
79+
this.#darkOption = this.querySelector('#dark-theme-option')!;
80+
this.#systemOption = this.querySelector('#system-sync-option')!;
81+
this.#systemText = this.querySelector('#system-sync-text')!;
82+
this.#systemIndicator = this.querySelector('#system-sync-indicator')!;
83+
84+
// Set aria role value
85+
this.#button.setAttribute('role', 'switch');
86+
this.#button.setAttribute('aria-checked', String(rootInDarkMode()));
87+
88+
// Abort signal
89+
const { signal } = (this.#controller = new AbortController());
90+
91+
// Check if theme sync is enabled
92+
this.updateSyncStatus();
93+
94+
// Toggle dropdown
95+
this.#button.addEventListener('click', (e) => {
96+
e.stopPropagation();
97+
const isHidden = this.#dropdown.classList.contains('hidden');
98+
99+
// Always update status before showing dropdown
100+
if (isHidden) {
101+
this.updateSyncStatus();
102+
}
103+
104+
this.#dropdown.classList.toggle('hidden');
105+
}, { signal });
106+
107+
// Handle option clicks
108+
this.#lightOption.addEventListener('click', () => this.setTheme('light', false), { signal });
109+
this.#darkOption.addEventListener('click', () => this.setTheme('dark', false), { signal });
110+
this.#systemOption.addEventListener('click', this.toggleSystemSync.bind(this), { signal });
111+
112+
// Close dropdown when clicking outside
113+
document.addEventListener('click', this.handleOutsideClick.bind(this), { signal });
114+
}
115+
116+
handleOutsideClick(e: Event) {
117+
if (!this.contains(e.target as Node) && !this.#dropdown.classList.contains('hidden')) {
118+
this.#dropdown.classList.add('hidden');
119+
}
120+
}
121+
122+
updateSyncStatus() {
123+
const isSyncing = window.themeUtils?.isSyncingWithOS() || false;
124+
const currentTheme = document.documentElement.getAttribute('data-theme');
125+
126+
// Update sync status
127+
this.#systemText.textContent = `Sync with system ${isSyncing ? '(on)' : '(off)'}`;
128+
this.#systemIndicator.classList.toggle('bg-green-500', isSyncing);
129+
this.#systemIndicator.classList.toggle('bg-gray-300', !isSyncing);
130+
131+
// Update active theme indicators
132+
if (currentTheme === 'light') {
133+
this.#lightOption.classList.add('theme-active-indicator');
134+
this.#darkOption.classList.remove('theme-active-indicator');
135+
} else {
136+
this.#lightOption.classList.remove('theme-active-indicator');
137+
this.#darkOption.classList.add('theme-active-indicator');
138+
}
139+
}
140+
141+
toggleSystemSync() {
142+
if (window.themeUtils) {
143+
window.themeUtils.toggleSyncWithOS();
144+
this.updateSyncStatus();
145+
this.#button.setAttribute('aria-checked', String(rootInDarkMode()));
146+
this.#dropdown.classList.add('hidden');
147+
}
148+
}
149+
150+
setTheme(theme: string, syncWithOS = false) {
151+
let themeChangeEvent = new CustomEvent('theme-change', {
152+
detail: {
153+
theme,
154+
syncWithOS
155+
},
156+
});
157+
158+
// Dispatch event -> ThemeProvider.astro
159+
document.dispatchEvent(themeChangeEvent);
160+
161+
// Set the aria-checked attribute
162+
this.#button.setAttribute('aria-checked', String(rootInDarkMode()));
163+
this.#dropdown.classList.add('hidden');
164+
this.updateSyncStatus();
165+
}
166+
167+
disconnectedCallback() {
168+
this.#controller?.abort();
169+
}
170+
}
171+
172+
customElements.define('theme-toggle', ThemeToggle);
173+
174+
// TypeScript interface for window.themeUtils
175+
interface Window {
176+
themeUtils?: {
177+
getTheme: () => string | null;
178+
setTheme: (theme: string, syncWithOS?: boolean) => void;
179+
isSyncingWithOS: () => boolean;
180+
toggleSyncWithOS: () => boolean;
181+
};
182+
}
183+
</script>

0 commit comments

Comments
 (0)