React Native's StyleSheet.create served us well for the first few months. Then it didn't. Here's a typical component before the migration:
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: isDark ? "#0b1120" : "#f7f9ff",
paddingHorizontal: 16,
},
title: {
fontSize: 24,
fontWeight: "700",
color: isDark ? "#e2e8f0" : "#0b1220",
marginBottom: 8,
},
card: {
backgroundColor: isDark ? "#1e293b" : "#ffffff",
borderRadius: 16,
padding: 20,
borderWidth: 1,
borderColor: isDark ? "#334155" : "#e2e8f0",
},
});Three problems with this:
- Dark mode is a runtime branch. Every component manually checks
isDarkand picks colors. Miss one check and you get a white card on a dark background. We had at least a dozen of these bugs. - Colors are hardcoded hex values. Change the brand blue from
#1d4ed8to#2563eband you're doing a find-and-replace across 162 files, praying you don't miss one. - No design system. Spacing values were freestyle — 12px here, 16px there, 14px because someone felt rebellious. There was no source of truth.
The NativeWind Decision
NativeWind brings Tailwind CSS to React Native. Instead of StyleSheet objects, you write:
<View className="flex-1 bg-background px-4">
<Text className="text-2xl font-bold text-foreground mb-2">
Dashboard
</Text>
<View className="bg-card rounded-2xl p-5 border border-border">
...
</View>
</View>Dark mode is automatic via dark: prefixes. Colors are design tokens. Spacing is consistent because Tailwind's scale (4, 8, 12, 16, 20...) is baked into the utility classes.
But migrating a running app with 162 components is... a lot.
The Infrastructure
Before touching a single component, we set up the foundation:
tailwind.config.ts
This became the single source of truth for every design token:
module.exports = {
content: ["./src/**/*.{ts,tsx}"],
presets: [require("nativewind/preset")],
theme: {
extend: {
colors: {
background: "var(--color-background)",
foreground: "var(--color-foreground)",
card: "var(--color-card)",
border: "var(--color-border)",
primary: "var(--color-primary)",
muted: "var(--color-muted)",
// ... every color in the system
},
},
},
};global.css
Tailwind directives plus CSS custom properties for light/dark themes:
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-background: #f7f9ff;
--color-foreground: #0b1220;
--color-card: #ffffff;
--color-border: #e2e8f0;
}
.dark {
--color-background: #0b1120;
--color-foreground: #e2e8f0;
--color-card: #1e293b;
--color-border: #334155;
}cssInterop for Third-Party Components
NativeWind only works on components that accept a className prop. React Navigation, Bottom Sheet, Reanimated — none of them do natively. We registered interop wrappers:
import { cssInterop } from "nativewind";
import { BottomSheetView } from "@gorhom/bottom-sheet";
cssInterop(BottomSheetView, { className: "style" });This tells NativeWind to convert className to style when rendering these third-party components.
cn() Utility
Conditional class composition was critical for interactive states:
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}Usage:
<Pressable
className={cn(
"rounded-xl p-4 border",
isActive
? "bg-primary border-primary"
: "bg-card border-border"
)}
>The Migration Process
With infrastructure in place, the actual migration was methodical but tedious:
- Pick a screen. Start with leaves (components with no children), then work up.
- Delete the StyleSheet. Remove the entire
StyleSheet.createblock. - Convert each style property to its Tailwind equivalent.
paddingHorizontal: 16→px-4.borderRadius: 16→rounded-2xl.backgroundColor: isDark ? "#1e293b" : "#ffffff"→bg-card. - Add
dark:prefixes where the design token doesn't automatically handle it. - Test on both themes. Toggle light/dark and verify every element.
The most common gotcha: text color conflicts on colored backgrounds. A primary-colored button needs white text, but the text-foreground token changes with the theme. We used a ternary pattern for these cases:
<Text className={cn(
"font-semibold",
isActive ? "text-white" : "text-foreground"
)}>What We Deleted
The migration let us kill entire subsystems:
ThemedViewandThemedText— wrapper components that just applied theme colors. With NativeWind, every View and Text handles themes natively viaclassName.AppearancePreferencescontext — a React context that tracked font preferences and theme state. Tailwind config handles fonts. NativeWind handles themes.use-color-schemeanduse-theme-colorhooks — custom hooks for reading the theme. Replaced by Tailwind's built-in dark mode.- Every hardcoded hex color — 200+ instances of
#1d4ed8,#e2e8f0, etc., all replaced with semantic tokens.
The Monorepo Wrinkle
One complication: the admin panel uses Tailwind CSS v4 (the new CSS-first configuration), while the mobile app uses Tailwind CSS v3 (required by NativeWind's current compatibility layer). Both coexist in the monorepo because each has its own tailwind.config and its own PostCSS pipeline. Bun's workspace resolution keeps them isolated.
This will simplify when NativeWind supports Tailwind v4, but for now, it works.
The Result
Before: 162 components with hardcoded colors, manual dark mode branching, and freestyle spacing.
After: 162 components with semantic design tokens, automatic dark mode, and consistent Tailwind spacing.
The visual appearance is almost identical — we weren't redesigning, we were systematizing. But the developer experience is transformed. Adding a new screen now means writing bg-background text-foreground instead of looking up hex codes in a Figma file. Changing the brand color means editing one line in tailwind.config.ts instead of 200 lines across the codebase.
The boring migration. The important one.
Abdul Rafay Founder, Syntax Lab Technology 162 components. One weekend. An unreasonable amount of coffee.