Our Approach to Dark Mode: Inside RapidNative's Theme-Aware React Native Architecture

(155 chars):

RI

By Rishav

2nd Sep 2026

Last updated: 2nd Sep 2026

Our Approach to Dark Mode: Inside RapidNative's Theme-Aware React Native Architecture

Dark mode in React Native looks like a five-minute feature. Read useColorScheme() from the OS, branch on the result, apply the right color. Done.

Then a designer changes the primary brand color. Or a user asks the AI to "make it warmer." And the app doesn't refactor — it fractures. Screen 12 goes cream. Screen 27 stays white. The card on the profile page still uses #1a1a1a. Half the buttons switched, half didn't. Nothing broke technically, and yet the app broke visually.

That failure mode — silent theme drift across a growing set of files — is the actual hard problem behind React Native dark mode. And it becomes twice as hard when the files are being written by an AI, one prompt at a time, without direct memory of what came three prompts ago.

This is a deep look at how RapidNative — an AI mobile app builder that turns prompts into working Expo apps — handles dark mode. Not "we support it." How. Which primitives we picked, which we rejected, and what specifically stops a model from painting itself into a light-only corner.

React Native app on a phone with a dark theme Photo by Rodion Kutsaiev on Unsplash

The failure mode we optimized against

If you've built React Native apps by hand, you know the anti-patterns. A file that starts with:

<View style={{ backgroundColor: '#ffffff' }}>
  <Text style={{ color: '#111827' }}>Hello</Text>
</View>

That code works. It ships. It looks fine in light mode because the designer's palette is light. Then dark mode arrives six months later and you spend a sprint changing hex codes in 200 files, missing three, and shipping a bug where the "Recent Orders" card is unreadable on OLED phones.

Multiply that risk by an AI code generator producing 5–20 new screens per session, and there is no world where sweeping hex codes works. So the design decision was made once, at the template layer: there are no hex codes in generated screens. There is one place colors live, and every screen references it symbolically.

That place is a file called theme.ts. Everything downstream is machinery to make that file the single point of truth — for the model that writes new code, for the app that runs the code, and for the user who wants to change the palette without refactoring anything.

Why we picked darkMode: 'class' over 'media'

Tailwind CSS — and by extension NativeWind, the React Native port — supports two dark mode strategies:

  • darkMode: 'media' ties dark mode to prefers-color-scheme at the CSS level. It's zero-JS and free.
  • darkMode: 'class' ties dark mode to a class name on a root element. You control when to add or remove it.

Every RapidNative template ships with:

// mobile/tailwind.config.js
module.exports = {
  darkMode: process.env.DARK_MODE ? process.env.DARK_MODE : 'class',
  // ...
}

'class' is the default because RapidNative apps have to satisfy three constraints that 'media' fights:

  1. A user toggle. Every generated app can expose a Sun/Moon button that overrides the OS. 'media' locks the app to the system preference and there's no way to override without hacks.
  2. A stable in-editor preview. The RapidNative editor is a dark surface, but the preview iframe should show whatever the app is set to — not whatever the editor is set to. A class we control is easier to reason about than a media query the browser resolves.
  3. Deterministic rendering for tests and snapshots. 'class' lets us render both themes in the same test run by toggling a class. Media queries require environment mocking.

The 'media' behavior is still one env var away for users who want it (DARK_MODE=media). But the default answers all three constraints at once.

The pivot layer: CSS variables with RGB channels

Here's the trick that ties the whole system together. Instead of Tailwind's default color palette (slate-100, zinc-800, hex-coded), our config maps each semantic color to a CSS custom property with a specific format:

// mobile/tailwind.config.js
colors: {
  background: 'rgb(var(--background) / <alpha-value>)',
  foreground: 'rgb(var(--foreground) / <alpha-value>)',
  primary: {
    DEFAULT: 'rgb(var(--primary) / <alpha-value>)',
    foreground: 'rgb(var(--primary-foreground) / <alpha-value>)',
  },
  card: {
    DEFAULT: 'rgb(var(--card) / <alpha-value>)',
    foreground: 'rgb(var(--card-foreground) / <alpha-value>)',
  },
  // ... muted, accent, destructive, border, input, ring
  // ... chart-1 through chart-5, sidebar tokens
}

Notice what's not here: no slate, no zinc, no gray. The palette exposed to generated code is entirely semantic — bg-background, text-foreground, bg-primary, text-muted-foreground. There is no name for a specific color, only for a role.

Values live one file over, defined with NativeWind's vars() helper:

// mobile/theme.ts
import { vars } from "nativewind";

export const lightTheme = vars({
  "--radius": "10",
  "--background": "255 255 255",
  "--foreground": "23 23 23",
  "--primary": "24 24 27",
  "--primary-foreground": "250 250 250",
  // ...
});

export const darkTheme = vars({
  "--radius": "10",
  "--background": "23 23 23",
  "--foreground": "250 250 250",
  // ...
});

Two things worth flagging.

RGB channels, not full RGB. Each variable stores "255 255 255", not "rgb(255,255,255)" or "#ffffff". That's what lets <alpha-value> in the Tailwind config work. When you write bg-primary/50, Tailwind produces rgb(var(--primary) / 0.5) — a valid CSS color with opacity, from the same variable. This is a Tailwind v3+ convention and it collapses the ceremony of "translucent card backgrounds" from a special case into a normal class.

Tokens are duplicated deliberately. Both lightTheme and darkTheme define the same set of variables with different values. There's no "extend" or "override" between them. This looks wasteful and it's intentional: a variable that only exists in one theme is a variable a screen can use safely in one mode and crash-render in the other. Full duplication is the invariant that keeps every semantic class safe in every scheme.

The ThemeProvider — one file, three environments

The runtime side is a single provider mounted at the top of the app tree. Its job is small but delicate: apply the right variable values to the right DOM/native tree, and re-apply them the moment the color scheme flips.

// mobile/src/providers/ThemeProvider.tsx
export function ThemeProvider({ children }: ThemeProviderProps) {
  const { colorScheme } = useColorScheme();
  const themeVars = colorScheme === 'dark' ? darkTheme : lightTheme;

  useEffect(() => {
    if (Platform.OS !== 'web') return;
    const root = document.documentElement;
    const vars: Record<string, string> =
      (themeVars as any).__cssVars ?? themeVars;
    for (const [key, value] of Object.entries(vars)) {
      root.style.setProperty(key, String(value));
    }
    root.classList.remove('light', 'dark');
    if (colorScheme) root.classList.add(colorScheme);
    // ...cleanup
  }, [themeVars, colorScheme]);

  return (
    <View style={themeVars} className={`${colorScheme} flex-1 bg-background`}>
      {children}
    </View>
  );
}

Three things this file quietly gets right, each of which came from a real bug.

1. Native and web use different application surfaces. On native, style={themeVars} on the root <View> is enough — NativeWind reads the vars off the JSX tree. On web, we also set them on document.documentElement. Why?

Because <Modal> in React Native, on the web target, portals its children out of the JSX tree and into document.body. Any modal — a confirmation dialog, a bottom sheet, an image viewer — renders outside the root <View>, so any CSS variable set on that View is invisible to the modal content. Result: every modal is unstyled. Setting the same vars on the <html> element makes them globally available, and modals suddenly render correctly. That fix took an afternoon to identify and one line of code to ship.

2. The class name is set imperatively, not statically. Setting class="dark" in the HTML template would work — until the user toggles. The classList.remove('light', 'dark') followed by add is what lets the same document flip between themes without a reload. The cleanup path also restores whatever class was there before, so the effect is composable.

3. useColorScheme from nativewind, not react-native. NativeWind exports its own hook (backed by React Native's Appearance API) that also participates in NativeWind's own reactive layer. Using Appearance.getColorScheme() directly works, but classes tagged with the dark: variant won't re-evaluate on scheme change — a subtle bug that shows as "everything except my className updated."

Developer editor showing React Native code with theme tokens Photo by Christopher Gower on Unsplash

Teaching the AI to stay on the rails

Semantic tokens are worthless if the model doesn't use them. This is where most AI code generators fall over: the palette is beautifully designed and the generated screen contains <View style={{ backgroundColor: 'white' }}>.

The fix is not a stronger prompt. Prompts drift. The fix is putting the rule in the project the model is editing, so it's re-read every turn.

Every RapidNative scaffold ships a mobile/CLAUDE.md at the root of the mobile workspace. It's read by the AI on every generation turn, and it contains, verbatim:

Do: Use semantic color classes (bg-background, text-foreground, etc.)

Don't: Hardcode colors

That's a two-line rule the model has in front of it whenever it edits a screen. Combined with the fact that the only Tailwind color classes that exist in the generated app are semantic (there is no bg-slate-100 in the compiled CSS), the practical outcome is: hardcoded colors don't happen because the affordance for them isn't there.

The same file lists other, harder-earned rules from the same family:

  • Never write unitless arbitrary classNames. h-[20] is invalid and silently does nothing on web. Use h-[20px] or a scale class. A single unitless class in a critical layout looks identical to a rendering bug in the preview.
  • Never rewrite ThemeProvider.tsx, theme.ts isn't for the AI on follow-up turns unless the user explicitly asks. The main coding agent's system prompt reinforces this: "Do NOT rewrite app.json, theme.ts, or call ask_question(app_icon_selector) unless the user explicitly asks." This is the invariant that makes a "add a settings screen" prompt not silently repaint the entire app.
  • Every catch in an event handler must set visible error state. Not theme-related, but the same pattern: rules that convert a silent failure into a loud one belong next to the code, not in the model's ephemeral prompt.

Rules in the template are load-bearing infrastructure. If you're building an AI code generator, this is the pattern to steal: don't try to make the model smart; make the file it's editing opinionated.

The read-only file guarantee

ThemeProvider.tsx is marked in the template CLAUDE.md as read-only. So is src/db/client.ts, AppProvider.tsx, useAuth.ts, and the root app/_layout.tsx. The AI is told, plainly, not to regenerate these files.

This matters for theming because the ThemeProvider is one refactor away from breaking the CSS-variable propagation on web. If a model, mid-generation, decides to "simplify" the provider by removing the documentElement block — every modal in the app is suddenly unthemed. The read-only rule makes that class of regression impossible.

The corollary: the surface area exposed to the AI is intentionally narrow. New screens can be added freely. New components can be added freely. New database migrations happen through a dedicated tool. But the four or five files that keep the plumbing correct don't move.

What this unlocks for users

The point of all this machinery is a user experience that looks trivial from the outside.

Change the primary color. A user types "make the primary color a warmer orange." The AI edits theme.ts — two --primary lines, one in lightTheme, one in darkTheme — and every button, every focus ring, every progress bar, every accent surface in the app picks up the new color. Zero screen files touched. Zero regressions.

Reference an existing brand. Ask the AI to build "something styled like Linear" or "with a Notion feel." The fetch_page tool pulls the site's actual computed colors, and those land in theme.ts as the palette. The screens don't need to know.

Toggle dark mode from any screen. Because useTheme() is exposed as a hook from the ThemeProvider:

export const useTheme = () => {
  const { colorScheme, setColorScheme } = useColorScheme();
  return {
    isDark: colorScheme === 'dark',
    colorScheme,
    setColorScheme,
  };
};

Any generated screen can render a Sun/Moon button that calls setColorScheme('dark'). NativeWind persists the choice across app reloads, and every semantic class in the app updates in a single React commit.

Convert single-mode apps safely. RapidNative's Lovable-to-Expo conversion has to handle web apps that were designed for one theme only. The converter writes both lightTheme and darkTheme with identical palettes and locks the scheme via setColorScheme('dark') in _layout.tsx. The app can never render in the "wrong" mode because there is no wrong mode — both are the same. The token architecture accommodates this with zero code changes.

A user toggling between dark and light themes on a phone Photo by Neil Soni on Unsplash

The parts we didn't build

Worth naming what the system is not:

  • No custom theme engine. Restyle from Shopify is excellent for type-safe theme access, but it adds a runtime and a mental model. Semantic Tailwind classes cover 95% of what Restyle offers, and NativeWind's compiler makes the runtime near-free.
  • No dark: prefix in generated code. The dark:bg-slate-900 pattern encodes both light and dark values on every element. That's fine for hand-written code where the author has both palettes in their head. For AI-generated code, doubling the class list doubles the surface for the model to make one right and one wrong. Semantic tokens flip the invariant: one class, one meaning, the theme swap happens under the class.
  • No theme-context prop-drilling. No useTheme() calls sprinkled through 40 screens to grab a theme.colors.primary. Screens use bg-primary and the resolver runs in the styling layer. The hook exists for interactive toggles, not for reading colors.

Every one of these is a road we deliberately didn't take. Each has real advantages in a hand-written codebase. In an AI-generated one, the calculus flips: fewer moving parts means fewer things the model can get subtly wrong.

The takeaway

If there's one idea to lift from all of this, it's that dark mode isn't a runtime problem. It's an architecture problem, and the answer is a pivot layer.

Semantic tokens (bg-background, text-primary-foreground) are the pivot. Tailwind's CSS-variable color format is the notation. NativeWind's vars() helper is the runtime. The template's CLAUDE.md is the enforcement layer for AI-written code. And the read-only file rule is the safety net.

Together, they turn "add dark mode" from a project into a one-file swap — and turn "change the entire palette" into something a user can do by typing.

If you want to see it work, try building a project on RapidNative and ask it, mid-way through, to invert the palette. Watch which files it changes. That's the whole architecture rendered as a diff.

Start now

Ready to build your app?

Turn your idea into a production-ready React Native app in minutes.

Free tools to get you started

Questions

Frequently asked questions

What is RapidNative?

RapidNative is an AI-powered mobile app builder. Describe the app you want in plain English and RapidNative generates real, production-ready React Native screens you can preview, edit, and publish to the App Store or Google Play.

Can I export the code?

Yes. RapidNative generates clean React Native and Expo code that you can export at any time. No lock-in, no proprietary format. Hand it to your developers or keep building inside RapidNative.

Is RapidNative free to use?

Yes. You can build apps on the free plan with no credit card required. Paid plans unlock unlimited AI generations, code export, and direct publishing to the App Store and Google Play.

Do I need to know how to code?

No. Most users build apps by describing what they want in plain English. Developers can drop into the code whenever they want more control, but coding is optional.

How long does it take to build an app?

Most users have a working first screen in under a minute. A full MVP usually takes a few hours instead of the weeks or months traditional development requires.