Feature spec
BeginnerCategory
personalization-ux
Build with AI
2–4 hours with Lovable or V0
Custom build
3–5 days custom dev
Running cost
$0/mo at any scale
Works on
Everything it takes to ship Customizable Themes — parts, prompts, and real costs.
Customizable themes need three layers: a CSS custom properties system defining color and font tokens, a theme provider (next-themes on web, ThemeData in Flutter) that switches them without a page reload, and preference persistence to localStorage for anonymous users and to Supabase for authenticated ones. With Lovable or V0 this ships in 2–4 hours for $0/month at any scale — Supabase stores theme preferences as tiny JSON blobs under 100 bytes per user.
What Customizable Themes Actually Are
A customizable theme system lets users control how the app looks: light vs dark mode, an accent color, and optionally a font size scale. Done well, it is a ten-minute feature that users remember for months because it makes the app feel personal. Done poorly, it creates a dark-mode flicker on every page load, breaks shadcn/ui components because the variable names don't match, and fails to persist across devices for logged-in users. The CSS custom properties architecture is the foundation — every color and font value in the app must reference a token (like --color-primary or --font-scale) rather than a hardcoded hex or pixel value. Theme switching then becomes a single variable update on the document root instead of a component tree re-render.
What users consider table stakes in 2026
- Light and dark mode toggle with system preference detection via prefers-color-scheme, overridable by the user
- At least 3–5 color accent presets shown as clickable color swatches that update the app's primary color instantly
- Font size scaling with Small, Medium, and Large options — all text in the app responds without a page reload
- Theme preference persisted across sessions: localStorage for unauthenticated users, Supabase for authenticated ones
- Instant theme switching without any flash, flicker, or layout shift
- WCAG AA contrast warning when a chosen accent color fails the minimum 4.5:1 contrast ratio against the background
Anatomy of the Feature
Seven components. The theme provider, CSS token layer, and persistence are where the architecture lives. The contrast checker is the one component AI tools almost never generate without explicit instruction.
Theme Provider
UIThe root wrapper that controls which theme is active. On Next.js (V0): next-themes ThemeProvider with attribute='class' and suppressHydrationWarning on the html element — this is the canonical setup for Tailwind dark mode without SSR flicker. On Lovable/Vite: a custom ThemeContext that writes a data-theme attribute to the document root. On Flutter: ThemeData and ThemeMode.system passed to MaterialApp, with an App State variable for user override.
Note: Next-themes handles system preference detection, user override, localStorage persistence, and SSR flicker prevention in one package. For Vite/Lovable projects that don't use next-themes, implement the same logic manually: read from localStorage on mount inside a useEffect, then write the class or data attribute to document.documentElement.
CSS Custom Properties Layer
UIThe design token system: :root defines --color-primary, --color-background, --color-text, --color-accent, --color-surface, and --font-scale as CSS custom properties. The .dark class (applied by next-themes) overrides the same variable names with dark-mode values. All colors in the app reference these variables — no hardcoded hex or rgb values anywhere in component styles.
Note: For shadcn/ui compatibility, accent presets must update --primary using HSL values (not hex) because shadcn's components reference --primary as an HSL triplet: '221.2 83.2% 53.3%'. Updating a non-shadcn variable like --accent-color will not affect Button, Card, or Input components.
Color Preset Picker
UIA grid of 5–8 color swatch buttons. Clicking a swatch calls a theme update function that sets document.documentElement.style.setProperty('--primary', hslValue) and persists the choice. On web: built with Radix UI ToggleGroup or custom button elements with a selected-state ring. The selected accent is saved to localStorage immediately and synced to Supabase when a user is authenticated.
Note: Offer named presets (Blue, Green, Purple, Orange, Rose) rather than a free hex picker to start — a full HSL color picker is a 3-day custom dev task and most users are happy with 5–6 options.
Font Size Scaler
UIA three-option button group (S / M / L) that updates the --font-scale CSS custom property: Small = 0.875rem, Medium = 1rem, Large = 1.125rem. All font size values in the app are defined in rem units relative to the root base, so updating --font-scale cascades automatically. The choice persists to localStorage and Supabase alongside the color preference.
Note: Changing font size can cause text overflow in fixed-height containers. Test all three sizes during development by temporarily setting the html font-size to each value. Use min-height instead of fixed height on text-heavy containers.
Theme Persistence
DataAnonymous users: theme preferences are stored in localStorage under key 'theme-preferences' as a JSON object: { mode: 'dark', accent: 'blue', font_size: 'medium' }. Authenticated users: the same JSON is upserted to the Supabase profiles.theme_preferences JSONB column on every change. On login, the Supabase value overrides localStorage so the user's preferences follow them across browsers and devices.
Note: Always write to localStorage first (synchronous, immediate) and Supabase second (async, non-blocking). This prevents any visible delay in theme application while the network call completes.
System Preference Watcher
UIA window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', handler) listener that updates the theme when the OS switches between light and dark modes. The listener only applies if the user has not manually overridden the mode — a manual choice takes precedence. Next-themes handles this automatically; custom ThemeContext implementations need to add this listener explicitly and track whether the mode was manually set.
Note: Store a mode_source field ('system' or 'user') alongside the mode preference. If mode_source is 'user', ignore the system preference change event. If 'system', follow the OS.
Contrast Checker
UIA client-side WCAG AA contrast ratio calculator using the relative luminance formula: ((L1 + 0.05) / (L2 + 0.05)) >= 4.5 for normal text. Runs whenever the accent color changes and displays a yellow warning badge if the chosen color fails the minimum ratio against the app's background color. No external API needed — the calculation is pure math that runs in under 1ms.
Note: Calculate relative luminance for each RGB channel: L = 0.2126 * R + 0.7152 * G + 0.0722 * B (after applying the sRGB gamma correction). Show the warning with a specific message like 'Blue on white: 3.1:1 — below WCAG AA minimum (4.5:1)' rather than a generic 'contrast too low'.
The data model
Add a single JSONB column to the existing profiles table. Run this in the Supabase SQL editor — it adds the column with a default value and the RLS policy remains on the existing profiles RLS setup.
1-- Add theme_preferences to the existing profiles table2alter table public.profiles3 add column if not exists theme_preferences jsonb4 not null default '{"mode":"system","accent":"blue","font_size":"medium"}'::jsonb;56-- If profiles table doesn't exist yet, create it with theme support7create table if not exists public.profiles (8 id uuid primary key references auth.users(id) on delete cascade,9 display_name text,10 theme_preferences jsonb not null default '{"mode":"system","accent":"blue","font_size":"medium"}'::jsonb,11 updated_at timestamptz not null default now()12);1314alter table public.profiles enable row level security;1516create policy if not exists "Users can view own profile"17 on public.profiles for select18 using (auth.uid() = id);1920create policy if not exists "Users can update own profile"21 on public.profiles for update22 using (auth.uid() = id)23 with check (auth.uid() = id);2425create policy if not exists "Users can insert own profile"26 on public.profiles for insert27 with check (auth.uid() = id);Heads up: Theme preferences are stored as a JSONB blob rather than separate columns because the schema evolves easily — adding a new preference key (like 'border_radius' or 'compact_mode') requires no migration, just a new key in the default object.
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.
The canonical path for Next.js: next-themes integrates perfectly with shadcn/ui's CSS variable system, dark mode works out of the box, and V0 generates the ThemeProvider setup and accent color picker correctly with a well-specified prompt.
Step by step
- 1Paste the prompt below into V0 to generate the ThemeProvider setup, ThemeSettingsPanel, and Supabase persistence logic
- 2Add your Supabase credentials in V0's Vars panel (NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY) so the authenticated persistence works
- 3Run the SQL from this page's data model in the Supabase SQL editor to add theme_preferences to the profiles table
- 4In the generated layout.tsx, verify that suppressHydrationWarning is present on the html element and that the ThemeProvider wraps the children correctly
- 5Deploy to Vercel and test the complete flow: anonymous theme change (localStorage), authenticated theme change (Supabase sync), page reload in both dark and light modes confirming no flicker
Add a customizable theme system to this Next.js App Router project using next-themes and shadcn/ui. ThemeProvider setup: install next-themes; wrap the root layout children with ThemeProvider attribute='class' defaultTheme='system' enableSystem. Add suppressHydrationWarning to the html element in layout.tsx to prevent SSR dark mode flash. CSS custom properties: the shadcn/ui default variables are already correct for dark mode — add 5 accent presets by defining HSL values for --primary and --primary-foreground. Accent presets: Blue (221.2 83.2% 53.3%), Green (142.1 76.2% 36.3%), Purple (262.1 83.3% 57.8%), Orange (24.6 95% 53.1%), Rose (346.8 77.2% 49.8%). Font size presets: Small (html font-size: 14px), Medium (html font-size: 16px), Large (html font-size: 18px). ThemeSettingsPanel component: a client component that can be placed in any settings page or popover. Controls: (1) Mode toggle (Light/Dark/System) using useTheme() from next-themes, (2) AccentPicker — 5 color swatch buttons that call document.documentElement.style.setProperty('--primary', hslValue) on click; selected state shown with a ring, (3) FontSizePicker — S/M/L toggle that updates document.documentElement.style.fontSize. Persistence: on any accent or font_size change, write to localStorage key 'theme-preferences'. If the user is authenticated (use Supabase createClientComponentClient()), also upsert to profiles.theme_preferences (JSONB). On mount, read from Supabase first (if authenticated), fall back to localStorage, apply the stored accent and font_size. Contrast checker: calculate WCAG AA contrast ratio when accent changes; show a yellow Badge with the ratio if < 4.5:1. System preference: next-themes handles this automatically with enableSystem — no additional listener needed. Edge case: if user has manually set Light or Dark mode, their choice must take precedence over the OS; next-themes handles this via its storageKey mechanism — document this behavior in a tooltip next to the System option.Where this path bites
- Accent color presets must use shadcn's specific HSL variable format (--primary as a space-separated HSL triplet without 'hsl()' wrapper) — using hex values or adding the 'hsl()' wrapper will break all shadcn/ui component styles
- V0's default shadcn setup uses fixed font sizes for some components (like shadcn/ui Badge and Button text) that override the html font-size scaling — audit components that use text-xs, text-sm classes and replace with rem-relative custom tokens if full scaling is required
- Supabase is not auto-provisioned by V0 — run the SQL schema manually and ensure the profiles table exists before testing authenticated theme persistence
Third-party services you'll need
This feature has zero third-party service cost at any scale. Everything runs on CSS, localStorage, and a JSONB column in Supabase:
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| next-themes | Dark mode toggle with system preference detection, SSR flicker prevention, and localStorage persistence for Next.js apps | Open-source, zero cost | Free |
| Supabase | Authenticated user theme preference persistence (one JSONB column on the profiles table — under 100 bytes per user) | Free tier: 2 projects, 500MB DB — covers theme storage at any realistic scale | $25/mo (Pro) — only needed if Supabase is already required for other features |
Swipe the table sideways to see pricing.
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
Client-side CSS variables and localStorage are free. Supabase free tier handles the profiles table with theme_preferences JSON blobs at this scale with no cost.
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.
Dark mode flickers to light on every page load in Next.js
Symptom: The server renders the page without knowing the user's theme preference (it's in localStorage, which the server cannot read). The page initially loads in the default light theme, then next-themes applies the dark class after JavaScript hydrates — producing a visible flash of the wrong mode. This is the most common and most complained-about dark mode bug.
Fix: Add suppressHydrationWarning to the html element in layout.tsx — this tells React to expect a difference between server and client render for this element. Use next-themes with attribute='class' and defaultTheme='system'. This is the exact configuration documented in the next-themes README and it eliminates the flicker entirely. For Lovable/Vite apps without next-themes: read the theme from a synchronous cookie (not localStorage) in a blocking script tag before the first paint.
Accent color change doesn't update shadcn/ui Button, Card, or Input components
Symptom: shadcn/ui components reference --primary (HSL triplet without the 'hsl()' wrapper). AI tools often apply accent presets to a different variable name like --accent-color or --brand-primary, leaving shadcn components visually unchanged. This disconnect is subtle because non-shadcn elements may update correctly while the core UI components stay at the default blue.
Fix: Map every accent preset to the exact shadcn/ui CSS variable names: --primary (HSL triplet, e.g., '221.2 83.2% 53.3%') and --primary-foreground (the contrasting foreground color). Update both in the :root and .dark selectors. You can find the full list of shadcn variable names in any shadcn/ui project's globals.css file.
Theme preference not applied on other browsers or devices for logged-in users
Symptom: localStorage is browser-local. A user who sets their theme on Chrome on their laptop expects to see it on Safari on their phone. If the theme is only persisted to localStorage and not synced to Supabase, authenticated users lose their preferences on every new browser or device — which feels like a bug even though localStorage is working as designed.
Fix: On every theme change for an authenticated user, upsert to Supabase profiles.theme_preferences in addition to writing localStorage. On session start, fetch from Supabase first and apply that value; write it back to localStorage as the offline cache. localStorage remains the source of truth for anonymous users and as the immediate-write layer for authenticated ones.
Font size scaling at Large (1.125rem) causes text to overflow fixed-height containers
Symptom: When the app was designed and tested at 1rem (16px), some containers were given fixed heights (h-10, h-12 Tailwind classes in pixels) rather than min-heights. At 1.125rem base, text inside these containers overflows or gets clipped because the container height does not scale with the font. This often shows up in nav bars, buttons, and table cells.
Fix: Audit all height-constrained containers during development by temporarily setting the html font-size to 20px. Replace fixed height classes with min-height equivalents. For text in small containers (badges, pills), use text-overflow: ellipsis and title attributes as the overflow strategy. Test all three font sizes on every major page before shipping.
FlutterFlow custom widgets remain light-colored in dark mode
Symptom: FlutterFlow's ThemeData correctly applies to built-in widgets. However, custom Dart widgets added via the Code Editor often contain hardcoded color values like Colors.black, Colors.white, Color(0xFF333333), or Colors.grey.shade100. These hardcoded values do not respond to ThemeMode changes, so custom widgets stay visually stuck in light mode even when the rest of the app has switched to dark.
Fix: Audit every custom Dart widget in the project. Replace hardcoded color values with semantic Theme.of(context).colorScheme references: Colors.black becomes Theme.of(context).colorScheme.onSurface, Colors.white becomes Theme.of(context).colorScheme.surface, and so on. The Flutter Material 3 ColorScheme has named slots for every common UI color role.
Best practices
Build the CSS custom properties design token system before writing any component styles — going back to replace hardcoded colors across dozens of components later is the most painful part of adding themes to an existing app
Use HSL values for color tokens rather than hex or RGB — HSL makes it trivial to define lighter and darker tints of the same hue for hover states, disabled states, and borders by adjusting only the L value
Write to localStorage first, Supabase second — the localStorage write is synchronous and instant, so the UI updates without any perceptible delay; the Supabase write is fire-and-forget from the user's perspective
On session login, always fetch the user's theme from Supabase and apply it before rendering the main app content — doing this in a Suspense boundary ensures the correct theme is visible on first meaningful paint
Test the contrast ratio of each preset against both light and dark backgrounds — an accent that passes AA on white may fail on the dark mode background color, which has a different luminance
Use next-themes' built-in class strategy rather than data attributes for Tailwind dark mode — Tailwind's darkMode: 'class' configuration expects the dark class on html, and next-themes applies it correctly without any additional setup
Provide 5 presets rather than a free color picker for the first version — presets can be shipped in 2 hours and satisfy 90% of users; a full HSL picker with accessibility validation is a 3-day task that most users will never use
Document which CSS variable names correspond to which UI roles in a comment block at the top of globals.css — new developers (and AI tools on follow-up prompts) need this map to add new components without breaking the theme
When You Need Custom Development
AI tools cover light/dark mode and accent color presets completely. These requirements push past what preset-based theming can handle:
- Per-organization white-label theming: each tenant (company, school, franchise) needs their own logo, brand colors, and custom fonts — theme data must be stored per org_id and injected via middleware based on subdomain or request context
- User-designed themes with a full HSL color picker, live preview, custom hex input, and shareable theme codes — this is a feature product in itself, not a settings panel
- Themes that affect native app icons, splash screens, and status bar colors — these require native build pipeline changes in Xcode and Android Studio that web-first AI tools cannot generate
- High-contrast and reduced-motion accessibility modes required by an enterprise contract at WCAG AAA level — these involve not just color contrast but font weight adjustments, animation disabling, and focus indicator enhancements beyond what CSS presets cover
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
Frequently asked questions
How do I add dark mode to an existing app without rebuilding it?
The minimum-effort path: install next-themes and wrap your root layout. Then audit your CSS for hardcoded colors (grep for 'bg-white', 'text-gray-900', 'border-gray-200' in Tailwind classes) and replace them with dark: variants or CSS custom property references. The audit is the slow part — shadcn/ui components already use dark: variants correctly, so they require no changes. Custom components built with hardcoded values need to be updated one at a time.
Can users create their own custom color themes?
Yes, but it is a meaningful engineering task beyond what preset swatches require. You need an HSL color picker (a three-slider control for Hue, Saturation, Lightness), a real-time preview that applies the chosen color to a sample of your UI components, a WCAG contrast check for every selected color, and a way to serialize and store the resulting theme object. For a V1, offer 5–8 curated presets — they satisfy 90% of users and ship in 2 hours instead of 2 days.
How do I make sure custom colors pass accessibility contrast requirements?
Calculate the WCAG contrast ratio client-side using the relative luminance formula: for two colors, compute L1 and L2 as 0.2126R + 0.7152G + 0.0722B (after sRGB gamma correction), then check (max(L1, L2) + 0.05) / (min(L1, L2) + 0.05) >= 4.5 for AA compliance. Run this calculation whenever the accent color changes and show a warning badge if it fails. Show the specific ratio (e.g., '3.1:1 — below 4.5:1 minimum') so users understand how far off they are, not just a binary pass/fail.
Does the theme apply to email templates too?
Not automatically. CSS custom properties do not work in most email clients (including Gmail). For branded emails, you need a separate template system where the user's accent color is injected as a hardcoded hex value at send time. Store the user's resolved hex color (from their accent preference) in the profiles table alongside the JSONB preferences, and pass it to your Resend or SendGrid template as a variable when sending.
How do I sync theme across mobile and web for the same user?
Store the canonical theme preference in the Supabase profiles.theme_preferences JSONB column. Both the web app and the Flutter mobile app read from and write to this column when the user is authenticated. On web, next-themes handles the immediate application from localStorage; on mobile, Flutter's ThemeData is rebuilt when the Supabase value changes. The key is treating Supabase as the source of truth and localStorage/App State as a write-through cache.
What's the difference between a theme and a skin?
A theme changes structural design tokens that affect the entire app: colors, typography scale, and spacing. A skin typically means a more holistic visual overhaul including illustrations, icon sets, and layout density — the same underlying components but with a completely different visual identity. In practice, the terms are used interchangeably. The implementation described on this page (CSS custom properties + next-themes) supports both: themes via variable overrides, skins via swapping entire CSS variable sets.
Can I offer seasonal themes as a marketing feature?
Yes — add a limited_themes array to your ThemeConfig that includes seasonal entries with a start_date and end_date. On each app load, filter the theme preset list to include seasonal options that are currently active. Automatically apply a seasonal default for new visitors during the season window (Halloween dark theme in October, for example) while still allowing users to override to their preferred theme. Seasonal themes are a zero-cost feature that drives social sharing when users screenshot the festive UI.
How do I handle system auto dark/light switching when the user has a preference set?
Store a mode_source value ('system' or 'user') alongside the mode in the theme preferences. When mode_source is 'user', always use the user's chosen mode regardless of OS changes. When mode_source is 'system', subscribe to the prefers-color-scheme media query change event and update the active mode automatically. Next-themes handles this correctly out of the box via its enableSystem prop — when the user explicitly chooses Light or Dark from your settings panel, next-themes stops following the system preference automatically.
Need this feature production-ready?
RapidDev builds customizable themes into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.