# How to Add Dark Mode to Your App — Copy-Paste Prompts Included

- Tool: App Features
- Last updated: July 2026

## TL;DR

Dark mode needs four things: a toggle button, a theme provider that reads the OS preference on first visit, a CSS variable or Tailwind dark: class layer that covers every component, and localStorage persistence so the setting survives a reload. With Lovable or V0 you ship it in 1–2 hours for $0/month — dark mode is a pure front-end feature with no service cost at any scale.

## What Dark Mode Actually Involves

Dark mode swaps the app's color palette — backgrounds turn dark, text turns light — based on user choice or their OS setting. What sounds simple hides one genuine complexity: the preference must be known before the first pixel paints, or users see a white flash before the dark theme kicks in. The real product decisions are how the preference is stored (localStorage for non-authenticated users, optionally Supabase for cross-device sync when logged in), which components need explicit dark-mode variants, and whether the toggle lives in a navigation bar or a dedicated settings screen. Everything else — the CSS, the context, the toggle icon — is solved infrastructure.

## Anatomy of the Feature

Six components — three front-end layers and two persistence options. The theme provider and the FOUC prevention script are where first builds consistently break.

- **Theme Toggle Button** (ui): A button in the header or settings that switches the active theme. Uses Lucide React icons (Sun, Moon) from lucide-react. Implemented as a shadcn/ui Switch component or a Radix UI Toggle. Dispatches a theme state change and updates its icon to reflect the current mode.
- **Theme Provider** (ui): In Next.js projects: next-themes ThemeProvider wraps the root layout; resolves the system prefers-color-scheme media query on mount and adds a data-theme attribute to the html element. In Vite/React (Lovable's stack): a React Context that does the same with a custom useTheme hook.
- **CSS Variable Layer** (ui): Tailwind CSS dark: variant classes applied to every component, or a CSS variables file defining --bg-primary, --text-primary, --border-color etc. for both modes. The dark class on the html element activates all dark: utilities. All shadcn/ui components (Card, Dialog, Dropdown, Table) use CSS variables that the theme provider flips automatically.
- **localStorage Persistence** (data): next-themes writes the user's preference to localStorage under the key 'theme'. On subsequent visits it reads this value before React mounts, preventing FOUC. In Vite/React apps, a small blocking script injected into index.html reads localStorage and sets data-theme on the html element before the React bundle loads.
- **Supabase Theme Preference** (data): Optional for cross-device sync: a user_preferences table in Supabase stores the theme value ('light', 'dark', or 'system') per authenticated user. When the user logs in on a new device, the stored preference overrides the local default. localStorage remains the source of truth for logged-out users.
- **prefers-color-scheme Watcher** (ui): A window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', handler) listener that detects when the user changes their OS dark mode setting while the app is open. Updates the app theme in real time without requiring a page reload. Built into next-themes; needs explicit setup in custom React Context implementations.

## Data model

Dark mode requires a data model only if you want cross-device sync for logged-in users. Run this in the Supabase SQL editor — localStorage alone handles the common case without a table.

```sql
create table public.user_preferences (
  id uuid references auth.users on delete cascade primary key,
  theme text not null default 'system' check (theme in ('light', 'dark', 'system')),
  updated_at timestamptz not null default now()
);

alter table public.user_preferences enable row level security;

create policy "Users can view own preferences"
  on public.user_preferences for select
  using (auth.uid() = id);

create policy "Users can upsert own preferences"
  on public.user_preferences for insert
  with check (auth.uid() = id);

create policy "Users can update own preferences"
  on public.user_preferences for update
  using (auth.uid() = id);
```

The table is small (one row per user) and never needs an index beyond the primary key. Upsert on login so new users get a row automatically.

## Build paths

### Lovable — fit 5/10, 1–2 hours

Lovable's Tailwind + shadcn/ui stack has dark: class support built in — the main task is wiring the theme context and adding the toggle. Fastest path for a Lovable-hosted app.

1. Open your Lovable project and paste the prompt below into Agent Mode
2. Lovable will create a ThemeContext (React Context, not next-themes — Lovable uses Vite/React, not Next.js) and a ThemeToggle component
3. Verify the dark class is applied to the html element in the published preview — open browser DevTools and inspect the html tag
4. If any shadcn/ui Dialog or Toast (Sonner) remains in light mode after toggle, send a follow-up prompt: 'The dark class must be on the html element, not on a wrapper div — modals render at document.body and need the html-level class'

Starter prompt:

```
Add a dark mode toggle to this app. Requirements: (1) Create a ThemeContext using React Context (not next-themes — this is a Vite/React project) that reads localStorage key 'theme' on mount and falls back to the OS prefers-color-scheme setting if no preference is stored. (2) Apply the dark class to the <html> element (not a wrapper div) so shadcn/ui Dialogs, Sonner toasts, and Radix UI Dropdowns are included. (3) Add a ThemeToggle button to the top navigation bar with a Sun icon in dark mode and Moon icon in light mode, with aria-label='Switch to dark mode' (or light). (4) On toggle, write the new value to localStorage and update the html class immediately. (5) Include a smooth 200ms transition on background-color and color properties for all elements using a global CSS rule. (6) If the user is logged in, read their theme from the user_preferences Supabase table on login and override localStorage; upsert the preference when they toggle. All shadcn/ui components (Card, Dialog, Table, Dropdown) must render correctly in both modes.
```

Limitations:

- Lovable uses Vite/React, not Next.js — next-themes is incompatible; always request a React Context-based theme provider
- Lovable's preview iframe may not accurately represent the published app's theme state — verify on the published Lovable URL
- Complex data visualizations (Recharts, Chart.js) with hardcoded hex colors need a follow-up prompt to switch to CSS variable references

### V0 — fit 5/10, 1–2 hours

V0 generates Next.js with next-themes out of the box — ThemeProvider in layout.tsx, a dropdown toggle with light/dark/system options, and correct suppressHydrationWarning setup.

1. Paste the prompt below into V0 to generate the ThemeProvider setup and toggle component
2. Open the Git panel, connect your repository, and push the generated code
3. Add NEXT_PUBLIC_ env vars if needed in the Vars panel (dark mode itself needs none)
4. Deploy to Vercel or your host and verify over the live URL — check that hard refresh keeps the chosen mode

Starter prompt:

```
Add a complete dark mode system to this Next.js App Router project. Requirements: (1) Install and configure next-themes ThemeProvider in app/layout.tsx wrapping the entire app; add suppressHydrationWarning to the <html> tag and attribute. (2) Create a ThemeToggle component in components/theme-toggle.tsx that uses shadcn/ui DropdownMenu with three options: Light, Dark, and System; use Lucide React Sun and Moon icons. (3) Add the ThemeToggle to the site header. (4) Configure tailwind.config.ts with darkMode: 'class'. (5) Verify that shadcn/ui Card, Dialog, Sheet, and DropdownMenu components all apply dark: variant classes correctly. (6) Include a 200ms CSS transition on background and text colors via globals.css. (7) Edge case: user not logged in — use localStorage only via next-themes default behavior. (8) Optional: when user is authenticated, read their theme from a Supabase user_preferences table (column: theme text default 'system') and call setTheme() after session loads to enable cross-device sync.
```

Limitations:

- V0 may place ThemeProvider inside a nested 'use client' component instead of the root layout — this causes SSR hydration mismatch; verify it wraps app/layout.tsx
- Tailwind v3/v4 version conflicts can cause dark: class utilities to stop working — if dark mode stops responding, pin Tailwind to v3.4.x explicitly
- V0's preview sandbox does not persist localStorage between refreshes — always verify persistence on the deployed URL

### Custom — fit 3/10, 1–2 days

Worth choosing when the app has complex data visualizations, per-component dark palette overrides, or enterprise requirements to enforce dark mode policy across a team.

1. Implement next-themes (Next.js) or a custom React Context (Vite) as the theme foundation
2. Define separate Recharts or D3 color palettes as CSS variables (--chart-1 through --chart-5) so chart colors flip with the theme
3. Wire the View Transitions API for a cross-fade animation between modes instead of a raw CSS transition
4. Add a Supabase user_preferences upsert in the auth onboarding flow to capture the initial OS preference as the stored default

Limitations:

- Retrofitting dark mode to an existing codebase with hardcoded hex colors in JSX touches every component — plan for a full component audit
- Tailwind's dark: strategy must be decided from day one; changing from media to class mode later breaks all existing dark: utilities

## Gotchas

- **White flash on every page load (FOUC)** — The page renders in light mode for a split second before JavaScript reads localStorage and applies the dark class. This happens because React hydrates client-side after the initial HTML paint — by the time useEffect runs, the user has already seen white. Fix: In Next.js: add suppressHydrationWarning to the <html> tag in layout.tsx and ensure next-themes ThemeProvider is at the root — it injects the correct class before hydration. In Vite/React: add a blocking <script> tag in index.html (before the React bundle) that reads localStorage and sets data-theme on the html element immediately.
- **Modals and toasts stay in light mode after toggle** — shadcn/ui Dialog, Sheet, and Sonner toasts render in a React portal attached to document.body. If the dark class is applied to a page wrapper div instead of the html element, these portals are outside the scoped dark class and render in light mode regardless of the toggle state. Fix: Always apply the dark class to the <html> element. When prompting Lovable or V0, add the explicit instruction: 'apply the dark class to the html element, not to a page wrapper or layout div.'
- **V0 dark mode works in preview but breaks after deploying to Vercel** — next-themes ThemeProvider is placed inside a component marked 'use client' nested several levels below the root layout. On the server, Next.js renders the html tag without the theme class; on the client, React re-renders with the class — causing a hydration error that Vercel's production build surfaces as a blank screen or broken styles. Fix: Open app/layout.tsx in the deployed codebase and verify ThemeProvider wraps the children directly in the root layout. Add suppressHydrationWarning attribute to the html tag. Move the 'use client' boundary to the ThemeToggle button only — the provider itself can stay in a Server Component wrapper.
- **Chart and SVG colors don't change in dark mode** — Recharts, Chart.js, and custom SVG graphics use hardcoded hex values like stroke='#6366f1' in JSX props. Tailwind's dark: classes cannot reach inline styles, so charts remain in their light palette even after the theme switches. Fix: Define chart color palettes as CSS variables: --chart-primary, --chart-secondary etc., set in both :root (light) and .dark (dark) scopes. Reference them in chart components: stroke='var(--chart-primary)'. When the dark class toggles on html, the variable values update and charts re-paint automatically.

## Best practices

- Apply the dark class to the html element — never to a child wrapper — so portal-rendered components (modals, toasts) inherit the theme
- Use the system option as the default preference rather than forcing light mode on first visit; match the user's OS intention from the start
- Add a 200ms CSS transition on background-color and color via a global rule so the switch feels smooth rather than jarring
- Define chart and data visualization colors as CSS variables from the start — retrofitting is significantly more work than doing it once
- Persist cross-device theme preference in the Supabase user_preferences table so users who log in on a new device see their expected mode immediately
- Test dark mode with WCAG contrast checker tools — some color combinations that look fine in light mode fail the 4.5:1 contrast ratio requirement in dark
- Add prefers-color-scheme media listener so the app responds in real time when a user switches OS dark mode without reloading the page
- Exclude dark mode toggle from server-side rendering checks — read localStorage in a useEffect or use next-themes' built-in hydration handling to avoid mismatch errors

## Frequently asked questions

### How do I stop the white flash when the page loads in dark mode?

The flash (called FOUC — Flash of Unstyled Content) happens because localStorage is read client-side after the initial HTML paint. In Next.js, add suppressHydrationWarning to the html tag and ensure next-themes ThemeProvider is in the root layout — it handles the timing automatically. In Vite/React apps, add a small blocking script in index.html that reads localStorage and sets data-theme on the html element before the React bundle loads.

### Does dark mode work without JavaScript?

Partially. The CSS prefers-color-scheme media query applies without JS if you use Tailwind in media mode — but you lose localStorage persistence and the manual toggle. For production apps, a JS-based theme provider is standard. Pure CSS dark mode is suitable only for static marketing pages where a toggle is unnecessary.

### Can I offer a System option that follows the OS setting automatically?

Yes. next-themes supports 'system' as a theme value out of the box — include it as a third option in your toggle dropdown alongside Light and Dark. In a custom React Context, listen to window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change') and update the applied class when the OS setting changes while the app is open.

### How do I sync dark mode preference when the user logs in on a different device?

Store the preference in a Supabase user_preferences table (column: theme text default 'system'). On login, fetch the stored value and call setTheme() to override the local setting. Upsert the preference on every toggle so the table stays current. Users who are not logged in fall back to localStorage silently.

### Does Tailwind CSS handle dark mode automatically?

Tailwind provides the dark: utility prefix, but you have to configure it. Set darkMode: 'class' in tailwind.config.ts (not the default 'media' setting) so dark: classes activate from a CSS class on the html element rather than the OS media query — this is required for localStorage-based toggles to work.

### Will dark mode affect my Lighthouse score or SEO?

No. Dark mode is implemented entirely in CSS and client-side JavaScript with no impact on HTML structure, page weight, or metadata. Lighthouse performance scores can improve slightly if you avoid re-painting on load (proper FOUC prevention), but the SEO impact is zero.

### How do I test dark mode on an iPhone or Android?

On iPhone: Settings → Display & Brightness → Dark to trigger prefers-color-scheme: dark in Safari. On Android: Settings → Display → Dark Theme. Open your deployed HTTPS URL in the device browser — device browsers send the OS preference via the media query, so your app should auto-apply the dark theme on first visit before any toggle interaction.

### Can users set dark mode per section of the app rather than globally?

It is technically possible to scope the dark class to a specific container, but it creates significant maintenance complexity — every new component must be tested in both scopes. Per-section theming is almost never worth it. The standard approach is a single global toggle with one theme active across the entire app at all times.

---

Source: https://www.rapidevelopers.com/app-features/dark-mode
© RapidDev — https://www.rapidevelopers.com/app-features/dark-mode
