Skip to main content
RapidDev - Software Development Agency
App Featurespersonalization-ux16 min read

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

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.

4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members

Feature spec

Beginner

Category

personalization-ux

Build with AI

1–3 hours with Lovable or V0

Custom build

1–2 days custom dev

Running cost

$0/mo at any scale

Works on

Web

Everything it takes to ship Dark Mode — parts, prompts, and real costs.

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.

What users consider table stakes in 2026

  • Toggle accessible from the top navigation bar or settings with a recognizable sun/moon icon
  • Preference persists across sessions and page reloads — choosing dark mode once stays dark forever
  • First visit respects the OS-level prefers-color-scheme setting automatically
  • Smooth 200ms CSS transition between modes with no flash of unstyled content on load
  • All UI elements — cards, dialogs, dropdowns, toasts, modals — fully styled in both modes
  • No layout shift or content reflow when toggling between light and dark

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.

Layers:UIData

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.

Note: Add aria-label='Switch to dark mode' (or light) so screen readers announce the action correctly. The icon should reflect the mode the button will switch TO, not 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.

Note: Must wrap the entire app at the root level — not a page component or layout section. In Next.js, place it in app/layout.tsx with suppressHydrationWarning on the html tag to silence React hydration mismatch warnings.

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.

Note: Tailwind's dark mode must be configured as class-based (darkMode: 'class') in tailwind.config.ts. Media-query mode does not work with localStorage persistence.

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.

Note: This is the critical FOUC prevention mechanism. If the read happens inside React's useEffect it's already too late — the page has painted in the default (light) theme.

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.

Note: Only implement this if your users genuinely use the app across multiple devices. For most apps, localStorage-only is sufficient and much simpler.

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.

Note: This listener only matters when the user has selected 'System' as their preference. Users who explicitly chose light or dark are unaffected by OS changes.

The 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.

schema.sql
1create table public.user_preferences (
2 id uuid references auth.users on delete cascade primary key,
3 theme text not null default 'system' check (theme in ('light', 'dark', 'system')),
4 updated_at timestamptz not null default now()
5);
6
7alter table public.user_preferences enable row level security;
8
9create policy "Users can view own preferences"
10 on public.user_preferences for select
11 using (auth.uid() = id);
12
13create policy "Users can upsert own preferences"
14 on public.user_preferences for insert
15 with check (auth.uid() = id);
16
17create policy "Users can update own preferences"
18 on public.user_preferences for update
19 using (auth.uid() = id);

Heads up: 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 it — pick your path

Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.

Full-stack, non-technical friendlyFit for this feature:

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.

Step by step

  1. 1Open your Lovable project and paste the prompt below into Agent Mode
  2. 2Lovable will create a ThemeContext (React Context, not next-themes — Lovable uses Vite/React, not Next.js) and a ThemeToggle component
  3. 3Verify the dark class is applied to the html element in the published preview — open browser DevTools and inspect the html tag
  4. 4If 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'
Paste into Lovable
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.

Where this path bites

  • 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

What it costs to run

Drag through the tiers to see how your monthly bill scales with users — no surprises later.

Estimated monthly running cost

$0/mo

Pure CSS/JavaScript — no service dependency. Supabase free tier covers the user_preferences table for 100 users with storage to spare.

Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.

What breaks when AI tools build this

The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.

White flash on every page load (FOUC)

Symptom: 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

Symptom: 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

Symptom: 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

Symptom: 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

1

Apply the dark class to the html element — never to a child wrapper — so portal-rendered components (modals, toasts) inherit the theme

2

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

3

Add a 200ms CSS transition on background-color and color via a global rule so the switch feels smooth rather than jarring

4

Define chart and data visualization colors as CSS variables from the start — retrofitting is significantly more work than doing it once

5

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

6

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

7

Add prefers-color-scheme media listener so the app responds in real time when a user switches OS dark mode without reloading the page

8

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

When You Need Custom Development

AI tools handle dark mode cleanly for standard Tailwind + shadcn/ui stacks. A few scenarios push beyond what prompting can reliably produce:

  • The app contains data visualizations with complex color palettes — heatmaps, multi-series Recharts, D3 diagrams — where a UI designer needs to specify separate dark theme color sets for brand accuracy
  • Brand guidelines require a custom animated transition between modes beyond a simple 200ms color fade (cross-fade, page-level View Transitions API animation)
  • Enterprise requirement to enforce dark mode via admin policy and prevent individual users from overriding it
  • App targets accessibility standards requiring WCAG AAA validated contrast ratios in both modes for every component state including hover, focus, and disabled

RapidDev builds these features for production

Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.

Get a fixed-price quote

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.

RapidDev

Need this feature production-ready?

RapidDev builds dark mode into real apps — auth, database, payments — at $13K–$25K.

Book a free consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.