CULTIVA IA Web Dev Skill Expo SDK 52 Intermedio

Tailwind CSS v4 + NativeWind v5
en Expo

Setup completo para la app CULTIVA Tools — estilos universales con clases CSS en iOS, Android y Web desde un único proyecto Expo.

Tailwind CSS v4
NativeWind v5
react-native-css
Expo Router v3
TypeScript Strict
📱
iOS
SF Colors, platformColor(), ui-rounded, ui-serif
🤖
Android
System fonts, lightningcss, media queries @media android
🌐
Web
CSS nativo, light-dark(), PostCSS + @tailwindcss/postcss
1
Instalación de dependencias
Paquetes necesarios + resolución de lightningcss
IMPORTANTE — lightningcss 1.30.1 Fijar la resolución en package.json para evitar incompatibilidades de build. autoprefixer NO es necesario (lo gestiona lightningcss en Expo).
terminal
bash
# Instalar con el resolver de Expo para versiones correctas
npx expo install tailwindcss@^4 \
  nativewind@5.0.0-preview.2 \
  react-native-css@0.0.0-nightly.5ce6396 \
  @tailwindcss/postcss \
  tailwind-merge \
  clsx
package.json
json
{
  "name": "cultiva-tools",
  "resolutions": {
    "lightningcss": "1.30.1"   // ← forzar versión compatible
  }
}
2
Archivos de configuración
Metro · PostCSS · CSS Global
cultiva-tools/ ├── metro.config.js ← modificar ├── postcss.config.mjs ← nuevo ├── src/ │ ├── global.css ← nuevo (imports TW) │ ├── css/ │ │ └── sf.css ← colores iOS nativos │ └── tw/ │ ├── index.tsx ← wrappers principales │ ├── image.tsx ← wrapper Image │ └── animated.tsx ← Animated.View └── app/ └── ...
metro.config.js
js
const { getDefaultConfig } = require(
  "expo/metro-config"
);
const { withNativewind } = require(
  "nativewind/metro"
);

const config = getDefaultConfig(
  __dirname
);

module.exports = withNativewind(
  config,
  {
    inlineVariables: false,
    globalClassNamePolyfill: false,
  }
);
postcss.config.mjs
js
export default {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};

// ✓ NO autoprefixer necesario
// lightningcss lo gestiona internamente
src/global.css
css
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base);
@import "tailwindcss/utilities.css";
@import "./css/sf.css";

/* Platform-specific font families */
@media android {
  :root {
    --font-mono: monospace;
    --font-sans: normal;
  }
}

@media ios {
  :root {
    --font-mono: ui-monospace;
    --font-sans: system-ui;
    --font-rounded: ui-rounded;
  }
}
3
CSS Component Wrappers
src/tw/index.tsx — wrappers con useCssElement
ℹ️
¿Por qué wrappers? react-native-css requiere que cada componente nativo esté envuelto con useCssElement para mapear la prop className al sistema de estilos de React Native. Sin esto, las clases CSS no se aplican.
src/tw/index.tsx
tsx
import {
  useCssElement,
  useNativeVariable as useFunctionalVariable,
} from "react-native-css";
import { Link as RouterLink } from "expo-router";
import {
  View as RNView,
  Text as RNText,
  Pressable as RNPressable,
  ScrollView as RNScrollView,
  TextInput as RNTextInput,
} from "react-native";

// CSS Variable hook — nativo en RN, var() en web
export const useCSSVariable =
  process.env.EXPO_OS !== "web"
    ? useFunctionalVariable
    : (variable: string) => `var(${variable})`;

// ── Componentes envueltos ──────────────────────
export const View = (
  props: React.ComponentProps<typeof RNView> &
    { className?: string }
) => useCssElement(RNView, props,
  { className: "style" }
);

export const Text = (
  props: React.ComponentProps<typeof RNText> &
    { className?: string }
) => useCssElement(RNText, props,
  { className: "style" }
);

export const ScrollView = (
  props: React.ComponentProps<typeof RNScrollView> &
    { className?: string;
      contentContainerClassName?: string }
) => useCssElement(RNScrollView, props, {
  className: "style",
  contentContainerClassName: "contentContainerStyle",
});

export const Pressable = (
  props: React.ComponentProps<typeof RNPressable> &
    { className?: string }
) => useCssElement(RNPressable, props,
  { className: "style" }
);

export const TextInput = (
  props: React.ComponentProps<typeof RNTextInput> &
    { className?: string }
) => useCssElement(RNTextInput, props,
  { className: "style" }
);
4
Apple SF Colors — iOS nativos + dark mode
src/css/sf.css — PlatformColor en iOS, light-dark() en Web/Android
src/css/sf.css
css
@layer base {
  html { color-scheme: light; }
}

/* ── Web + Android: light-dark() fallback ── */
:root {
  --sf-blue:   light-dark(rgb(0 122 255), rgb(10 132 255));
  --sf-green:  light-dark(rgb(52 199 89),  rgb(48 209 89));
  --sf-red:    light-dark(rgb(255 59 48),  rgb(255 69 58));
  --sf-text:   light-dark(rgb(0 0 0),        rgb(255 255 255));
  --sf-bg:     light-dark(rgb(255 255 255), rgb(0 0 0));
  --sf-bg-2:   light-dark(rgb(242 242 247), rgb(28 28 30));
}

/* ── iOS: colores nativos del sistema ── */
@media ios {
  :root {
    --sf-blue:   platformColor(systemBlue);
    --sf-green:  platformColor(systemGreen);
    --sf-red:    platformColor(systemRed);
    --sf-text:   platformColor(label);
    --sf-bg:     platformColor(systemBackground);
    --sf-bg-2:   platformColor(secondarySystemBackground);
  }
}

/* ── Registrar como clases Tailwind ── */
@layer theme {
  @theme {
    --color-sf-blue:   var(--sf-blue);
    --color-sf-green:  var(--sf-green);
    --color-sf-red:    var(--sf-red);
    --color-sf-text:   var(--sf-text);
    --color-sf-bg:     var(--sf-bg);
    --color-sf-bg-2:   var(--sf-bg-2);
  }
}
5
Migración desde NativeWind v4 + Tailwind v3
5 diferencias clave — guía antes/después
Aspecto v3 / NW v4 (antes) v4 / NW v5 (ahora)
Config principal tailwind.config.js @theme {} en CSS
Babel config nativewind/babel preset NO babel.config.js
Imports CSS @tailwind base/utilities @import "tailwindcss/..."
PostCSS plugin tailwindcss (v3) @tailwindcss/postcss (v4)
Metro config withNativewind() básico inlineVariables: false
Componentes className directo useCssElement wrapper
babel.config.js — eliminar bloque NativeWind
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};
// ✓ babel.config.js ya NO necesario para NativeWind v5
// La configuración es ahora 100% CSS-first vía PostCSS
6
Ejemplo real — Pantalla CULTIVA Tools
app/(tabs)/projects.tsx usando los wrappers
app/(tabs)/projects.tsx
tsx
import { View, Text, ScrollView,
         Pressable } from "@/tw";
import { Image } from "@/tw/image";
import { useCSSVariable } from "@/tw";

export default function ProjectsScreen() {
  const blue = useCSSVariable("--sf-blue");

  return (
    <ScrollView className="flex-1 bg-sf-bg">
      <View className="p-6 gap-4">

        <Text className="text-2xl font-bold text-sf-text">
          Proyectos activos
        </Text>

        {PROJECTS.map(project => (
          <Pressable
            key={project.id}
            className="bg-sf-bg-2 rounded-2xl p-4
                       active:opacity-70"
          >
            <Text className="font-semibold text-sf-text">
              {project.name}
            </Text>
            <Text className="text-sm text-sf-text-2 mt-1">
              {project.status}
            </Text>
            <View
              style={{ borderColor: blue }}
              className="mt-3 border rounded-full
                         px-3 py-1 self-start"
            >
              <Text className="text-xs text-sf-blue">
                {project.type}
              </Text>
            </View>
          </Pressable>
        ))}

      </View>
    </ScrollView>
  );
}
!
Troubleshooting frecuente
Problemas habituales y sus soluciones
🔴
Estilos no se aplican 1. Verificar que global.css está importado en el entry point de la app
2. Comprobar que el componente usa el wrapper de src/tw/, no el nativo directamente
3. Revisar que Metro tiene withNativewind(config, { inlineVariables: false })
🟡
PlatformColor no funciona en iOS Usar platformColor() SOLO dentro de bloques @media ios.
En Android/Web usar siempre el fallback light-dark().
🔵
Errores TypeScript con className Extender los tipos del componente: type Props = React.ComponentProps<typeof RNView> & { className?: string }