# How to Add Customizable Themes to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

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.

## 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** (ui): The 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.
- **CSS Custom Properties Layer** (ui): The 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.
- **Color Preset Picker** (ui): A 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.
- **Font Size Scaler** (ui): A 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.
- **Theme Persistence** (data): Anonymous 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.
- **System Preference Watcher** (ui): A 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.
- **Contrast Checker** (ui): A 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.

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

```sql
-- Add theme_preferences to the existing profiles table
alter table public.profiles
  add column if not exists theme_preferences jsonb
  not null default '{"mode":"system","accent":"blue","font_size":"medium"}'::jsonb;

-- If profiles table doesn't exist yet, create it with theme support
create table if not exists public.profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  display_name text,
  theme_preferences jsonb not null default '{"mode":"system","accent":"blue","font_size":"medium"}'::jsonb,
  updated_at timestamptz not null default now()
);

alter table public.profiles enable row level security;

create policy if not exists "Users can view own profile"
  on public.profiles for select
  using (auth.uid() = id);

create policy if not exists "Users can update own profile"
  on public.profiles for update
  using (auth.uid() = id)
  with check (auth.uid() = id);

create policy if not exists "Users can insert own profile"
  on public.profiles for insert
  with check (auth.uid() = id);
```

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 paths

### Lovable — fit 4/10, 2–3 hours

Solid path for a Vite-based Lovable app: Lovable builds a custom ThemeContext with CSS custom properties reliably; the Supabase persistence and system preference watcher need to be specified explicitly in the prompt.

1. In a new or existing Lovable project, paste the prompt below into Agent Mode
2. After the theme panel is generated, open the preview and test: toggle dark mode, select a different accent color, change font size — verify all three update the UI instantly without a reload
3. Navigate to another page and back — verify the theme persists in the session via localStorage
4. If you have a Supabase backend connected, test while logged in: change the theme, sign out, sign in again, and confirm the preference is restored from the database
5. Test the SSR dark mode scenario: hard-refresh the page and confirm the correct theme loads without any flash of the default light mode

Starter prompt:

```
Add a customizable theme system to this app. Architecture: create a ThemeContext (React Context) that manages three state values: mode ('light', 'dark', or 'system'), accent ('blue', 'green', 'purple', 'orange', 'rose'), and font_size ('small', 'medium', 'large'). The ThemeContext wraps the app root. On mount (useEffect), read preferences from localStorage key 'theme-preferences'. If the user is authenticated, also fetch from Supabase profiles.theme_preferences and use that as the source of truth, then sync it back to localStorage. CSS custom properties: define on :root — --color-primary, --color-background, --color-surface, --color-text, --color-accent, --font-scale. The 'dark' class on the html element overrides these to dark-mode values. Accent presets: update --color-primary and --color-accent to the selected preset HSL values. Font size: set --font-scale to 0.875rem (small), 1rem (medium), or 1.125rem (large) on the html element. Theme Settings Panel: a settings drawer or settings page section with three controls: (1) Light/Dark/System toggle using Radix UI ToggleGroup, (2) a grid of 5 colored swatch buttons (Blue, Green, Purple, Orange, Rose) with a selected-state ring indicator, (3) S/M/L font size toggle. Persistence: on any preference change, immediately write to localStorage; if the user is authenticated, also upsert to Supabase profiles.theme_preferences (JSONB column). System preference: use window.matchMedia('(prefers-color-scheme: dark)') to detect OS mode; apply it when mode is 'system'; ignore OS changes if mode is 'light' or 'dark' (user override). Contrast checker: when an accent is selected, calculate the WCAG AA contrast ratio of the accent color against the background color using the relative luminance formula; show a yellow warning badge if the ratio is below 4.5:1. Edge case: if the user's system switches from light to dark while mode is 'system', update the UI without a page reload. Ensure all existing components use the CSS custom property tokens (--color-primary etc.) rather than hardcoded hex values.
```

Limitations:

- Lovable's Vite stack does not use next-themes, so SSR dark mode flicker prevention requires explicit handling — the prompt above specifies localStorage read in a useEffect, which avoids the flicker on initial load but may show a brief flash on very slow connections
- Lovable may apply the accent presets to a non-shadcn variable name (--accent-color instead of --primary) which means shadcn/ui Button and Card components will not update — verify that the generated code updates the correct shadcn variable names
- The font size scaler may break overflow in fixed-height containers in the existing UI — test at Large size and add min-height where needed

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

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.

1. Paste the prompt below into V0 to generate the ThemeProvider setup, ThemeSettingsPanel, and Supabase persistence logic
2. Add your Supabase credentials in V0's Vars panel (NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY) so the authenticated persistence works
3. Run the SQL from this page's data model in the Supabase SQL editor to add theme_preferences to the profiles table
4. In the generated layout.tsx, verify that suppressHydrationWarning is present on the html element and that the ThemeProvider wraps the children correctly
5. Deploy 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

Starter prompt:

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

Limitations:

- 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

### Flutterflow — fit 4/10, 2–4 hours

Straightforward for mobile: FlutterFlow's built-in Theme Editor supports light and dark mode via ThemeData natively; accent color palettes are set in project settings; font scaling via an App State variable.

1. Open FlutterFlow Project Settings → Theme Editor; set the primary color palette for both light and dark modes — this generates the ThemeData for both modes
2. Add an App State variable called selectedThemeMode (String, default 'system'); add another called fontSizeScale (double, default 1.0)
3. In MaterialApp settings, set themeMode to a conditional: if selectedThemeMode == 'system' use ThemeMode.system, 'light' use ThemeMode.light, 'dark' use ThemeMode.dark
4. Build a Theme Settings Page: add a ToggleButtons widget for Light/Dark/System that writes to selectedThemeMode App State; add three buttons (S/M/L) that write fontSizeScale values (0.875, 1.0, 1.125) to App State; add a MediaQuery.textScaleFactor override via a custom action
5. For persistence: on settings change, call a Supabase UPDATE action on profiles.theme_preferences (stored as JSONB); on app launch, call a Supabase SELECT action and restore the values to App State

Limitations:

- FlutterFlow's ThemeData system does not support dynamic CSS custom properties like the web implementation — changing the accent color requires rebuilding the ThemeData object via a custom Dart action; it cannot be done at runtime through the visual Action editor alone
- Custom widget code (custom Dart widgets added via FlutterFlow's Code Editor) often hardcodes Colors.black or Colors.white rather than using Theme.of(context).colorScheme values — these do not respond to dark mode switching and must be manually audited
- The contrast ratio checker for accent colors requires a custom Dart function — FlutterFlow has no built-in accessibility checking widget

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

Custom development is warranted only when theming goes beyond presets: per-organization brand themes, user-designed color palettes with a full HSL picker, or themes that affect native app icons and splash screens.

1. Per-organization white-label theming: store a themes table per org_id with primary_color, secondary_color, logo_url, and custom_fonts; inject org theme via middleware based on subdomain or query param; each tenant's users see the org's brand, not the app defaults
2. Full custom color picker: implement an HSL picker using a color-pick library or build a custom three-slider control (H/S/L); validate every selected color against the WCAG AA standard in real time; allow users to generate and share theme codes (a hex string or base64 encoding of the theme object)
3. Custom font upload: allow users or org admins to upload a font file to Supabase Storage; load it via @font-face in the CSS; this requires font subsetting for performance and CORS configuration on the Storage bucket
4. Native icon/splash theming: generating per-theme app icons and splash screens requires native build pipeline configuration (Xcode for iOS, Android Studio for Android) — not achievable in a web-first workflow

Limitations:

- Per-org theming with custom subdomains requires Vercel's wildcard domain routing and middleware to inject org context on every request — this is a meaningful infrastructure addition
- Custom font upload with subsetting is a non-trivial build step; Google Fonts remains the simplest safe alternative if font choice matters more than custom upload

## Gotchas

- **Dark mode flickers to light on every page load in Next.js** — 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** — 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** — 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** — 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** — 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

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

---

Source: https://www.rapidevelopers.com/app-features/customizable-themes
© RapidDev — https://www.rapidevelopers.com/app-features/customizable-themes
