Engineering

Migrating 162 Components from StyleSheet.create to NativeWind v4

We replaced every React Native StyleSheet in the app with Tailwind CSS classes. The migration touched 162 components, deleted our entire theming system, and unified our design language across platforms.

By Lumo EngineeringMar 29, 20264 min read

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:

  1. Dark mode is a runtime branch. Every component manually checks isDark and picks colors. Miss one check and you get a white card on a dark background. We had at least a dozen of these bugs.
  2. Colors are hardcoded hex values. Change the brand blue from #1d4ed8 to #2563eb and you're doing a find-and-replace across 162 files, praying you don't miss one.
  3. 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:

  1. Pick a screen. Start with leaves (components with no children), then work up.
  2. Delete the StyleSheet. Remove the entire StyleSheet.create block.
  3. Convert each style property to its Tailwind equivalent. paddingHorizontal: 16px-4. borderRadius: 16rounded-2xl. backgroundColor: isDark ? "#1e293b" : "#ffffff"bg-card.
  4. Add dark: prefixes where the design token doesn't automatically handle it.
  5. 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:

  • ThemedView and ThemedText — wrapper components that just applied theme colors. With NativeWind, every View and Text handles themes natively via className.
  • AppearancePreferences context — a React context that tracked font preferences and theme state. Tailwind config handles fonts. NativeWind handles themes.
  • use-color-scheme and use-theme-color hooks — 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.

Loading...