feat: add dark mode with system preference detection and toggle#47
feat: add dark mode with system preference detection and toggle#47
Conversation
Add class-based dark mode using Tailwind v4's @custom-variant directive. Detect system preference via prefers-color-scheme with an inline head script to prevent FOUC. Persist user choice in localStorage. Add a ThemeToggle component with sun/moon icons to the header on all pages.
There was a problem hiding this comment.
Pull request overview
Adds class-based dark mode across the Astro/Tailwind site, including early theme application to prevent FOUC, persisted user preference, and updated component styling to support both themes.
Changes:
- Introduces a Tailwind v4 custom
darkvariant driven by a.darkclass on the root element. - Adds an inline head script to set initial theme from
localStorageor system preference, plus a reusableThemeTogglecomponent. - Updates navigation and multiple UI components/pages with
dark:styles for consistent dark-mode appearance.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/styles/global.css | Defines Tailwind custom dark variant targeting .dark class usage. |
| src/layouts/Layout.astro | Adds inline pre-render theme detection + dark-mode body classes. |
| src/components/ThemeToggle.astro | New toggle button + client script to toggle .dark and persist choice. |
| src/components/Navigation.astro | Adds toggle to header; updates scroll-aware nav styling for dark mode. |
| src/pages/projects/index.astro | Adds toggle and dark-mode styling to docs/projects index header and cards. |
| src/layouts/DocsLayout.astro | Adds toggle to docs header and applies dark-mode styling to docs nav/sidebar controls. |
| src/components/SectionHeading.astro | Adds dark-mode typography colors. |
| src/components/ProjectGrid.astro | Adds dark-mode section background. |
| src/components/ProjectCard.astro | Adds dark-mode card/badge/text styles. |
| src/components/HexDocsLink.astro | Adds dark-mode button styling. |
| src/components/GetInvolved.astro | Adds dark-mode section/card/icon/text styles. |
| src/components/Footer.astro | Tweaks dark-mode footer background/border. |
| src/components/DocsSidebar.astro | Adds dark-mode link/section/divider styles. |
| src/components/DocsNavigation.astro | Adds dark-mode borders/backgrounds/text for prev/next navigation. |
| src/components/DocsContent.astro | Enables dark:prose-invert and dark-mode link/code/pre styling. |
| src/components/DocsBreadcrumb.astro | Adds dark-mode breadcrumb text/icon/link styles. |
| src/components/Badge.astro | Adds dark-mode badge background/text colors. |
| src/components/About.astro | Adds dark-mode section/text/feature-card styles. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| document.documentElement.classList.toggle( | ||
| "dark", | ||
| localStorage.theme === "dark" || | ||
| (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches), | ||
| ); |
There was a problem hiding this comment.
The inline theme-detection script directly accesses localStorage and window.matchMedia. In some browsers/privacy modes, localStorage access can throw a SecurityError, which would break early theme application and can log noisy errors. Consider wrapping storage access in a try/catch and using localStorage.getItem('theme') (or treating failures as “no stored preference”) before applying the dark class.
| <button | ||
| type="button" | ||
| class="theme-toggle rounded-lg p-1.5 transition-colors hover:bg-slate-200 dark:hover:bg-slate-700" | ||
| aria-label="Toggle dark mode" | ||
| > | ||
| <Icon name="lucide:sun" class="h-5 w-5 hidden dark:block" /> | ||
| <Icon name="lucide:moon" class="h-5 w-5 block dark:hidden" /> |
There was a problem hiding this comment.
ThemeToggle is a persistent on/off control, but the button doesn’t expose state to assistive tech (e.g., no aria-pressed / role="switch" + aria-checked). This makes it hard for screen-reader users to know whether dark mode is currently enabled. Update the script to set an explicit state attribute on load and on toggle, and consider updating the accessible label accordingly.
| <script> | ||
| document.querySelectorAll(".theme-toggle").forEach((btn) => { | ||
| btn.addEventListener("click", () => { | ||
| const isDark = document.documentElement.classList.toggle("dark"); | ||
| localStorage.theme = isDark ? "dark" : "light"; | ||
| }); |
There was a problem hiding this comment.
The theme toggle click handler writes to localStorage without guarding for storage being unavailable/blocked. If localStorage throws (privacy mode / blocked storage), the click handler will error and leave the UI in a partially updated state. Consider wrapping the localStorage write in try/catch and falling back to only toggling the class when storage isn’t available.
| nav.querySelectorAll(".nav-brand").forEach((el) => { | ||
| el.classList.remove("text-white"); | ||
| el.classList.add("text-slate-900"); | ||
| el.classList.add(isDark ? "text-white" : "text-slate-900"); | ||
| }); |
There was a problem hiding this comment.
In updateNav, .nav-brand only removes text-white but doesn’t remove text-slate-900 when switching to dark mode while scrolled. This can leave both classes on the element and makes the final color depend on class insertion order. Prefer explicitly removing both mutually-exclusive classes (or using classList.toggle with a boolean) before adding the correct one.
Summary
@custom-variant darkprefers-color-schememedia querylocalStoragewith toggle buttonTest plan