Saltar al contenido
Ver todas las notas
design-systemreactreact-nativetailwind

Un design system que sobrevive a tres aplicaciones

Compartir interfaz entre dos apps web y una móvil no va de compartir componentes: eso no se puede. Va de compartir tokens, lenguaje y decisiones, y de automatizar la regla que impide que el sistema se erosione solo.

· 10 min de lectura

El primer intento de casi todo el mundo —el mío incluido— es hacer un paquete de componentes y usarlo en las tres aplicaciones. Funciona mientras las tres sean web. En cuanto entra una app nativa, se rompe: en React Native no existe <div>, no existe className sin más, y un <button> no es un botón.

La conclusión incómoda llega pronto: entre web y móvil no se comparten componentes. Lo que se comparte es otra cosa, y resulta que es la que importaba.

Lo que sí se comparte#

Tres niveles, de más valioso a menos:

1. Los tokens#

Colores, escala tipográfica, espaciado, radios, duraciones. Se definen una vez y cada plataforma los consume a su manera: en web como variables CSS que alimentan un preset de Tailwind; en móvil como un preset de NativeWind más un objeto de tema.

/* tokens/colors.css — el único sitio del repositorio con un color literal */
:root {
  --background: 215 37% 6%;
  --surface: 213 37% 9%;
  --primary: 200 81% 52%;
  --foreground: 210 53% 94%;
}

Lo importante no es el archivo: es que ningún componente conoce un color. Todos usan bg-surface, text-foreground, border-border. El día que cambia la marca, cambia un archivo y se mueven las tres aplicaciones a la vez.

2. El lenguaje#

Esto vale más de lo que parece. Si en web el botón tiene variant="primary" y en móvil se llama type="filled", cada persona que cambia de plataforma vuelve a aprender el sistema.

Mismos nombres en todas partes: primary, secondary, ghost, link. Mismos tamaños: default, lg, icon. Mismos tonos de estado: success, warning, danger.

La implementación diverge —tiene que hacerlo—, pero la conversación no. Alguien puede decir "el botón secundario en el modal de pago" y significar lo mismo en las tres aplicaciones.

3. La lógica pura#

Formateadores de moneda y fecha, validaciones, helpers de zona horaria. Nada de esto toca el DOM, así que se comparte de verdad. Con una condición: no puede haber window ni document en el punto de entrada, o el empaquetador nativo revienta.

La escala tipográfica es el mejor ejemplo#

En web:

fontSize: {
  h1:   ['clamp(2rem, 3.5vw + 1rem, 3rem)', { lineHeight: '1.15' }],
  h2:   ['clamp(1.6rem, 1.6vw + 1rem, 2.1rem)', { lineHeight: '1.25' }],
  body: ['1rem', { lineHeight: '1.7' }],
}

En móvil no hay clamp() ni viewport fluido, así que son números fijos:

// theme/typography.ts, en lockstep con el preset de NativeWind
export const typography = {
  h1: 28,
  h2: 22,
  h3: 18,
  body: 16,
  caption: 13,
};

Las implementaciones no se parecen. Los nombres sí. Y esa es toda la ventaja: text-h2 significa "encabezado de sección" en las tres aplicaciones, aunque debajo sean 34px fluidos o 22px fijos.

Con una regla que hago cumplir sin excepciones: prohibido text-[17px] y prohibidas las clases por defecto de Tailwind (text-sm, text-lg). Si falta un tamaño, se agrega a la escala. Un tamaño suelto es una decisión de diseño tomada por accidente a las once de la noche.

Por qué prohíbo clonar componentes#

El patrón que mata un design system es este:

"El botón del ui-kit es casi lo que necesito, pero este va con el borde distinto. Lo copio y lo ajusto."

Ahora hay dos botones. En seis meses hay cinco, ninguno idéntico, y cambiar el foco de accesibilidad implica tocar cinco archivos y acordarse de los cinco.

La regla es simple y no admite excepciones cómodas:

  1. ¿Ya existe? Úsalo.
  2. ¿Es casi igual? Agrega una variant al original, en su paquete.
  3. ¿Es nuevo y se usará en más de un sitio? Va al paquete.
  4. ¿Es inequívocamente de un solo uso? Puede vivir junto a su pantalla.

Y cero overrides en el sitio de uso: nada de !border-0, style={{}} ni className="bg-[#123456]" para diferenciar. Si la diferencia es legítima, sube como propiedad al componente. Si no lo es, no debería existir.

Lo que NativeWind no hace, y cómo lo descubres tarde#

Esta es la parte que nadie cuenta y que cuesta días.

Con NativeWind, algunas utilidades no se aplican en tiempo de ejecución y fallan en silencio: no hay error, no hay aviso, simplemente el estilo no está.

Las que me han mordido:

UtilidadQué pasa
Margen negativo (-mt-*, -mx-*)No se aplica
Bordes por eje (border-y, border-x)No se aplica
A veces py-* y algunas alturasInconsistente
Imágenes remotas sin altura explícitaCaen a su tamaño intrínseco

La solución no es elegante y es la correcta: para posición, bordes, padding crítico y alturas de imagen remota, estilo inline numérico.

// El overlay tiene que estar exactamente ahí. `-mt-5` no funcionaría.
<View style={{ position: 'absolute', top: -20 }}>

Documentarlo es más importante que resolverlo. Un fallo silencioso que no está escrito en ningún sitio se vuelve a cometer cada vez que entra alguien nuevo —y cada vez cuesta la misma tarde de diagnóstico.

Un design system no se rompe: se erosiona#

Nadie decide destruirlo. Lo que pasa es que un martes con prisa alguien pone un #2ecc71 porque "es solo este caso". Y funciona, y pasa la revisión, y en tres meses hay catorce.

Por eso las reglas que sostienen el sistema no pueden ser acuerdos de equipo. Tienen que fallar solas:

// El lint rechaza los tres patrones que erosionan el sistema
'no-restricted-syntax': ['error',
  { selector: 'JSXAttribute[name.name="className"] > Literal[value=/bg-\\[#/]',
    message: 'Color hardcoded. Usa las utilidades del tema.' },
  { selector: 'JSXAttribute[name.name="className"] > Literal[value=/text-\\[[0-9]/]',
    message: 'Tamaño arbitrario. Usa la escala.' },
  { selector: 'JSXAttribute[name.name="style"]',
    message: 'Estilo inline. Sube la variante al paquete.' },
];

Con eso, el color suelto no llega ni a la revisión de código: rompe el build en la máquina de quien lo escribió, que es el único momento en que arreglarlo es barato.

Cuándo no montaría un design system#

Con una sola aplicación, no hace falta. Una carpeta components/ bien ordenada hace el mismo trabajo sin el costo de mantener un paquete, versionarlo y documentarlo.

Empieza a compensar cuando:

  • Hay dos o más aplicaciones que deben verse igual.
  • Hay dos o más personas tomando decisiones visuales.
  • El producto va a durar lo suficiente como para que la marca cambie al menos una vez.

Si nada de eso se cumple, un paquete de UI es una capa de indirección que solo añade archivos que leer. Y si se cumple, cada semana que se retrasa hace la migración más cara.

¿Tienes un sistema con este tipo de problemas?

Cuéntame en qué estás y te digo cómo lo abordaría yo.

Hablemos de tu sistema