Construyendo una app de radios LATAM (3): migrar de StyleSheet a NativeWind sin morir en el intento

Publicado el 26/05/2026

El problema

La primera versión de la app la escribí con StyleSheet.create. Funcionaba. Pero a las tres pantallas ya tenía:

  • Estilos duplicados. El mismo borderRadius: 12, padding: 12, backgroundColor: '#181c2e' repetido en seis sitios.
  • Tokens dispersos. El color #7c5cff aparecía en strings literales por toda la app. Un día decidí oscurecerlo y tuve que hacer find-and-replace por el repo. Inaceptable.
  • StyleSheet.create al final del archivo. Tenía que hacer scroll constante entre el JSX y el bloque de estilos. La distancia entre "decisión de diseño" y "código" era demasiada.
  • Componer estilos era verboso. Mezclar dos estilos condicionalmente acababa siendo style={[styles.card, isActive && styles.cardActive, pressed && styles.pressed]} y rezar.

No quería un design system de juguete. Quería tokens reutilizables, condicionalidad limpia y poder iterar visualmente igual de rápido que en una landing con Tailwind.

La decisión: NativeWind

NativeWind es Tailwind para React Native (y web). Te deja escribir className="px-4 py-2 bg-bg-soft" en componentes nativos y compila eso a estilos optimizados en build time. Soporta:

  • Tokens custom vía tailwind.config.js.
  • Variantes condicionales (hover:, active:, etc., con matices en RN).
  • El mismo className funcionando en iOS, Android y Web sin parchear nada.

Era exactamente lo que necesitaba.

Por qué no Tamagui, Restyle o styled-components

Los consideré:

  • Tamagui es brillante para apps grandes con muchas variantes. Para una v1 era usar un cañón para una mosca: setup pesado, conceptos nuevos (themes anidados, breakpoints, animaciones integradas).
  • Restyle (de Shopify) es buenísimo en TypeScript y theming, pero la sintaxis JSX-y propietaria me parecía un peaje innecesario cuando ya pienso en Tailwind para web.
  • styled-components lo descarté por su trade-off de runtime: cada estilo se evalúa en JS, no es lo mejor para listas largas (y aquí scrolleo cientos de emisoras).

NativeWind tiene la propiedad mágica que buscaba: la sintaxis es la misma que uso en web, así no cambio de modo mental al pasar de un proyecto a otro.

Instalación: tres pasos y un susto

La configuración fue más sencilla de lo que esperaba. Tres archivos:

babel.config.js:

module.exports = function (api) {
  api.cache(true);
  return {
    presets: [
      ['babel-preset-expo', { jsxImportSource: 'nativewind' }],
      'nativewind/babel',
    ],
  };
};

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' });

global.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

Y luego import '../global.css'; en el _layout.tsx raíz.

El susto: TypeScript no reconocía className en componentes de React Native. La solución fue añadir nativewind-env.d.ts:

/// <reference types="nativewind/types" />

Eso te activa los tipos para className en View, Text, Pressable, etc. Sin esto, el editor te subraya rojo todo el rato y te vuelves loco.

Los tokens: el activo más valioso del cambio

Aquí está la parte donde NativeWind dejó de ser "Tailwind portado" y empezó a ser un mini design system. Mi tailwind.config.js:

module.exports = {
  content: ['./app/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,jsx,ts,tsx}', './contexts/**/*.{js,jsx,ts,tsx}'],
  presets: [require('nativewind/preset')],
  theme: {
    extend: {
      colors: {
        bg: {
          DEFAULT: '#05060c',
          soft: '#0b0d18',
          card: '#11142099',
          elevated: '#181c2e',
        },
        ink: {
          DEFAULT: '#f5f7ff',
          muted: '#a5acc4',
          subtle: '#6b7290',
          dim: '#3d435a',
        },
        accent: {
          DEFAULT: '#7c5cff',
          hot: '#ff4d8b',
          glow: '#22d3ee',
          warm: '#f59e0b',
          live: '#22c55e',
        },
        border: {
          DEFAULT: '#1f2335',
          strong: '#2e3450',
        },
      },
      boxShadow: {
        glow: '0 18px 40px -16px rgba(124,92,255,0.55)',
        card: '0 12px 30px -16px rgba(0,0,0,0.6)',
      },
    },
  },
  plugins: [],
};

Esto me dio cosas que en StyleSheet.create puro nunca tuve:

  • Vocabulario consistente. bg-bg-soft, text-ink-muted, border-accent/40. Lees una clase y sabes a qué nivel de jerarquía visual pertenece.
  • Alpha en cualquier color. bg-accent/10, border-accent/40 — el /N te da opacidad con dos caracteres. Lo usé constantemente para estados "actual" o "hover".
  • Cambiar tema = tocar un fichero. Si decido mañana que el acento es #5b8def en vez de #7c5cff, lo cambio aquí y reza por nada más.

Antes y después: la diferencia se ve

Antes, una tarjeta de emisora:

<View style={styles.card}>
  <Text style={styles.name}>{station.name}</Text>
</View>

const styles = StyleSheet.create({
  card: {
    marginHorizontal: 16,
    marginBottom: 12,
    flexDirection: 'row',
    alignItems: 'center',
    borderRadius: 16,
    borderWidth: 1,
    borderColor: '#1f2335',
    backgroundColor: 'rgba(11,13,24,0.7)',
    padding: 12,
  },
  name: {
    fontSize: 15,
    fontWeight: '600',
    color: '#f5f7ff',
  },
});

Después:

<View className="mx-4 mb-3 flex-row items-center rounded-2xl border border-border bg-bg-soft/70 p-3">
  <Text className="text-[15px] font-semibold text-ink">{station.name}</Text>
</View>

No es solo más corto. Es que el JSX cuenta toda la historia sin tener que hacer scroll a un objeto al final del archivo.

Lo bonito: condicionalidad sin gimnasia

El caso que más usé. La misma tarjeta tiene un estado "actual" (la que se está reproduciendo) que se pinta distinto:

<Pressable
  className={`mx-4 mb-3 flex-row items-center rounded-2xl border p-3 ${
    isCurrent ? 'border-accent/60 bg-accent/10' : 'border-border bg-bg-soft/70'
  }`}
  style={({ pressed }) => ({ opacity: pressed ? 0.92 : 1 })}
>

Dos cosas a notar:

  1. Concateno strings de clases. Sí, en proyectos más grandes usaría clsx o tailwind-merge. Aquí, con tres condicionales por componente como mucho, el template string me sobraba.
  2. style sigue existiendo. Para cosas que NativeWind no cubre bien (animaciones con Reanimated, style dinámico de Pressable's pressed), uso la prop nativa. No es o uno o lo otro: es y los dos, y eso es una de las cosas que más me gustó.

El truco de las sombras

Una de las pequeñas miserias de React Native es que las sombras se configuran distinto en iOS (shadowColor, shadowOpacity, ...) y en Android (elevation). NativeWind tiene shadow-, pero para las sombras de color custom que quería (un glow morado bajo el botón de play), acabé combinando:

<Pressable
  className={`h-11 w-11 items-center justify-center rounded-full ${
    isCurrentPlaying ? 'bg-accent' : 'bg-bg-elevated border border-accent/40'
  }`}
  style={
    isCurrentPlaying
      ? {
          shadowColor: '#7c5cff',
          shadowOpacity: 0.5,
          shadowRadius: 12,
          shadowOffset: { width: 0, height: 4 },
          elevation: 6,
        }
      : undefined
  }
>

Esto cubre tanto iOS como Android sin librerías extras. Lo dejo como prop style porque mezclar tantos atributos en className no merecía la pena. NativeWind no te ata, te ayuda donde es útil y se aparta donde no.

Los rincones donde me peleé

  • prettier-plugin-tailwindcss. Lo añadí desde el día uno para que ordenara las clases. Sin esto, en dos semanas tienes p-4 bg-bg-soft rounded-2xl border mx-4 mb-3 mezclado con mx-4 rounded-2xl bg-bg-soft mb-3 border p-4 y la inconsistencia te muerde.
  • content paths. El primer día me dejé contexts/ fuera y un componente que vive ahí no aplicaba estilos. Tardé media hora en darme cuenta. Si una clase no se aplica, el primer sitio que reviso ya siempre es content de Tailwind.
  • No todo Tailwind funciona. Algunas utilidades web (gradients radiales, ciertos transforms) no se traducen. Si las necesito, uso expo-linear-gradient o style nativo. Pero el 95% del Tailwind que uso a diario funciona tal cual.

La conclusión que me llevo

NativeWind me dio dos cosas que StyleSheet.create no me daba: densidad visual (puedo leer una pantalla entera en una scroll y entenderla) y un vocabulario compartido entre web y móvil. El coste de aprenderlo si ya sabes Tailwind es cero. Si vienes de cero, es una semana de inversión y vuelve doblada.

Y lo más importante: iterar visualmente sin fricción. Cambiar el color de un acento, el radio de las tarjetas, el espaciado de una sección, son cambios de tres caracteres. Eso es lo que de verdad necesito en una app que mantengo yo solo a ratos.

En el próximo capítulo: cómo dividí un Context monolítico de favoritos+player en dos contextos independientes, y por qué eso resolvió bugs sutiles de re-render.