Dark Mode Toggle
What We Did
Section titled “What We Did”Added a dark mode toggle to the web app so users can switch between light and dark themes. The CSS infrastructure (oklch color variables under a .dark class) already existed in @repo/ui, but there was no way for users to activate it. We installed next-themes, wired up a ThemeProvider, and created a toggle button visible on every page.
Why This Approach
Section titled “Why This Approach”Key reasons:
- next-themes is the standard solution for Next.js theme switching — it handles SSR hydration, localStorage persistence, system preference detection, and FOUC prevention out of the box
- The existing CSS already used a
.darkclass with oklch color variables, soattribute="class"was a perfect fit with zero CSS changes needed - The
<html>tag in the layout already hadsuppressHydrationWarning, which is specifically designed for next-themes compatibility
Alternatives considered:
- Manual implementation with React context: Would require reimplementing localStorage sync, system preference detection, and hydration mismatch prevention — all solved by next-themes
- CSS
prefers-color-schemeonly: No user control; relies entirely on OS settings
Commands Used
Section titled “Commands Used”# Install next-themes in the web appcd apps/webbun add next-themesImplementation Details
Section titled “Implementation Details”File Changes
Section titled “File Changes”New file — apps/web/components/theme-toggle.tsx:
A "use client" component that renders a ghost icon button. It uses useTheme() from next-themes to read the resolved theme and toggle between light and dark. The sun icon shows in light mode (hidden in dark via dark:scale-0), and the moon icon shows in dark mode (hidden in light via scale-0 dark:scale-100). Icons are inline SVGs to avoid adding an icon library dependency.
Modified — apps/web/app/providers.tsx:
Wrapped the entire provider tree with <ThemeProvider>:
import { ThemeProvider } from "next-themes"
// In the Providers component return:<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <ConvexAuthProvider ...> <QueryProvider ...>{children}</QueryProvider> </ConvexAuthProvider></ThemeProvider>The ThemeProvider is the outermost wrapper because it needs to apply the .dark class to <html> before any themed content renders.
Modified — apps/web/components/home-page.tsx:
Added <ThemeToggle /> in two locations:
- Login view (unauthenticated): Positioned absolutely in the top-right corner
- Authenticated view: In the header, next to the
<UserHeader />component
How the Theme System Works
Section titled “How the Theme System Works”The flow connects three layers:
next-themesadds/removes thedarkclass on<html>globals.cssin@repo/uidefines CSS custom properties under:root(light) and.dark(dark) using oklch colors- Tailwind CSS v4 maps these variables via
@custom-variant dark (&:is(.dark *))so alldark:utilities work automatically
No CSS changes were needed — the entire dark theme was already defined.
Key Dependencies
Section titled “Key Dependencies”next-themes: ^0.4.6 — Theme management for Next.js with SSR support, localStorage persistence, and system preference detection
Integration with Existing Code
Section titled “Integration with Existing Code”@repo/uiglobals.css: The.darkclass selector and all oklch color variables were already defined — next-themes simply activates them- Tailwind CSS v4: The
@custom-variant dark (&:is(.dark *))rule means everydark:utility class works automatically once the.darkclass is present @repo/uiButton: The toggle reuses the existingButtoncomponent withvariant="ghost"andsize="icon"- Layout
suppressHydrationWarning: Already present on the<html>tag, preventing React hydration warnings when next-themes modifies the class attribute client-side
Context for AI
Section titled “Context for AI”When working with theming in this project:
- The theme is controlled by the
darkclass on<html>, managed bynext-themes - All color tokens are defined as CSS custom properties using oklch in
packages/ui/src/styles/globals.css - Use Tailwind
dark:variants for any theme-aware styling — they work automatically - The
ThemeProvideris inapps/web/app/providers.tsxas the outermost wrapper defaultTheme="system"means the app respects OS dark mode preference on first visit- User preference is persisted in localStorage automatically by next-themes
- The
ThemeTogglecomponent uses inline SVGs, not an icon library
Outcomes
Section titled “Outcomes”Before
Section titled “Before”- Dark mode CSS variables existed but were unreachable — no way to toggle the
.darkclass - The app always rendered in light mode
- Users can toggle between light and dark mode on any page
- System preference is respected by default on first visit
- Theme preference persists across page reloads via localStorage
- The toggle button appears on both the login screen and the authenticated dashboard
Testing/Verification
Section titled “Testing/Verification”# Type checkturbo check-types --filter=web
# Lintturbo lint --filter=web
# Dev serverturbo dev --filter=webExpected results:
- Toggle button visible in the top-right of the login page
- Toggle button visible in the header next to the user menu when authenticated
- Clicking the toggle switches between light and dark themes
- Refreshing the page preserves the selected theme
- On first visit with no preference, the app follows the OS setting
Next Steps
Section titled “Next Steps”- Consider adding a system/auto option to the toggle (currently cycles between light and dark only)
- The toggle could be extracted to
@repo/uiif other apps in the monorepo need dark mode
Related Documentation
Section titled “Related Documentation”- next-themes documentation
- shadcn/ui Components — The UI library providing the Button and color system
- Better Convex RSC — Previous guide in the development journey