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
#7c5cffaparecí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.createal 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
classNamefuncionando 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/Nte 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
#5b8defen 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:
- Concateno strings de clases. Sí, en proyectos más grandes usaría
clsxotailwind-merge. Aquí, con tres condicionales por componente como mucho, el template string me sobraba. stylesigue existiendo. Para cosas que NativeWind no cubre bien (animaciones conReanimated,styledinámico dePressable'spressed), 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 tienesp-4 bg-bg-soft rounded-2xl border mx-4 mb-3mezclado conmx-4 rounded-2xl bg-bg-soft mb-3 border p-4y la inconsistencia te muerde.contentpaths. 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 escontentde Tailwind.- No todo Tailwind funciona. Algunas utilidades web (gradients radiales, ciertos
transforms) no se traducen. Si las necesito, usoexpo-linear-gradientostylenativo. 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.