What it actually does
It is worth being precise, because the mental model prevents most of the bugs. NativeWind does not ship a CSS engine to the device. At build time it reads your source, finds the class names you literally wrote, asks Tailwind what those classes mean, and compiles them into style objects. At runtime a thin layer picks the right object for the current variant state — dark mode, platform, pressed.
Three consequences follow directly from that, and they explain nearly every “why isn’t this working” question: a class the compiler never saw does not exist; a file the compiler never scanned contributes nothing; and CSS that has no React Native equivalent — floats, most selectors, pseudo-elements — has nowhere to land, no matter how valid the Tailwind class is.
The four files
NativeWind v4 is the line most projects are on today, and a v5 tracking Tailwind v4 is landing — pin a version and follow that version’s docs, because the config shape is exactly what changes between them. The v4 setup in an Expo Router project looks like this:
// 1. tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
// Every directory that contains a className. Miss one and those
// screens render unstyled — with no error anywhere.
content: [
'./app/**/*.{js,jsx,ts,tsx}',
'./components/**/*.{js,jsx,ts,tsx}',
'./features/**/*.{js,jsx,ts,tsx}',
],
presets: [require('nativewind/preset')],
theme: {
extend: {
colors: { brand: '#fb923c', surface: '#1c1c1c' },
},
},
plugins: [],
};/* 2. global.css */
@tailwind base;
@tailwind components;
@tailwind utilities;// 3. metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: './global.css' });// 4. babel.config.js
module.exports = function (api) {
api.cache(true);
return {
presets: [
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
'nativewind/babel',
],
};
};Then import the CSS once, at the root, and add the types so TypeScript stops complaining that classNameisn’t a prop:
// app/_layout.tsx
import '../global.css';
import { Stack } from 'expo-router';
export default function RootLayout() {
return <Stack />;
}
// nativewind-env.d.ts
/// <reference types="nativewind/types" />Then restart Metro with the cache cleared.Babel and Metro config are read at startup, so editing them and reloading the app leaves you testing the old bundle and concluding, reasonably but wrongly, that the setup is broken. This one step accounts for a large share of “NativeWind doesn’t work” threads.
The constraint that surprises web developers
Class names are extracted from the literal text of your source. A name assembled at runtime was never seen by the compiler, so it resolves to nothing — silently:
// BROKEN — 'bg-red-500' as a literal never appears in the source.
<View className={`bg-${color}-500`} />
// WORKS — every possible class is written out, so all of them get generated.
const TONE = {
danger: 'bg-red-500 border-red-600',
success: 'bg-green-500 border-green-600',
neutral: 'bg-white/10 border-white/20',
} as const;
<View className={`rounded-xl border p-4 ${TONE[tone]}`} />The lookup-object pattern is how variant-driven components stay readable, and it is the main reason NativeWind beats StyleSheet on a design system: a button with four tones and three sizes is two small maps, where the StyleSheet version is twelve named entries you have to keep in sync by hand.
Third-party components need mapping
className works on the core components out of the box. A library component only works if it forwards style, and even then you have to register it:
import { cssInterop, remapProps } from 'nativewind';
import { LinearGradient } from 'expo-linear-gradient';
import { FlashList } from '@shopify/flash-list';
// Simple case: className -> style on this component.
cssInterop(LinearGradient, { className: 'style' });
// Multi-prop case: separate classNames for the list and its content container.
remapProps(FlashList, {
className: 'style',
contentContainerClassName: 'contentContainerStyle',
});Components that take colors as their own props rather than through style — icon sets, chart libraries, status bars — can’t be mapped this way at all. Keep those colors in your theme module and pass the value directly, so the Tailwind config stays the single source of truth even where classes can’t reach.
When to use it, and when not
| Situation | Reach for | Why |
|---|---|---|
| Shared tokens with a web app | NativeWind | One tailwind config, same color and spacing scale on both |
| Variant-heavy design system | NativeWind | Class strings compose; no StyleSheet permutation explosion |
| Reanimated / per-frame styles | Plain styles | Animated values need style objects on the UI thread |
| A five-screen app, solo | Either | Four config files may cost more than the styling saves |
| Heavy third-party UI kit | Check first | Every wrapped component needs cssInterop mapping |
The animation row is the one worth taking seriously. Reanimated drives styles on the UI thread from shared values, and that means style objects, not classes recomputed in React. Mixing is normal and fine: classes for the static layout, an animatedStyle for the parts that move. The animations guide covers why that boundary exists.
The traps
- Incomplete content globs. The single most common cause. A new top-level directory of components renders completely unstyled, with no warning at any layer.
- Not clearing the Metro cache after a config change. You are testing the previous bundle.
- Expecting web CSS to exist. No pseudo-elements, no arbitrary selectors, no
position: fixed. Shadows are approximate and differ per platform; check both. - Runtime-built class strings. Silent no-op. Use a lookup map.
- Assuming dark mode is free. The
dark:variant needs a color scheme source. Wire it to the system setting deliberately — see the dark mode guide.
The shortcut: skip the config archaeology
None of this is conceptually hard. It is four files that have to agree, a cache that has to be cleared, and a set of failure modes that produce no error message — which is a bad combination for an afternoon.
ShipNative generates React Native apps with the styling layer already wired, so you describe the screen and adjust the look in plain language rather than debugging Metro config. When you want the code, export the full Expo project — config files included — and take it from there.
Build it free
Describe your app in one sentence and have it running on your own phone in minutes at shipnative.dev. No credit card, full React Native source export whenever you want it.