Módulos de CSS y las soluciones de tiempo de compilación
Cómo se conserva la ergonomía del CSS-in-JS sin su coste: extracción en build, el papel de las custom properties como única parte dinámica, y qué renuncia exige cada opción.
Cuando el coste de calcular estilos en el navegador dejó de ser aceptable, la industria no volvió atrás: movió el mismo trabajo al build. El resultado es una familia de herramientas que se parecen mucho por dentro, comparten exactamente la misma renuncia y la misma válvula de escape, y se diferencian sobre todo en la sintaxis y en cuánta información de tipos te devuelven. Entender el mecanismo común te ahorra evaluarlas una por una.
- Explicar qué hacen los módulos de CSS y por qué son el suelo del ecosistema.
- Describir el mecanismo común de las herramientas de extracción en compilación.
- Identificar la renuncia que todas comparten y la salida vía custom properties.
- Comparar las familias principales por lo que cuestan, no por su sintaxis.
Los módulos de CSS: el suelo
Un módulo de CSS es un fichero .module.css normal que el empaquetador procesa de una forma concreta: renombra cada clase local a un identificador único y te devuelve un objeto que mapea el nombre que escribiste al nombre generado.
/* tarjeta.module.css */
.raiz { padding: var(--espaciado-4); border-radius: 0.75rem; }
.titulo { font-size: 1.125rem; font-weight: 600; }
import estilos from './tarjeta.module.css';
// estilos.raiz === 'tarjeta_raiz_x7f2a'
Eso es todo el mecanismo, y resuelve el problema principal del CSS global con un coste de runtime de exactamente cero: lo que llega al navegador es una hoja de estilos normal con nombres feos. No hay serialización, no hay inserción, no hay contexto, no hay incompatibilidad con nada, y funciona igual en componentes de servidor que en cualquier otro sitio, porque el objeto de mapeo es un dato estático conocido en compilación.
Lo que aportan sobre CSS a secas es una sola garantía: no colisión, aplicada por la herramienta. Y lo que no aportan es todo lo demás: no hay tipos sobre los tokens, no hay recetas de variantes, no hay composición programática. Es la opción con menos dependencias, menos configuración y menos que aprender, y por eso sigue siendo el suelo sobre el que se apoya buena parte del ecosistema. Los estilos con ámbito de componente que traen de serie varios frameworks —el bloque de estilo con ámbito de un componente de una sola pieza— son el mismo mecanismo con otra presentación.
El mecanismo común de la extracción
Las herramientas tipadas —las que te dejan escribir estilos en TypeScript o con plantillas etiquetadas— hacen todas lo mismo, con variaciones:
- Durante el build, evalúan tu fichero de estilos en el entorno de compilación.
- De esa evaluación sacan un conjunto de reglas CSS y un conjunto de nombres de clase.
- Emiten las reglas a un fichero
.cssestático. - Sustituyen en tu código las llamadas por las cadenas literales de los nombres de clase.
El resultado es indistinguible de un módulo de CSS: una hoja estática y unas cadenas. Lo que ganas respecto a un módulo es que el fichero de origen es código, con lo que tienes tipos, autocompletado sobre los tokens, comprobación de que no te inventas un valor de la escala, y la posibilidad de generar variantes de forma programática.
// pseudocodigo representativo del modelo, no de una API concreta
export const tarjeta = estilo({
padding: tokens.espaciado[4],
borderRadius: tokens.radio.md,
selectors: {
'&:hover': { background: tokens.superficie.elevada },
},
});
El precio está en el paso 1 y es importante: tu fichero de estilos se ejecuta en el build. Eso significa que no puede depender de nada que solo exista en el navegador —ni de window, ni de props, ni de datos de red— y que un error en él es un error de compilación, no un fallo visual. Para muchos equipos eso último es una ventaja, no un coste.
La renuncia compartida y la válvula de escape
Todas estas herramientas renuncian a lo mismo: el estilo no puede depender de un valor arbitrario de runtime. Es la renuncia que hace posible eliminar el coste, y no es negociable en ninguna de ellas.
Y todas tienen la misma salida, que conviene saber ver porque es un patrón que también usarás en CSS escrito a mano: lo que varía en runtime se pasa como custom property.
<div className={estilos.barra} style={{ '--progreso': `${porcentaje}%` }} />
.barra::before {
inline-size: var(--progreso);
transition: inline-size 200ms ease-out;
}
La regla es estática y se conoce en el build; el valor viaja por una variable. Con esto se cubre la inmensa mayoría de los casos que motivaban el modelo dinámico: barras de progreso, posiciones, colores calculados, alturas medidas, cualquier magnitud continua. Lo que no cubre son los casos donde cambia la estructura de las reglas y no un valor, que son mucho más raros de lo que parece cuando revisas código real.
Merece la pena notar que este patrón es la razón técnica por la que las custom properties son tan centrales en el CSS moderno: son el único puente estándar entre el estado de la aplicación y la hoja de estilos que no exige generar CSS nuevo. Un valor cambia, la regla no.
Este patrón no necesita build. Un componente de servidor que escribe style="--progreso: 62%" en el HTML, con la regla en una hoja estática, tiene exactamente las mismas propiedades: cero JavaScript, cero coste de runtime, valor dinámico. Antes de adoptar una herramienta para esto, comprueba si tu caso ya está cubierto.
Las familias, por lo que cuestan
Ordenadas por cuánta maquinaria añaden, no por popularidad:
| Familia | Qué añade | Qué cuesta |
|---|---|---|
CSS plano con @layer y @scope |
nada | disciplina de nombres |
| Módulos de CSS | garantía de no colisión | una convención de nombres de fichero |
| Estilos con ámbito de componente | lo mismo, integrado en el framework | atarte al framework |
| Extracción tipada desde TypeScript | tipos, tokens verificados, recetas | evaluación en build, más configuración |
| Motor de utilidades | la escala aplicada por el compilador | ruido en el marcado, dependencia externa |
Fíjate en que la primera fila existe y es una opción real. Con capas en cascada, ámbito, custom properties y anidamiento nativo, un proyecto puede tener ámbito garantizado, orden garantizado y tokens verificables sin ninguna herramienta, siempre que el equipo mantenga la convención. Esa es exactamente la disyuntiva que planteaba la lección anterior sobre los dos argumentos: las herramientas de esta tabla se pagan en configuración y dependencias, y compran garantías que también se pueden obtener con disciplina.
Hay una consideración adicional que casi nunca se pondera y debería: cada una de estas herramientas es una apuesta sobre un empaquetador. Los módulos de CSS los soportan todos, y llevan una década haciéndolo. Las herramientas de extracción tipada requieren un plugin que alguien tiene que mantener al día con cada versión mayor de cada empaquetador, y esa es una fuente de fricción real que aparece precisamente cuando quieres actualizar otra cosa.
Si pones los cinco modelos uno al lado del otro y miras únicamente lo que llega al navegador, la conclusión es incómoda para todo el mundo: son el mismo artefacto. Una hoja de estilos estática con nombres de clase que nadie escribió a mano, más custom properties para lo que varía. Un módulo de CSS produce eso. Una extracción tipada produce eso. Un motor de utilidades produce eso, con nombres más cortos y más reglas atómicas. Y CSS escrito a mano con @scope produce eso mismo sin build. La diferencia entre ellos no está en el resultado sino en quién garantiza las invariantes y cuándo se entera de que las has roto: el compilador en tiempo de build, la herramienta al empaquetar, o una persona en la revisión de código. Eso reordena por completo cómo conviene elegir. La pregunta no es cuál produce mejor CSS —ninguno produce mejor CSS— sino cuál te avisa antes cuando te equivocas, y cuánta configuración estás dispuesto a mantener a cambio de ese aviso. Y tiene un corolario práctico muy útil: como todos convergen al mismo artefacto, migrar entre ellos es mucho más barato de lo que la gente teme. Lo que no es barato de migrar es tu capa de tokens y tus fronteras de componente, porque eso sí es modelo de dominio. Invierte ahí, y trata la herramienta de estilo como lo que es: un detalle de implementación reemplazable.