Módulo 07: Estilos en React
Objetivos del módulo
- Entender que React no impone ninguna forma de escribir CSS y conocer las técnicas disponibles
- Importar CSS global y ver por qué dos componentes pueden pisarse los estilos
- Aislar los estilos de cada componente con CSS Modules,
composesy:global - Usar estilos en línea cuando el valor se calcula en ejecución, y variables CSS cuando además necesitas
:hovero@media - Construir clases condicionales sin errores, a mano y con
clsx - Saber qué es CSS-in-JS, medir su coste real y entender por qué ha perdido terreno
- Configurar Tailwind CSS 4 con
@tailwindcss/vite, definir un tema con@themey evitar sus trampas - Implementar un tema claro, oscuro y de sistema, con persistencia y sin parpadeo al cargar
- Declarar
<style>y<link>dentro de un componente y controlar su orden conprecedence - Cambiar el título y los metadatos de la página desde cualquier componente
- Elegir la técnica adecuada para cada caso
💡 Consejo: Solo las secciones 9 y 10 llevan la marca 🆕. El resto del módulo es CSS estándar y herramientas externas, así que funciona igual con React 18.3 y con React 19.3.
1. Cuántas formas hay de dar estilo en React
React se encarga de crear y actualizar elementos del DOM, pero no tiene opinión sobre el CSS. Lo único que aporta son dos props: className, que acaba en el atributo class, y style, que acaba en el atributo style. Todo lo demás lo ponen el navegador y tus herramientas. Por eso, si preguntas a diez equipos cómo escriben sus estilos, oirás varias respuestas.
Esa libertad tiene un problema de fondo. CSS nació para documentos y todas sus reglas son globales: un selector .titulo afecta a cualquier elemento de la página que tenga esa clase, lo haya creado quien lo haya creado. En cambio, una aplicación de React es un árbol de cientos de componentes escritos por personas distintas. Casi todas las técnicas de este módulo responden a la misma pregunta: ¿cómo evito que los estilos de un componente afecten a otro?
Se diferencian en dos cosas: hasta dónde llega cada regla (su ámbito) y cuándo se produce el CSS, si al compilar o mientras la aplicación se ejecuta en el navegador. Esta segunda diferencia es la que más afecta al rendimiento.
Figura 1. CSS global, CSS Modules y Tailwind producen un fichero .css al compilar. Los estilos en línea y el CSS-in-JS en ejecución trabajan en el navegador.
📘 Concepto: El ámbito de un estilo es el conjunto de elementos a los que puede afectar. Una técnica aísla bien cuando el ámbito coincide con el componente que escribió la regla.
Prepara el proyecto
Crea un proyecto como en el módulo 01, donde se explica también qué cambia si usas PowerShell:
npm create vite@latest modulo-07 -- --template react-ts
cd modulo-07
npm install
npm run dev
Cada ejemplo completo sustituye a src/App.tsx, y sus ficheros auxiliares van junto a él en src/. Si dos ejemplos usan el mismo nombre de fichero, sobrescríbelo o crea una carpeta por ejemplo. Borra src/App.css y sustituye src/index.css por estas reglas, salvo en la sección de Tailwind, que trae las suyas:
:root {
font-family: 'Segoe UI', system-ui, sans-serif;
color: #263238;
background: #f5f6fa;
}
body {
margin: 0;
padding: 1.25rem;
}
h2 {
margin: 0 0 0.75rem;
font-size: 1.15rem;
color: #1a237e;
}
.fila {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
2. CSS global
Es lo que ya sabes hacer: un fichero .css de toda la vida. La única novedad es cómo llega a la página. En lugar de un <link> en el HTML, lo importas desde JavaScript:
import './TarjetaEmpleado.css';
Este import no es JavaScript estándar, sino una función de Vite. En desarrollo, Vite convierte el CSS en un módulo que crea un <style> en <head>, lo que permite el reemplazo en caliente: cambias un color y lo ves sin perder el estado. Al compilar con npm run build, reúne el CSS en ficheros .css dentro de dist/assets y los enlaza desde el HTML.
Importar el CSS en el componente que lo usa da una falsa sensación de aislamiento. Parece que ese CSS pertenece al componente, pero no es así: el import solo decide que el fichero se cargue. Una vez cargado, sus reglas valen para toda la página. Veámoslo con dos componentes de la aplicación EMPRESA escritos por separado, que han elegido los mismos nombres de clase.
src/TarjetaEmpleado.css:
.tarjeta {
width: 240px;
padding: 0.75rem 1rem;
background: #e8eaf6;
border-left: 6px solid #3f51b5;
border-radius: 6px;
}
.titulo {
margin: 0 0 0.25rem;
font-size: 1rem;
color: #1a237e;
}
src/TarjetaProducto.css:
.tarjeta {
width: 240px;
padding: 0.75rem 1rem;
background: #fff3e0;
border-left: 6px solid #ff7043;
border-radius: 6px;
}
.titulo {
margin: 0 0 0.25rem;
font-size: 1rem;
color: #bf360c;
}
src/TarjetaEmpleado.tsx:
import './TarjetaEmpleado.css';
interface Props {
nombre: string;
oficio: string;
}
export function TarjetaEmpleado({ nombre, oficio }: Props) {
return (
<article className="tarjeta">
<h3 className="titulo">{nombre}</h3>
<p>{oficio}</p>
</article>
);
}
src/TarjetaProducto.tsx:
import './TarjetaProducto.css';
interface Props {
nombre: string;
precio: number;
}
export function TarjetaProducto({ nombre, precio }: Props) {
return (
<article className="tarjeta">
<h3 className="titulo">{nombre}</h3>
<p>{precio} €</p>
</article>
);
}
src/App.tsx:
import { TarjetaEmpleado } from './TarjetaEmpleado';
import { TarjetaProducto } from './TarjetaProducto';
export default function App() {
return (
<div className="fila">
<TarjetaEmpleado nombre="Ana García López" oficio="DIRECTOR GENERAL" />
<TarjetaProducto nombre="Portátil UltraBook 14" precio={899} />
</div>
);
}
El resultado es que la tarjeta del empleado sale naranja, igual que la del producto. Los dos títulos tienen el color rgb(191, 54, 12), medido en el navegador con getComputedStyle:

Figura 2. La tarjeta del empleado debería ser azul, pero las reglas del producto la han pisado.
Las dos reglas .tarjeta tienen la misma especificidad, así que gana la última que aparece. El orden depende de los import: TarjetaProducto se importa después en App.tsx. Si cambias el orden de esas dos líneas, la que se estropea es la tarjeta del producto. Un error así es difícil de depurar porque quien lo provoca no está en el fichero que falla, y puede aparecer al reordenar un import sin relación aparente.
⚠️ Importante: El CSS sigue cargado aunque el componente se desmonte. Si un componente importa
Informe.cssy sales de esa pantalla, sus reglas siguen en la página. Además, cuando divides la aplicación en trozos que se cargan bajo demanda (módulo 14), el orden de las reglas puede depender de qué pantalla visitó antes el usuario.
Cuándo usar CSS global
No hay que evitarlo siempre. Es lo correcto para lo que debe ser global:
- La base del documento:
body, tipografía, enlaces ybox-sizing. - Las variables de diseño en
:root, como verás en la sección 8. - Un reinicio de estilos, o el CSS de una biblioteca externa.
Impórtalo una sola vez, en main.tsx. Es lo que hace la plantilla de Vite con src/index.css.
Durante años, la solución para los componentes fue una convención de nombres como BEM (tarjeta-empleado__titulo--destacado): si todos los nombres son largos y únicos, no chocan. Funciona, pero depende de la disciplina de cada persona del equipo y nada avisa si alguien se equivoca. CSS Modules hace ese trabajo de forma automática.
3. CSS Modules
Un fichero que termina en .module.css es un módulo CSS. Se escribe como CSS normal, pero Vite renombra cada clase para que sea única y te entrega un objeto con la traducción. No hay que instalar ni configurar nada. Basta con cambiar el nombre del fichero y la forma de usar las clases:
import estilos from './TarjetaEmpleado.module.css';
interface Props {
nombre: string;
oficio: string;
}
export function TarjetaEmpleado({ nombre, oficio }: Props) {
return (
<article className={estilos.tarjeta}>
<h3 className={estilos.titulo}>{nombre}</h3>
<p>{oficio}</p>
</article>
);
}
TarjetaProducto.tsx cambia de la misma forma y App.tsx no cambia. El CSS de TarjetaEmpleado.module.css y TarjetaProducto.module.css es exactamente el de antes, con .tarjeta y .titulo repetidos. Ahora cada tarjeta muestra su color:

Figura 3. El mismo CSS, renombrado a .module.css, ya no choca.
Si inspeccionas el elemento, verás clases generadas. Estas son las que ha producido Vite 8 en nuestro proyecto, y son iguales en desarrollo y en el build:
TarjetaEmpleado.module.css .tarjeta -> _tarjeta_16ym1_1 .titulo -> _titulo_16ym1_9
TarjetaProducto.module.css .tarjeta -> _tarjeta_9nxf2_1 .titulo -> _titulo_9nxf2_9
El formato es _nombre_hash_línea: el nombre original, un hash que depende del fichero y la línea donde está la regla. El hash resuelve la colisión, porque dos ficheros distintos dan dos hashes distintos. Y como el nombre original se conserva, sigues pudiendo leer la clase en las herramientas del navegador.
Figura 4. Vite renombra las clases en el CSS y te entrega un objeto que traduce cada nombre.
📘 Concepto:
estiloses un objeto de JavaScript corriente.estilos.tarjetaes una cadena, y por eso la puedes pasar aclassName, guardarla en una variable o combinarla con otras.
Reutilizar clases con composes
composes hace que una clase incluya a otra. Sirve también entre ficheros:
src/comun.module.css:
.sombra {
box-shadow: 0 2px 6px rgb(0 0 0 / 0.25);
}
src/Aviso.module.css:
.caja {
padding: 0.5rem 0.75rem;
border-radius: 6px;
background: #eceff1;
}
.alerta {
composes: caja;
composes: sombra from './comun.module.css';
background: #ffebee;
}
.texto-largo {
max-width: 40ch;
}
.caja :global(a) {
color: #bf360c;
}
import aviso from './Aviso.module.css';
export function Aviso() {
return (
<p className={aviso.alerta}>
Stock bajo. <a href="#">Ver productos</a>
</p>
);
}
composes no copia reglas: añade más clases al elemento. Lo vemos en lo que se recibe al importar:
{
"caja": "_caja_f7wj7_1",
"alerta": "_alerta_f7wj7_7 _caja_f7wj7_1 _sombra_cx7gy_1",
"texto-largo": "_texto-largo_f7wj7_13"
}
aviso.alerta contiene tres clases separadas por espacios. Por eso el elemento recibe el fondo de alerta y la sombra de sombra a la vez.
💡 Consejo: Con
composes ... fromen Vite 8 verás al arrancar y al compilar este aviso:A PostCSS plugin did not pass the from option to postcss.parse. Lo hemos reproducido en un proyecto sin Tailwind y desaparece al quitar esa línea. Los estilos se generan bien: la sombra y las clases aparecen como acabas de ver.
Salir del ámbito con :global
En .caja :global(a) el selector combina dos mundos. .caja se renombra, y a queda tal cual. Es útil cuando no controlas el HTML interior, por ejemplo con contenido que viene de un editor o de una biblioteca. Con una etiqueta como a es opcional, porque las etiquetas no se renombran. Es imprescindible con clases ajenas, como .caja :global(.enlace-externo).
Nombres con guion
estilos.texto-largo no es JavaScript válido, porque se leería como una resta. Tienes tres opciones:
<p className={aviso['texto-largo']}>...</p>
La segunda es escribir las clases en camelCase en el CSS (.textoLargo), que es lo más habitual. La tercera es convertir los nombres de forma automática con la opción css.modules.localsConvention de vite.config.ts.
export default defineConfig({
plugins: [react()],
css: { modules: { localsConvention: 'camelCaseOnly' } },
});
El tipo de las clases y las erratas
La plantilla incluye los tipos de vite/client, y por eso TypeScript acepta el import de un .module.css. Pero esos tipos describen un objeto con cualquier clave, de modo que TypeScript no detecta las erratas:
<p className={estilos.noExiste}>y</p>
Compila sin errores. En ejecución, estilos.noExiste es undefined y React omite el atributo:
<p>y</p>
No hay aviso ni fallo: simplemente el elemento no tiene estilos. Cuando un estilo no se aplique, lo primero que debes mirar en las herramientas del navegador es si el atributo class existe.
Sass y otros preprocesadores
Vite también admite Sass: instala sass-embedded y usa .scss o .module.scss. Hoy lo necesitas menos que antes, porque CSS ya tiene variables, anidamiento y funciones de color. No lo usaremos en el curso.
4. Estilos en línea
La prop style recibe un objeto y lo aplica directamente al elemento. Tiene sentido cuando el valor no se conoce hasta la ejecución: el ancho de una barra calculado a partir de los datos, la posición de un elemento o un color que viene de la API. Crear una clase para cada porcentaje posible sería absurdo.
src/BarraStock.tsx:
interface Props {
nombre: string;
stock: number;
maximo: number;
}
export function BarraStock({ nombre, stock, maximo }: Props) {
const porcentaje = Math.min(100, (stock / maximo) * 100);
const color = stock < 5 ? '#c62828' : stock < 20 ? '#ef6c00' : '#2e7d32';
return (
<div style={{ marginBottom: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14 }}>
<span>{nombre}</span>
<strong>{stock}</strong>
</div>
<div style={{ height: 10, background: '#eceff1', borderRadius: 5 }}>
<div
style={{
width: `${porcentaje}%`,
height: '100%',
backgroundColor: color,
borderRadius: 5,
}}
/>
</div>
</div>
);
}

Figura 5. Barras cuyo ancho y color se calculan a partir de los datos de cada producto.
Las reglas de style:
- Las propiedades van en camelCase:
backgroundColor, nobackground-color. Las de prefijo de fabricante empiezan por mayúscula:WebkitLineClamp. - Las dobles llaves no son una sintaxis especial: las de fuera abren una expresión JSX y las de dentro son el objeto. Se explicó en el módulo 02.
- Un número se convierte en píxeles, salvo en las propiedades que no tienen unidad.
- Los valores
null,undefinedyfalseeliminan la propiedad.
Esta tabla muestra lo que React ha escrito realmente en el atributo style:
| Objeto | Atributo style resultante | Por qué |
|---|---|---|
{ padding: 8 } | padding: 8px; | Número: añade px |
{ margin: 0 } | margin: 0px; | También con el cero |
{ opacity: 0.5, zIndex: 2 } | opacity: 0.5; z-index: 2; | Propiedades sin unidad |
{ lineHeight: 1.5, flexGrow: 1 } | line-height: 1.5; flex-grow: 1; | Propiedades sin unidad |
{ fontWeight: 700 } | font-weight: 700; | Propiedad sin unidad |
{ width: '50%' } | width: 50%; | Cadena: se copia tal cual |
{ WebkitLineClamp: 2 } | -webkit-line-clamp: 2; | Prefijo y sin unidad |
{ color: undefined } | sin atributo | No se aplica |
{ color: 'red !important' } | sin atributo | !important no se admite: se descarta en silencio |
La última fila merece atención. No hay aviso ni error: la declaración se pierde. Si necesitas !important, usa una clase.
TypeScript sí te protege aquí, y conviene comparar con lo que pasaba con las erratas de CSS Modules. Estos tres objetos no compilan:
style={{ 'background-color': 'red' }}
error TS2353: Object literal may only specify known properties, and ''background-color''
does not exist in type 'Properties<string | number, string & {}>'.
<div style={{ ':hover': { color: 'red' } }} /> // error TS2353
<div style={{ '--uso': '40%' }} /> // error TS2353
Lo que style no puede hacer
Un atributo style solo contiene declaraciones para ese elemento. Como no hay selector, no hay forma de escribir :hover, :focus-visible, ::before, @media ni animaciones con @keyframes. Además, tiene más prioridad que cualquier regla de una hoja de estilos sin !important, lo que dificulta sobrescribirlo desde fuera. Y cada elemento lleva su propia copia del texto, que no se comparte.
Por eso no se usa style como forma general de maquetar. Si un estilo es fijo, debe ir en un fichero CSS. El ejemplo de BarraStock mezcla ambos a propósito para que veas la sintaxis, pero lo razonable sería dejar en style solo width y backgroundColor.
⚠️ Importante: Un objeto
styleescrito dentro del JSX se crea de nuevo en cada render. React compara propiedad por propiedad y solo toca el DOM si algo cambió, así que no vale la pena preocuparse por esto. Lo que sí se nota es la suma de muchos elementos con muchos estilos: otra razón para usar clases.
Lo mejor de los dos mundos: variables CSS
Puedes pasar solo el dato como variable CSS y dejar las reglas en la hoja de estilos. Así tienes valores calculados y además :hover, transition y @media:
src/Medidor.css:
.medidor {
height: 10px;
background: #eceff1;
border-radius: 5px;
}
.medidor__relleno {
width: var(--porcentaje);
height: 100%;
background: var(--color);
border-radius: 5px;
transition: width 0.3s;
}
.medidor__relleno:hover {
filter: brightness(1.2);
}
@media (prefers-reduced-motion: reduce) {
.medidor__relleno {
transition: none;
}
}
src/Medidor.tsx:
import type { CSSProperties } from 'react';
import './Medidor.css';
interface Props {
porcentaje: number;
color: string;
}
export function Medidor({ porcentaje, color }: Props) {
const variables = {
'--porcentaje': `${porcentaje}%`,
'--color': color,
} as CSSProperties;
return (
<div className="medidor">
<div className="medidor__relleno" style={variables} />
</div>
);
}
src/App.tsx, que usa los dos componentes:
import { BarraStock } from './BarraStock';
import { Medidor } from './Medidor';
const productos = [
{ productoId: 1, nombre: 'Portátil UltraBook 14', stock: 25 },
{ productoId: 2, nombre: 'Portátil Gaming 16', stock: 10 },
{ productoId: 5, nombre: 'Torre Workstation', stock: 4 },
{ productoId: 7, nombre: 'Teclado mecánico', stock: 60 },
{ productoId: 8, nombre: 'Pack teclado y ratón', stock: 0 },
];
export default function App() {
return (
<section style={{ maxWidth: 420 }}>
<h2>Stock por producto</h2>
{productos.map((p) => (
<BarraStock key={p.productoId} nombre={p.nombre} stock={p.stock} maximo={60} />
))}
<h2 style={{ marginTop: 16 }}>Ocupación del almacén</h2>
<Medidor porcentaje={64} color="#3f51b5" />
</section>
);
}
Las variables CSS son la excepción a la conversión a píxeles. { '--n': 10 } produce --n: 10;, sin px, porque React no puede saber qué significa esa variable.
as CSSProperties hace falta porque los tipos de React no conocen --porcentaje, como has visto en el error anterior. Es un aviso a TypeScript de que confíe en ti. Si lo usas mucho, puedes ampliar el tipo una vez para todo el proyecto, y ya no necesitarás el as. Crea un fichero src/css-variables.d.ts:
import 'react';
declare module 'react' {
interface CSSProperties {
[variable: `--${string}`]: string | number;
}
}
La sintaxis declare module se explica en el módulo 13.
💡 Consejo: Esta regla práctica resume la sección:
stylepara el dato que cambia, CSS para todo lo demás, y una variable CSS como puente entre ambos.
5. Clases condicionales
En una interfaz real, las clases dependen del estado: la fila seleccionada, el botón desactivado, el campo con error. className es una simple cadena, así que todo se reduce a construir esa cadena sin equivocarse.
A mano
<li className={activo ? 'item activo' : 'item'}>...</li>
<li className={`item ${activo ? 'activo' : ''}`}>...</li>
La plantilla funciona, pero tiene trampas. La más típica es usar &&, como se hace en el JSX:
// ❌ MAL: si activo es false, el elemento recibe la clase "false"
<li className={`item ${activo && 'activo'}`}>...</li>
Dentro de una plantilla, false se convierte en el texto "false", y undefined en "undefined". El resultado es class="item false". No rompe nada visible, pero ensucia el DOM y puede coincidir con una clase real. Con más de dos condiciones el código se vuelve además ilegible. Una alternativa sin dependencias es filtrar un array:
const clases = ['item', activo && 'activo', error && 'error'].filter(Boolean).join(' ');
Con clsx
clsx es una pequeña función que hace eso mismo y acepta más formas de expresarlo. Es tan habitual que la encontrarás en casi cualquier proyecto:
npm install clsx
Hemos usado la versión 2.1.1. Admite cadenas, objetos y arrays, e ignora todo lo que sea falso:
import clsx from 'clsx';
const activo = true, error = false, usuario = null, n = 0;
clsx('boton', activo && 'activo', error && 'error'); // "boton activo"
clsx('boton', { activo, error }); // "boton activo"
clsx(['a', ['b', { c: true }]], usuario, undefined, n, 'd'); // "a b c d"
Con CSS Modules hay una trampa. Las claves calculadas se convierten en texto, y si te equivocas en el nombre aparece una clase llamada undefined:
clsx('boton', { [estilos.cargnado]: true }); // "boton undefined"
Por eso, con módulos, es más seguro el estilo condicion && estilos.clase: si la clase no existe, el resultado es undefined y clsx lo ignora.
Un componente con variantes
Lo más útil es reunir las clases en un componente, de forma que quien lo use elija una variante con una prop tipada. Es el botón que usaríamos en toda la aplicación EMPRESA.
src/Boton.module.css:
.boton {
padding: 0.5rem 1rem;
font: inherit;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
}
.boton:focus-visible {
outline: 3px solid #ff7043;
outline-offset: 2px;
}
.boton:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.primario {
color: #fff;
background: #3f51b5;
}
.primario:hover:enabled {
background: #283593;
}
.secundario {
color: #283593;
background: #fff;
border-color: #3f51b5;
}
.secundario:hover:enabled {
background: #e8eaf6;
}
.peligro {
color: #fff;
background: #c62828;
}
.peligro:hover:enabled {
background: #8e0000;
}
.bloque {
display: block;
width: 100%;
}
.cargando {
cursor: progress;
}
src/Boton.tsx:
import type { ComponentProps } from 'react';
import clsx from 'clsx';
import estilos from './Boton.module.css';
type Variante = 'primario' | 'secundario' | 'peligro';
interface BotonProps extends ComponentProps<'button'> {
variante?: Variante;
bloque?: boolean;
cargando?: boolean;
}
export function Boton({
variante = 'primario',
bloque = false,
cargando = false,
disabled,
className,
children,
...resto
}: BotonProps) {
return (
<button
{...resto}
disabled={disabled || cargando}
aria-busy={cargando}
className={clsx(
estilos.boton,
estilos[variante],
bloque && estilos.bloque,
cargando && estilos.cargando,
className,
)}
>
{cargando ? 'Guardando...' : children}
</button>
);
}
Fíjate en estos detalles:
estilos[variante]usa la prop como clave, así que el nombre de la variante coincide con el de la clase CSS, y el tipoVariantelimita los valores posibles.classNameva al final declsxpara que quien use el componente pueda añadir clases propias.{...resto}reenvíaonClick,typey cualquier otra prop de<button>. Va al principio para quedisabledyclassNameno se puedan sobrescribir por accidente.ComponentProps<'button'>son todas las props de un botón nativo. Se estudia en el módulo 13.- El estado visual se escribe con pseudoclases como
:hover:enabledy:disabled. No necesitan React.
Este es el resultado de <Boton variante="secundario" bloque className="mi-clase-extra">:
<button aria-busy="false" class="_boton_1il61_1 _secundario_1il61_28 _bloque_1il61_47 mi-clase-extra">
El estado como atributo
Muchas veces no necesitas una clase condicional. Si el estado ya está en un atributo, el CSS puede leerlo directamente:
src/TablaEmpleados.tsx:
import { useState } from 'react';
import estilos from './TablaEmpleados.module.css';
const empleados = [
{ empId: 100, nombre: 'Ana García López', oficio: 'DIRECTOR GENERAL', salario: 6500 },
{ empId: 101, nombre: 'Luis Martínez Ruiz', oficio: 'DIRECTOR', salario: 4800 },
{ empId: 110, nombre: 'Carmen Ruiz Ortega', oficio: 'VENDEDOR', salario: 2200 },
{ empId: 111, nombre: 'David Torres Romero', oficio: 'VENDEDOR', salario: 2100 },
];
export function TablaEmpleados() {
const [seleccionado, setSeleccionado] = useState<number | null>(101);
return (
<table className={estilos.tabla}>
<thead>
<tr>
<th>Empleado</th>
<th>Oficio</th>
<th>Salario</th>
</tr>
</thead>
<tbody>
{empleados.map((e) => (
<tr
key={e.empId}
data-seleccionada={e.empId === seleccionado}
onClick={() => setSeleccionado(e.empId)}
>
<td>{e.nombre}</td>
<td>{e.oficio}</td>
<td className={e.salario >= 4000 ? estilos.salarioAlto : undefined}>
{e.salario} €
</td>
</tr>
))}
</tbody>
</table>
);
}
src/TablaEmpleados.module.css:
.tabla {
width: 100%;
border-collapse: collapse;
background: #fff;
}
.tabla th,
.tabla td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #c5cae9;
}
.tabla tbody tr {
cursor: pointer;
}
.tabla tbody tr:hover {
background: #eceff1;
}
.tabla tbody tr[data-seleccionada='true'] {
color: #fff;
background: #3f51b5;
}
.salarioAlto {
font-weight: 700;
}
React escribe data-seleccionada="true" o "false", y el selector se encarga del resto. Este enfoque tiene una ventaja importante con los atributos ARIA: si escribes .pestana[aria-selected='true'], el estilo depende del mismo dato que usa el lector de pantalla, y es imposible que lo visual y lo accesible se desincronicen. Lo verás en el ejercicio 3.
src/App.tsx:
import { Boton } from './Boton';
import { TablaEmpleados } from './TablaEmpleados';
export default function App() {
return (
<section style={{ maxWidth: 560 }}>
<h2>Variantes del botón</h2>
<div className="fila" style={{ marginBottom: 12 }}>
<Boton>Guardar</Boton>
<Boton variante="secundario">Cancelar</Boton>
<Boton variante="peligro">Eliminar</Boton>
<Boton cargando>Guardar</Boton>
<Boton disabled>Sin permiso</Boton>
</div>
<Boton variante="secundario" bloque className="mi-clase-extra">
Nuevo empleado
</Boton>
<h2 style={{ marginTop: 20 }}>Fila seleccionada</h2>
<TablaEmpleados />
</section>
);
}

Figura 6. Las variantes del botón y la fila seleccionada mediante un atributo data-*.
💡 Consejo: Esta tabla se usa solo con ratón, para no alargar el ejemplo. Una tabla real debe poder manejarse con teclado. Lo veremos en el módulo 24.
6. CSS-in-JS y por qué ha decaído
Durante años, muchos proyectos de React escribían el CSS dentro de JavaScript con bibliotecas como styled-components o Emotion. Aunque hoy no sea lo recomendable para empezar un proyecto, te la encontrarás en muchas aplicaciones, y conocer sus motivos te ayuda a valorar las técnicas actuales.
Cómo funciona
npm install styled-components
Hemos usado la versión 6.6.0. Declaras un componente con su CSS en una plantilla, que puede depender de las props:
import styled from 'styled-components';
const Caja = styled.article<{ $destacada?: boolean }>`
width: 240px;
padding: 0.75rem 1rem;
background: ${(props) => (props.$destacada ? '#fff3e0' : '#e8eaf6')};
border-left: 6px solid ${(props) => (props.$destacada ? '#ff7043' : '#3f51b5')};
border-radius: 6px;
&:hover {
box-shadow: 0 2px 6px rgb(0 0 0 / 0.25);
}
`;
const Titulo = styled.h3`
margin: 0 0 0.25rem;
font-size: 1rem;
color: #1a237e;
`;
interface Props {
nombre: string;
destacada?: boolean;
}
export function Tarjeta({ nombre, destacada }: Props) {
return (
<Caja $destacada={destacada}>
<Titulo>{nombre}</Titulo>
</Caja>
);
}
Las ventajas se ven enseguida: el CSS está junto al componente, los valores dependen de las props sin clases condicionales y los estilos no chocan. El prefijo $ indica que $destacada es solo para el estilo y no debe llegar como atributo al DOM.
La clave es lo que pasa mientras se ejecuta. Estas son las clases reales de nuestras dos tarjetas:
ARTICLE sc-bdnzMm cLuthR (normal)
H3 sc-gtsusp hPNkXB
ARTICLE sc-bdnzMm cFdOUW (destacada)
H3 sc-gtsusp hPNkXB
La biblioteca evalúa la plantilla con las props, calcula un hash del resultado y usa ese hash como clase. Si la clase no existía, inserta la regla en un <style>. Por eso hay dos clases cLuthR y cFdOUW para el mismo componente.
Figura 7. El CSS-in-JS en ejecución añade trabajo a cada render, y el navegador debe recalcular estilos cuando aparecen reglas nuevas.
Medir el coste
Hemos hecho tres pruebas en nuestro laboratorio, con el mismo componente en las dos técnicas.
Reglas generadas. Si un valor cambia a menudo, cada valor nuevo crea una regla. Un styled.div con width: ${(p) => p.$ancho}px, pulsando 50 veces un botón que suma 1:
Antes: 6 reglas en la hoja de estilos
Tras 50 clics: 56 reglas en la hoja de estilos
Esas reglas no se borran. Por cierto, en las herramientas del navegador verás el <style data-styled> vacío, porque las reglas se insertan por la API del CSSOM y no como texto. Para valores tan cambiantes, la propia técnica exige usar style.
Peso. El build de Vite separa la biblioteca en su propio fichero: 25,81 kB, o 10,69 kB con gzip. Es JavaScript que hay que descargar, analizar y ejecutar antes de ver el primer estilo. CSS Modules añade 0 kB de biblioteca, y Tailwind tampoco: solo un fichero CSS.
Tiempo de render. Cambiamos una prop de 5000 elementos, cada uno con un div y un span, en un build de producción sobre Chromium, y medimos hasta el final de la actualización del DOM, sin contar el pintado:
| Técnica | Mediana de 11 ejecuciones | Mínimo | Máximo |
|---|---|---|---|
| CSS Modules | 26,6 ms | 22,1 ms | 37,4 ms |
| styled-components 6.6.0 | 84,3 ms | 78,1 ms | 107,5 ms |
Más o menos el triple. No te quedes con la cifra exacta, que depende de la máquina y del caso, sino con el orden de magnitud. En una pantalla de 50 elementos no lo notarás. En una tabla grande que se actualiza a menudo, sí.
Por qué ha decaído
No hubo un único motivo, sino varios que se sumaron:
- Rendimiento. En octubre de 2022, Sam Magura, uno de los principales mantenedores de Emotion, publicó Why We’re Breaking Up with CSS-in-JS. Explicaba que en su empresa, Spot, un componente tardaba de media 54,3 ms en renderizar con Emotion y 27,7 ms al pasarlo a Sass Modules. Advierte que esos números corresponden a su uso concreto. Coincide con lo que acabamos de medir.
- Concurrencia. Insertar reglas durante el render obliga al navegador a recalcular estilos. La documentación de
useInsertionEffect, un hook creado para estas bibliotecas (módulo 09), dice que la inyección en ejecución no es recomendable: fuerza a recalcular estilos más a menudo, y ese problema no tiene arreglo. Lo que sí resuelve el hook es el momento en que se hace. - Server Components. El anuncio que citamos en el punto siguiente señala también que styled-components se apoyaba en el Context de React, que no está disponible en los Server Components.
- Mantenimiento. En marzo de 2025, Evan Jacobs anunció en el blog de styled-components que el proyecto entraba en modo de mantenimiento, y escribió que para proyectos nuevos no recomendaría styled-components ni la mayoría de soluciones CSS-in-JS.
- Mejores alternativas. CSS Modules, Tailwind y las variables CSS ofrecen casi todo lo que aportaba, sin coste en ejecución.
Hay que matizar lo anterior: el proyecto no ha muerto. A fecha de este curso sigue publicando versiones (6.6.0 es de octubre de 2026 y hay versiones preliminares de la 7), y su README dice que funciona en Server Components. Esa compatibilidad no la hemos comprobado. El propio README advierte de que lo mantiene casi una sola persona. La conclusión práctica es esta: si heredas un proyecto con styled-components, no hay prisa por reescribirlo, pero para uno nuevo elige otra técnica.
📘 Concepto: “CSS-in-JS” no significa necesariamente “en ejecución”. Bibliotecas como vanilla-extract o Linaria te dejan escribir los estilos en TypeScript y los convierten en ficheros
.cssal compilar. Tienen el coste en ejecución de CSS Modules, a cambio de una herramienta más. No las usaremos en el curso.
7. Tailwind CSS 4
Tailwind parte de una idea opuesta a las anteriores: en lugar de inventar nombres de clase, usas clases ya definidas, cada una con una sola función. Es lo que se llama CSS de utilidades. p-4 da padding, rounded-lg redondea las esquinas y bg-white pone el fondo. El aislamiento se consigue porque ya no escribes reglas que se puedan pisar.
Este curso usa Tailwind 4.3.3. Aquí veremos lo necesario para usarlo con React; para aprenderlo a fondo tienes el Curso de Tailwind.
Instalación con @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite
Añade el plugin en vite.config.ts:
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
});
Y sustituye src/index.css por lo siguiente. En Tailwind 4 toda la configuración va en el propio CSS: ya no hace falta tailwind.config.js.
@import 'tailwindcss';
@custom-variant dark (&:where([data-tema='oscuro'], [data-tema='oscuro'] *));
@theme {
--font-sans: 'Segoe UI', system-ui, sans-serif;
--color-marca-50: #e8eaf6;
--color-marca-500: #3f51b5;
--color-marca-700: #283593;
--color-marca-900: #1a237e;
--color-acento: #ff7043;
}
Línea a línea:
@import 'tailwindcss'carga el reinicio de estilos (Preflight), el tema por defecto y las utilidades.@custom-variant darkhace quedark:dependa de un atributodata-tema. Por defecto depende de la preferencia del sistema operativo.@themedefine tus valores de diseño. Cada--color-marca-500genera las clasesbg-marca-500,text-marca-500yborder-marca-500, y también queda como variable CSS normal.
⚠️ Importante: Preflight quita los estilos por defecto del navegador. Con Tailwind, un
<h1>sin clases mide 16 px y no va en negrita (lo hemos medido:font-size: 16px; font-weight: 400). No es un error: en Tailwind cada elemento lleva sus clases.
Un primer ejemplo
src/TarjetaEmpleado.tsx:
interface Props {
nombre: string;
oficio: string;
salario: number;
activo: boolean;
}
const insignias = {
activo: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100',
baja: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100',
};
export function TarjetaEmpleado({ nombre, oficio, salario, activo }: Props) {
return (
<article className="rounded-lg border-l-4 border-marca-500 bg-white p-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:bg-slate-800">
<div className="flex items-center justify-between gap-2">
<h3 className="font-semibold text-marca-900 dark:text-marca-50">{nombre}</h3>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
activo ? insignias.activo : insignias.baja
}`}
>
{activo ? 'Activo' : 'Baja'}
</span>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400">{oficio}</p>
<p className="mt-2 text-lg font-bold text-slate-800 dark:text-slate-100">{salario} €</p>
</article>
);
}
src/App.tsx:
import { useState } from 'react';
import { TarjetaEmpleado } from './TarjetaEmpleado';
const empleados = [
{ empId: 100, nombre: 'Ana García', oficio: 'DIRECTOR GENERAL', salario: 6500, activo: true },
{ empId: 101, nombre: 'Luis Martínez', oficio: 'DIRECTOR', salario: 4800, activo: true },
{ empId: 110, nombre: 'Carmen Ruiz', oficio: 'VENDEDOR', salario: 2200, activo: false },
{ empId: 111, nombre: 'David Torres', oficio: 'VENDEDOR', salario: 2100, activo: true },
];
export default function App() {
const [oscuro, setOscuro] = useState(false);
return (
<div data-tema={oscuro ? 'oscuro' : 'claro'}>
<main className="min-h-screen bg-slate-100 p-6 font-sans dark:bg-slate-900">
<header className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-marca-900 dark:text-marca-50">Empleados</h2>
<button
onClick={() => setOscuro(!oscuro)}
className="rounded-md bg-marca-500 px-3 py-1.5 text-sm text-white hover:bg-marca-700 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-acento"
>
{oscuro ? 'Modo claro' : 'Modo oscuro'}
</button>
</header>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{empleados.map((e) => (
<TarjetaEmpleado key={e.empId} {...e} />
))}
</div>
</main>
</div>
);
}

Figura 8. El panel en pantalla ancha, con cuatro columnas.

Figura 9. Tras pulsar el botón, las clases dark: entran en juego.
Las variantes son prefijos que aplican una utilidad solo en ciertas condiciones:
| Variante | Se aplica | Ejemplo |
|---|---|---|
hover: | Al pasar el ratón | hover:shadow-md |
focus-visible: | Con foco de teclado | focus-visible:outline-acento |
sm:, md:, lg: | Desde ese ancho de pantalla | lg:grid-cols-4 |
dark: | Con el tema oscuro | dark:bg-slate-800 |
Las clases sin prefijo valen para cualquier ancho, y sm: y siguientes actúan a partir de cierto tamaño. Por eso se diseña primero para móvil. Con grid-cols-1 sm:grid-cols-2 lg:grid-cols-4, la misma página pasa a una columna en un teléfono:

Figura 10. La misma página en un móvil de 390 px de ancho.
Para un valor que no está en el tema tienes los valores arbitrarios: w-[37%] o bg-[#ff7043]. Úsalos poco, porque rompen la coherencia que te da el tema.
Las trampas de Tailwind con React
Las clases no se pueden construir por piezas. Tailwind no ejecuta tu código: lee los ficheros como texto y busca nombres de clase. Solo genera el CSS de lo que encuentra escrito tal cual:
const color = 'fuchsia';
// ❌ MAL: Tailwind no ve la cadena bg-fuchsia-300 en ninguna parte
<div className={`bg-${color}-300 p-4`}>...</div>
El elemento recibe la clase bg-fuchsia-300, pero nadie ha generado la regla: en el navegador el fondo es transparente. La solución es escribir las clases completas, como hace el objeto insignias de TarjetaEmpleado:
const fondos = {
activo: 'bg-green-100',
baja: 'bg-red-100',
};
<span className={fondos[estado]}>...</span>
El orden en className no decide quién gana. Cuando dos clases se contradicen, gana la que aparece después en el CSS generado, no en el atributo. Con className="p-8 p-1" medimos un relleno de 32 px, es decir, gana p-8, aunque p-1 esté detrás. Esto importa cuando un componente acepta className para que le cambien algo. La solución es tailwind-merge, que elimina las clases que entran en conflicto. Lo normal es combinarlo con clsx en una función llamada cn:
npm install tailwind-merge
import clsx from 'clsx';
import type { ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...clases: ClassValue[]) {
return twMerge(clsx(clases));
}
twMerge('p-8 bg-teal-300', 'p-1') devuelve bg-teal-300 p-1, y ahora sí el relleno es de 4 px. Hemos usado la versión 3.7.0. Lo practicarás en el ejercicio 5.
Tailwind frente a CSS Modules
| CSS Modules | Tailwind | |
|---|---|---|
| Dónde está el estilo | En un fichero aparte | En el JSX |
| Nombres | Los inventas tú | Ya existen |
| Sistema de diseño | Lo construyes con variables | Viene de serie y se ajusta con @theme |
| Ver el código | JSX limpio, CSS separado | JSX con muchas clases |
| Reutilizar | Con clases y composes | Con componentes de React |
| Tamaño del CSS | Crece con el proyecto | Crece poco: se reutilizan las utilidades |
Cuando una lista de clases se repite, la solución no es @apply, sino crear un componente de React. Lo que se reutiliza en Tailwind es el componente, y por eso encaja tan bien con React. El proyecto final usa Tailwind.
8. Tema oscuro
Un tema oscuro bien hecho debe resolver cuatro cosas: definir los colores en un solo sitio, respetar la preferencia del sistema, recordar lo que eligió el usuario y no mostrar un destello claro al cargar la página.
Variables y light-dark()
La idea es que los componentes no sepan qué tema hay activo: solo usan variables como var(--color-texto). Antes se escribía un bloque de :root para cada tema; hoy CSS tiene la función light-dark(claro, oscuro), que elige según la propiedad color-scheme del elemento.
src/tema.css:
:root {
color-scheme: light dark;
--color-fondo: light-dark(#f5f6fa, #12151f);
--color-superficie: light-dark(#ffffff, #1e2230);
--color-texto: light-dark(#263238, #e6e8f0);
--color-texto-suave: light-dark(#546e7a, #9aa3b5);
--color-marca: light-dark(#283593, #9fa8da);
--color-borde: light-dark(#c5cae9, #3a4160);
}
:root[data-tema='claro'] {
color-scheme: light;
}
:root[data-tema='oscuro'] {
color-scheme: dark;
}
body {
color: var(--color-texto);
background: var(--color-fondo);
}
.panel {
max-width: 420px;
padding: 1rem 1.25rem;
background: var(--color-superficie);
border: 1px solid var(--color-borde);
border-radius: 10px;
}
.panel h2 {
color: var(--color-marca);
}
.panel p {
color: var(--color-texto-suave);
}
Figura 11. Solo cambia color-scheme. El resto de la cadena se ajusta solo.
Observa lo poco que hay que hacer. Sin atributo, color-scheme: light dark significa “sigue al sistema”. Con data-tema, forzamos uno. Además, color-scheme adapta las barras de desplazamiento y los controles de formulario del navegador, que son los que suelen quedar mal al hacer un tema a mano.
💡 Consejo: Si debes admitir navegadores antiguos sin
light-dark(), define las variables en:rooty redefínelas en@media (prefers-color-scheme: dark)y en:root[data-tema='oscuro']. Hace lo mismo con más líneas.
La preferencia en React
Guardamos la elección en localStorage y la reflejamos en la etiqueta <html>, que está fuera del árbol de React. Sincronizar con algo externo es justo el trabajo de useEffect, que aún no hemos visto: aquí basta con saber que el código se ejecuta después de cada render en el que cambie tema. Se estudia en el módulo 09. Igualmente, un hook personalizado como useTema se explica en el módulo 12. Para compartirlo en toda la aplicación usarías Context (módulo 11).
src/useTema.ts:
import { useEffect, useState } from 'react';
export type Tema = 'claro' | 'oscuro' | 'sistema';
function leerTema(): Tema {
const guardado = localStorage.getItem('tema');
return guardado === 'claro' || guardado === 'oscuro' ? guardado : 'sistema';
}
export function useTema() {
const [tema, setTema] = useState<Tema>(leerTema);
useEffect(() => {
const raiz = document.documentElement;
if (tema === 'sistema') {
delete raiz.dataset.tema;
localStorage.removeItem('tema');
} else {
raiz.dataset.tema = tema;
localStorage.setItem('tema', tema);
}
}, [tema]);
return [tema, setTema] as const;
}
src/SelectorTema.tsx:
import type { Tema } from './useTema';
const opciones: { valor: Tema; texto: string }[] = [
{ valor: 'claro', texto: 'Claro' },
{ valor: 'oscuro', texto: 'Oscuro' },
{ valor: 'sistema', texto: 'Sistema' },
];
interface Props {
tema: Tema;
onCambiar: (tema: Tema) => void;
}
export function SelectorTema({ tema, onCambiar }: Props) {
return (
<fieldset>
<legend>Tema</legend>
{opciones.map((o) => (
<label key={o.valor} style={{ marginRight: 12 }}>
<input
type="radio"
name="tema"
checked={tema === o.valor}
onChange={() => onCambiar(o.valor)}
/>{' '}
{o.texto}
</label>
))}
</fieldset>
);
}
src/App.tsx:
import './tema.css';
import { SelectorTema } from './SelectorTema';
import { useTema } from './useTema';
export default function App() {
const [tema, setTema] = useTema();
return (
<section className="panel">
<h2>Datos de la empresa</h2>
<p>7 departamentos y 24 empleados en plantilla.</p>
<SelectorTema tema={tema} onCambiar={setTema} />
</section>
);
}

Figura 12. Tema claro.

Figura 13. Tema oscuro. Los controles nativos también cambian por color-scheme.
Hemos comprobado el recorrido en el navegador:
| Acción | data-tema | localStorage | Fondo de body |
|---|---|---|---|
| Primera visita, sistema claro | sin atributo | vacío | rgb(245, 246, 250) |
| Elegir Oscuro | oscuro | oscuro | rgb(18, 21, 31) |
| Recargar la página | oscuro | oscuro | rgb(18, 21, 31) |
| Elegir Sistema, sistema claro | sin atributo | vacío | rgb(245, 246, 250) |
| El sistema pasa a oscuro | sin atributo | vacío | rgb(18, 21, 31) |
La última fila es la más interesante: la página cambia sola, sin escuchar ningún evento desde JavaScript. No necesitas matchMedia porque todo lo decide el CSS.
El destello al cargar
Hay un problema. React arranca después de que se descargue y ejecute tu JavaScript, y mientras tanto el navegador ya puede haber pintado el tema equivocado. El usuario que eligió oscuro vería un destello blanco. Por eso, el atributo debe estar puesto antes de la primera pintura, con un pequeño script en línea en index.html, dentro de <head>:
<script>
const tema = localStorage.getItem('tema');
if (tema === 'claro' || tema === 'oscuro') {
document.documentElement.dataset.tema = tema;
}
</script>
Es uno de los pocos casos donde un script bloqueante está justificado: lo ejecuta el navegador al leer el HTML. Para probarlo, bloqueamos la carga de main.tsx y recargamos: sin que React llegase a arrancar, data-tema valía oscuro.
⚠️ Importante: Si la página tiene una política de seguridad de contenido (CSP) que prohíbe los scripts en línea, esto no funcionará tal cual. Lo veremos en el módulo 25. Por otra parte,
localStorageno existe en el servidor: si renderizas en servidor (módulo 23),leerTemadebe adaptarse.
Con Tailwind
Las dos ideas encajan. La línea @custom-variant dark de la sección anterior hace que dark: se active con data-tema='oscuro' en cualquier ancestro, así que el useTema de esta sección le sirve sin cambios. Si prefieres que dark: siga siempre al sistema, quita @custom-variant: ese es el comportamiento por defecto de Tailwind, pero entonces el selector de tema deja de tener efecto.
9. <style> y <link> en componentes 🆕 19.0
🆕 Desde React 19.0. No existe en React 18.3 ni anteriores. Si usas una versión anterior:
<style>y<link>se quedan en el<body>, se repiten con cada instancia yprecedenceacaba como atributo sin efecto. Importa el CSS conimporto colócalo enindex.html, y carga los estilos bajo demanda conimport().
Hasta ahora el CSS dependía de un import, que lo carga para toda la aplicación. React 19 te permite declarar una hoja de estilos en el componente que la necesita, con tres ventajas: se mueve a <head>, no se duplica aunque renderices cien instancias y su carga se integra con Suspense. Hay un motivo de fondo: quien hace esto no suele ser una aplicación, sino bibliotecas de componentes y entornos con renderizado en servidor.
<style> con href y precedence
Esta gráfica lleva su propio CSS, y la renderizamos tres veces:
function Grafica({ valor }: { valor: number }) {
return (
<>
<style href="grafica" precedence="componente">{`
.grafica { height: 14px; margin: 6px 0; background: #3f51b5; }
`}</style>
<div className="grafica" style={{ width: valor * 2 }} />
</>
);
}
Hay dos props que activan el comportamiento especial, y hacen falta las dos:
hrefidentifica la hoja de estilos. No es una URL: es la clave con la que React elimina duplicados.precedenceindica en qué grupo se coloca, y con ello qué hoja gana a cuál.
Esto es lo que encontramos en el navegador, en React 19.3.1 y en 19.0.9, frente a 18.3.1:
<!-- React 19: en <head>, una sola copia -->
<style data-href="grafica" data-precedence="componente"> .grafica { ... } </style>
<!-- React 18.3: tres copias dentro de <body>, con los atributos sin procesar -->
<style href="grafica" precedence="componente"> .grafica { ... } </style>
<style href="grafica" precedence="componente"> .grafica { ... } </style>
<style href="grafica" precedence="componente"> .grafica { ... } </style>
Un <style> sin esas dos props no tiene nada especial, ni siquiera en React 19: se queda en su sitio, dentro del <body>, y se repite en cada instancia.
Figura 14. El <head> de nuestro ejemplo. React agrupa por precedence y elimina los duplicados por href.
precedence no significa lo que parece
Los valores de precedence son nombres que inventas tú: React no sabe si alta es más que baja. El orden sale de cuál apareció antes. Compruébalo:
<style href="c" precedence="alta">{`p { color: red; }`}</style>
<style href="a" precedence="baja">{`p { color: blue; }`}</style>
<style href="d" precedence="alta">{`p { background: yellow; }`}</style>
<!-- <head> resultante -->
<style data-href="c" data-precedence="alta">p { color: red; }</style>
<style data-href="d" data-precedence="alta">p { background: yellow; }</style>
<style data-href="a" data-precedence="baja">p { color: blue; }</style>
El texto sale azul. alta apareció primero, de modo que se coloca antes, y en CSS lo que va después gana. Observa también que d se ha colocado junto a c, por tener la misma precedencia. Usa nombres que reflejen capas (base, componente, pagina) y asegúrate de que la primera en renderizarse es la más general.
⚠️ Importante: React inserta estas hojas al principio de
<head>, antes de lo que ya estaba. En nuestra prueba, conh2 { color: blue }en un CSS importado por Vite yh2 { color: red }en un<style precedence>, el título salió azul. Es decir, ante la misma especificidad, gana el CSS importado, porque va después. Si mezclas las dos técnicas, usa selectores más específicos o@layer.
<link> y Suspense
Con <link rel="stylesheet"> pasa lo mismo, y además hay un archivo que esperar. React lo mueve a <head>, elimina los duplicados por href y no muestra el componente hasta que los estilos estén listos. Así se evita ver el contenido sin formato por un instante.
function Informe() {
return (
<>
<link rel="stylesheet" href="/lento.css" precedence="pagina" />
<p className="informe">Informe de ventas</p>
</>
);
}
// En el padre
<Suspense fallback={<p>Cargando estilos...</p>}>
{verInforme && <Informe />}
</Suspense>
Suspense se estudia a fondo en el módulo 15. Por ahora, basta con saber que muestra fallback mientras algo de su interior no está listo. Para probarlo, retrasamos la respuesta de lento.css 1,5 segundos:
| Momento tras el clic | ¿Se ve el fallback? | ¿Existe .informe? | ¿Se aplicó el CSS? |
|---|---|---|---|
| 300 ms | Sí | No | Todavía no |
| 1900 ms | No | Sí | Sí (font-weight: 700) |
El <link> ya estaba en <head> a los 300 ms, pero React retuvo el contenido. También añadió por su cuenta un <link rel="preload" as="style">.
Limitaciones
- Quedan en la página. Al desmontar las tres gráficas, el
<style>siguió en<head>. Es lógico: otro componente podría usarlo, y volver a insertarlo cuesta. Pero recuerda que sigue activo. - Son para contenido fijo. Según la documentación, React ignora los cambios de props una vez renderizado y descarta otras props distintas de
hrefyprecedence. Para valores que cambian, usa una variable CSS, como en la sección 4. - Requiere
hrefyprecedencejuntos. Si falta uno, el elemento se trata como uno normal. - El
hrefde<style>debe ser único. Dos estilos distintos con el mismohrefse quedarían en uno solo.
¿Cuándo tiene sentido en tu aplicación? Con un build de Vite, CSS Modules resuelve casi todo y es más sencillo. <style precedence> es sobre todo una herramienta para quien publica componentes que deben traer su CSS sin que el usuario configure nada, y para <link> de hojas externas que solo necesita una pantalla.
10. Metadatos de documento 🆕 19.0
🆕 Desde React 19.0. No existe en React 18.3 ni anteriores. Si usas una versión anterior:
<title>y<meta>se quedan en el<body>y el título de la pestaña no cambia. Asignadocument.titledentro de unuseEffect, o usa una biblioteca que gestione<head>.
Cada pantalla debería tener su propio título: es lo que se ve en la pestaña, en el historial y en los marcadores, y lo primero que lee un lector de pantalla al cambiar de página. Pero <head> queda fuera de #root, adonde React no llegaba. Ahora, si renderizas <title>, <meta> o <link> en cualquier componente, React los sube a <head>:
import { useState } from 'react';
function PaginaEmpleados() {
return (
<>
<title>Empleados | EMPRESA</title>
<meta name="description" content="Listado de la plantilla de EMPRESA." />
<link rel="canonical" href="https://empresa.example/empleados" />
<h2>Empleados</h2>
<p>Hay 24 personas en plantilla.</p>
</>
);
}
function PaginaDepartamentos({ total }: { total: number }) {
return (
<>
<title>{`Departamentos (${total}) | EMPRESA`}</title>
<meta name="description" content="Departamentos de EMPRESA." />
<h2>Departamentos</h2>
<p>Tenemos {total} departamentos.</p>
</>
);
}
export default function App() {
const [pagina, setPagina] = useState<'empleados' | 'departamentos'>('empleados');
return (
<main>
<nav className="fila">
<button onClick={() => setPagina('empleados')}>Empleados</button>
<button onClick={() => setPagina('departamentos')}>Departamentos</button>
</nav>
{pagina === 'empleados' ? <PaginaEmpleados /> : <PaginaDepartamentos total={7} />}
</main>
);
}
Esto es lo que hay en <head> en cada pantalla, y document.title en cada caso:
<!-- Pantalla de empleados. document.title: "Empleados | EMPRESA" -->
<title>Empleados | EMPRESA</title>
<title>lab-react</title>
<meta name="description" content="Listado de la plantilla de EMPRESA.">
<link rel="canonical" href="https://empresa.example/empleados">
<!-- Pantalla de departamentos. document.title: "Departamentos (7) | EMPRESA" -->
<title>Departamentos (7) | EMPRESA</title>
<title>lab-react</title>
<meta name="description" content="Departamentos de EMPRESA.">
Fíjate en tres cosas:
- El
<title>deindex.htmlsigue ahí, pero React inserta el suyo delante, y el navegador usa el primero. Si no se renderiza ninguno, vuelve a valer el deindex.html: lo hemos comprobado desmontando el componente. - Al revés que las hojas de estilo, los metadatos sí se retiran cuando el componente se desmonta. El
canonicaldesapareció al cambiar de pantalla. - No hay eliminación de duplicados en los metadatos. Cada
<meta>se limita a estar en el<head>mientras su componente esté montado.
En React 18.3.1 el resultado del mismo código es muy distinto: los tres elementos se quedan dentro del <body> y la pestaña sigue diciendo lab-react.
Un error que no avisa: el título vacío
JSX convierte <title>Avisos {n}</title> en un array de dos hijos, y <title> solo acepta un texto. Lo curioso es lo que pasa: la pestaña se queda con el título vacío, y document.title vale "". No hay ningún error de compilación ni aviso en la consola, ni en desarrollo ni en producción (React 19.3.1). Usa siempre una plantilla, de modo que haya un único hijo:
<title>Avisos {n}</title> // ❌ MAL: título vacío
<title>{`Avisos ${n}`}</title> // ✅ BIEN
Más de un <title>
Si dos componentes renderizan <title> a la vez, los dos acaban en <head>. En nuestra prueba, Chromium mostró el último en montarse, pero la documentación avisa de que el comportamiento de navegadores y buscadores no está definido. Debe haber un solo responsable: normalmente, el componente de cada página. Aquí es donde encaja bien el router (módulo 18).
La excepción es <title> dentro de un <svg>, que describe el gráfico. React lo deja en su sitio.
Y el SEO
Este mecanismo cambia el <head> en el navegador, después de ejecutar JavaScript. Es perfecto para la pestaña y la accesibilidad, pero un rastreador que no ejecute JavaScript, o la vista previa de un enlace en una red social, solo verá lo que hay en index.html. Si necesitas esos metadatos, hay que generarlos en el servidor, como veremos con Next.js en el módulo 23. Cambia también <html lang="en"> por lang="es" en tu index.html.
11. Qué técnica elijo
| Necesidad | Técnica | Motivo |
|---|---|---|
| Reinicio, tipografía, variables de diseño | CSS global en main.tsx | Debe afectar a todo |
| Estilos de un componente, si prefieres CSS | CSS Modules | Sin instalación ni coste en ejecución |
| Interfaz rápida con un sistema de diseño coherente | Tailwind | Tema y utilidades de serie |
| Valor calculado en ejecución | style o variable CSS | No se conoce al compilar |
Valor calculado que además necesita :hover o @media | Variable CSS | Une ambos mundos |
| Clases que dependen del estado | clsx o atributos data-* y ARIA | Evita errores con cadenas |
| Componentes de una biblioteca que traen su CSS | <style precedence> 🆕 19.0 | Sin duplicados ni configuración |
| Un proyecto nuevo con CSS-in-JS en ejecución | Evítalo | Coste en cada render |
| Un proyecto heredado con styled-components | Mantenlo si funciona | Migrar poco a poco solo si hay un problema real |
Lo más importante es que el equipo elija una forma principal y la siga. CSS Modules y Tailwind son igual de válidos: cada uno tiene sus defensores, y la mezcla descontrolada es peor que cualquiera de las dos.
12. Errores frecuentes y depuración
| Síntoma | Causa | Solución |
|---|---|---|
| Un componente cambia de aspecto al tocar otro | Colisión de clases en CSS global | Pásalo a CSS Modules |
La clase no se aplica y class no aparece | Errata en estilos.xxx (es undefined) | Revisa el nombre: TypeScript no lo detecta |
class="undefined" | Clave calculada { [estilos.xxx]: true } con errata | Usa condicion && estilos.xxx |
class="item false" | && dentro de una plantilla | Usa clsx o un ternario |
class en JSX | Es la prop className | Cámbiala, y lee el aviso de la consola |
style="..." da error | Debe ser un objeto | style={{ color: 'red' }} |
style con !important no hace nada | React lo descarta en silencio | Usa una clase |
| Un número no pone píxeles | Propiedad sin unidad, o variable CSS | Pasa una cadena con la unidad |
| Clase de Tailwind sin efecto | Nombre construido por piezas | Escribe la clase completa |
className no sobrescribe en Tailwind | Decide el orden del CSS generado | Usa tailwind-merge |
dark: no reacciona al botón | Falta @custom-variant dark | Configúralo en el CSS |
| Destello claro al recargar | El tema se aplica cuando arranca React | Script en línea en index.html |
| Pestaña con título vacío | <title> con varios hijos | Usa una plantilla |
<style precedence> no gana a tu CSS | Se inserta antes en <head> | Sube la especificidad o usa @layer |
Cuando un estilo no funcione, sigue este orden en la pestaña Elementos de las herramientas del navegador:
- ¿Tiene el elemento la clase? Si no, el problema está en tu JavaScript.
- ¿Existe la regla? Mira el panel de estilos. Si la clase no tiene regla, el CSS no se ha cargado o Tailwind no la ha generado.
- ¿La regla está tachada? Otra regla gana por especificidad u orden. El panel indica cuál.
- ¿La propiedad tiene un aviso? Un valor no válido se marca con un icono de advertencia.
13. Ejercicios
Ejercicio 1: Insignia de estado de un pedido
Crea un componente InsigniaEstado que reciba el estado de un pedido de la API (PENDIENTE, ENVIADO, ENTREGADO o CANCELADO) y muestre una etiqueta redondeada, con un color por estado y el texto legible. Usa CSS Modules y clsx. El tipo debe impedir un estado desconocido.
Ver solución
src/InsigniaEstado.module.css:
.insignia {
display: inline-block;
padding: 0.1rem 0.6rem;
font-size: 0.8rem;
font-weight: 600;
border-radius: 999px;
}
.PENDIENTE {
color: #8d5a00;
background: #fff3cd;
}
.ENVIADO {
color: #283593;
background: #e8eaf6;
}
.ENTREGADO {
color: #1b5e20;
background: #e8f5e9;
}
.CANCELADO {
color: #b71c1c;
background: #ffebee;
text-decoration: line-through;
}
src/InsigniaEstado.tsx:
import clsx from 'clsx';
import estilos from './InsigniaEstado.module.css';
export type EstadoPedido = 'PENDIENTE' | 'ENVIADO' | 'ENTREGADO' | 'CANCELADO';
const textos: Record<EstadoPedido, string> = {
PENDIENTE: 'Pendiente',
ENVIADO: 'Enviado',
ENTREGADO: 'Entregado',
CANCELADO: 'Cancelado',
};
export function InsigniaEstado({ estado }: { estado: EstadoPedido }) {
return (
<span className={clsx(estilos.insignia, estilos[estado])}>{textos[estado]}</span>
);
}
src/App.tsx:
import { InsigniaEstado } from './InsigniaEstado';
import type { EstadoPedido } from './InsigniaEstado';
const estados: EstadoPedido[] = ['PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO'];
export default function App() {
return (
<div className="fila">
{estados.map((e) => (
<InsigniaEstado key={e} estado={e} />
))}
</div>
);
}

Figura 15. Las cuatro insignias.
Las clases se llaman igual que los valores de la API, de modo que estilos[estado] funciona sin conversiones. Record<EstadoPedido, string> obliga a dar un texto para cada estado: si la API añade uno y amplías el tipo, TypeScript te señala el objeto.
Ejercicio 2: Consumo de presupuesto
Haz un componente Presupuesto que reciba nombre, gastado y presupuesto, y dibuje una barra con el porcentaje. Pasa el porcentaje como variable CSS --uso. Si se supera el 100 %, la barra debe quedarse al máximo y ponerse roja. Pruébalo con VENTAS (210 000 de 350 000), INFORMÁTICA (378 000 de 420 000) y MARKETING (236 000 de 200 000).
Ver solución
src/Presupuesto.module.css:
.fila {
margin-bottom: 0.75rem;
}
.pista {
height: 12px;
overflow: hidden;
background: #eceff1;
border-radius: 6px;
}
.relleno {
width: var(--uso);
height: 100%;
background: #3f51b5;
}
.excedido {
background: #c62828;
}
src/Presupuesto.tsx:
import type { CSSProperties } from 'react';
import clsx from 'clsx';
import estilos from './Presupuesto.module.css';
interface Props {
nombre: string;
gastado: number;
presupuesto: number;
}
export function Presupuesto({ nombre, gastado, presupuesto }: Props) {
const uso = (gastado / presupuesto) * 100;
const excedido = uso > 100;
const variables = { '--uso': `${Math.min(uso, 100)}%` } as CSSProperties;
return (
<div className={estilos.fila}>
<div>
{nombre}: {Math.round(uso)} %
</div>
<div
className={estilos.pista}
role="progressbar"
aria-label={nombre}
aria-valuenow={Math.min(Math.round(uso), 100)}
>
<div
className={clsx(estilos.relleno, excedido && estilos.excedido)}
style={variables}
/>
</div>
</div>
);
}
src/App.tsx:
import { Presupuesto } from './Presupuesto';
export default function App() {
return (
<section style={{ maxWidth: 420 }}>
<Presupuesto nombre="VENTAS" gastado={210000} presupuesto={350000} />
<Presupuesto nombre="INFORMÁTICA" gastado={378000} presupuesto={420000} />
<Presupuesto nombre="MARKETING" gastado={236000} presupuesto={200000} />
</section>
);
}

Figura 16. MARKETING supera el presupuesto: 118 %.
El dato visual se limita con Math.min, pero el texto conserva el valor real. role="progressbar" y los atributos ARIA permiten a un lector de pantalla entender la barra.
Ejercicio 3: Pestañas con estado en ARIA
Crea unas pestañas (Empleados, Departamentos y Pedidos) en las que la activa se distinga por una línea naranja. No uses clases condicionales: el CSS debe basarse en aria-selected.
Ver solución
src/Pestanas.module.css:
.pestanas {
display: flex;
gap: 0.25rem;
border-bottom: 2px solid #c5cae9;
}
.pestana {
padding: 0.5rem 1rem;
font: inherit;
color: #546e7a;
background: none;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
}
.pestana[aria-selected='true'] {
color: #1a237e;
font-weight: 600;
border-bottom-color: #ff7043;
}
src/App.tsx:
import { useState } from 'react';
import estilos from './Pestanas.module.css';
const secciones = ['Empleados', 'Departamentos', 'Pedidos'];
export default function App() {
const [activa, setActiva] = useState('Empleados');
return (
<>
<div role="tablist" className={estilos.pestanas}>
{secciones.map((s) => (
<button
key={s}
role="tab"
aria-selected={s === activa}
className={estilos.pestana}
onClick={() => setActiva(s)}
>
{s}
</button>
))}
</div>
<p>Sección activa: {activa}</p>
</>
);
}
Un solo dato, aria-selected, gobierna el aspecto y lo que anuncia el lector de pantalla. Si olvidas actualizarlo, se nota enseguida en pantalla. Para que las pestañas sean completas, faltaría la navegación con flechas, que se trata en el módulo 24.
Ejercicio 4: Tarjetas de producto con Tailwind
Usa la configuración de la sección 7 para mostrar productos en una rejilla: una columna en móvil, dos en sm y cuatro en lg. El color del stock depende de la cantidad (verde, ámbar si hay menos de 5, rojo si está agotado), y un producto descatalogado sale tachado y atenuado. Las clases deben funcionar también en oscuro.
Ver solución
interface Producto {
productoId: number;
nombre: string;
precio: number;
stock: number;
descatalogado: boolean;
}
const productos: Producto[] = [
{ productoId: 1, nombre: 'Portátil UltraBook 14', precio: 899, stock: 25, descatalogado: false },
{ productoId: 5, nombre: 'Torre Workstation', precio: 1899, stock: 4, descatalogado: false },
{ productoId: 8, nombre: 'Pack teclado y ratón', precio: 34.5, stock: 0, descatalogado: false },
{ productoId: 14, nombre: 'Altavoz Inteligente', precio: 49, stock: 0, descatalogado: true },
];
function claseStock(stock: number) {
if (stock === 0) return 'text-red-700 dark:text-red-300';
if (stock < 5) return 'text-amber-700 dark:text-amber-300';
return 'text-green-700 dark:text-green-300';
}
function TarjetaProducto({ producto }: { producto: Producto }) {
const { nombre, precio, stock, descatalogado } = producto;
return (
<article
className={`rounded-lg bg-white p-4 shadow-sm dark:bg-slate-800 ${
descatalogado ? 'opacity-60' : ''
}`}
>
<h3
className={`font-semibold text-marca-900 dark:text-marca-50 ${
descatalogado ? 'line-through' : ''
}`}
>
{nombre}
</h3>
<p className="mt-1 text-lg font-bold text-slate-800 dark:text-slate-100">{precio} €</p>
<p className={`text-sm ${claseStock(stock)}`}>
{stock === 0 ? 'Agotado' : `${stock} en stock`}
</p>
</article>
);
}
export default function App() {
return (
<main className="min-h-screen bg-slate-100 p-6 font-sans dark:bg-slate-900">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{productos.map((p) => (
<TarjetaProducto key={p.productoId} producto={p} />
))}
</div>
</main>
);
}

Figura 17. El Altavoz Inteligente está descatalogado.
claseStock devuelve siempre clases completas, y por eso Tailwind las encuentra. Si escribieras text-${color}-700, el color no se generaría.
Ejercicio 5: Botón que admite cambios con cn
Crea un Boton de Tailwind con estilos base que acepte className. Una instancia debe poder cambiar el color y el relleno horizontal sin quedarse con las clases originales.
Ver solución
La función cn es la de la sección 7. src/App.tsx:
import type { ComponentProps } from 'react';
import { cn } from './cn';
function Boton({ className, ...resto }: ComponentProps<'button'>) {
return (
<button
{...resto}
className={cn('rounded-md bg-marca-500 px-4 py-2 text-white', className)}
/>
);
}
export default function App() {
return (
<main className="flex gap-3 p-6 font-sans">
<Boton>Normal</Boton>
<Boton className="bg-red-700 px-8">Eliminar</Boton>
</main>
);
}
Las clases finales del segundo botón son las siguientes. Faltan bg-marca-500 y px-4, sustituidas por las nuevas:
rounded-md py-2 text-white bg-red-700 px-8
Ejercicio 6: Título con avisos sin leer
Muestra en la pestaña (3) Avisos | EMPRESA con el número de avisos sin leer, o Avisos | EMPRESA cuando no haya ninguno. Añade botones para recibir un aviso nuevo y marcarlos todos como leídos.
Ver solución
import { useState } from 'react';
export default function App() {
const [avisos, setAvisos] = useState(2);
const titulo = avisos > 0 ? `(${avisos}) Avisos | EMPRESA` : 'Avisos | EMPRESA';
return (
<main>
<title>{titulo}</title>
<h2>Avisos sin leer: {avisos}</h2>
<div className="fila">
<button onClick={() => setAvisos(avisos + 1)}>Nuevo aviso</button>
<button onClick={() => setAvisos(0)}>Marcar todos como leídos</button>
</div>
</main>
);
}
titulo es una sola cadena, de modo que <title> tiene un único hijo. Con esta solución obtuvimos (2) Avisos | EMPRESA, (3) Avisos | EMPRESA tras un aviso nuevo, y Avisos | EMPRESA tras marcarlos como leídos.
Ejercicio 7: Un componente que trae su CSS
Crea un componente Avatar que muestre las iniciales de un empleado en un círculo y lleve su propio <style>. Renderiza tres, y comprueba en <head> que solo hay una copia.
Ver solución
const empleados = ['Ana García', 'Luis Martínez', 'Carmen Ruiz'];
function Avatar({ nombre }: { nombre: string }) {
const iniciales = nombre
.split(' ')
.map((parte) => parte[0])
.join('');
return (
<>
<style href="avatar" precedence="componentes">{`
.avatar {
display: inline-grid;
place-items: center;
width: 44px;
height: 44px;
color: #fff;
font-weight: 600;
background: #3f51b5;
border-radius: 50%;
}
`}</style>
<span className="avatar" title={nombre}>
{iniciales}
</span>
</>
);
}
export default function App() {
return (
<div className="fila">
{empleados.map((e) => (
<Avatar key={e} nombre={e} />
))}
</div>
);
}
En <head> hay un solo <style data-href="avatar" data-precedence="componentes">, y los tres avatares miden 44 px. El <style> es fijo: el color de cada avatar, si dependiera de los datos, debería ir en una variable CSS.
Ejercicio 8: Color de acento persistente
Añade un selector con tres colores de acento (azul, naranja y verde). Usa un atributo data-acento en <html>, y guarda la elección en localStorage. Debe mantenerse al recargar. Puedes combinarlo con la solución de la sección 8.
Ver solución
src/acentos.css:
:root {
color-scheme: light dark;
--acento: #3f51b5;
--fondo: light-dark(#f5f6fa, #12151f);
--texto: light-dark(#263238, #e6e8f0);
}
:root[data-acento='naranja'] {
--acento: #e64a19;
}
:root[data-acento='verde'] {
--acento: #2e7d32;
}
body {
color: var(--texto);
background: var(--fondo);
}
.boton-acento {
padding: 0.5rem 1rem;
font: inherit;
color: #fff;
background: var(--acento);
border: none;
border-radius: 6px;
}
src/App.tsx:
import { useEffect, useState } from 'react';
import './acentos.css';
const acentos = ['azul', 'naranja', 'verde'] as const;
type Acento = (typeof acentos)[number];
function leerAcento(): Acento {
const guardado = localStorage.getItem('acento');
return acentos.find((a) => a === guardado) ?? 'azul';
}
export default function App() {
const [acento, setAcento] = useState<Acento>(leerAcento);
useEffect(() => {
document.documentElement.dataset.acento = acento;
localStorage.setItem('acento', acento);
}, [acento]);
return (
<section>
<h2 style={{ color: 'var(--acento)' }}>Color de acento</h2>
<div className="fila">
{acentos.map((a) => (
<button key={a} className="boton-acento" onClick={() => setAcento(a)}>
{a}
</button>
))}
</div>
</section>
);
}
acentos.find valida lo que hay en localStorage: el usuario puede editarlo, así que no conviene darlo por bueno. Tras elegir verde y recargar, el título sigue en rgb(46, 125, 50). Para evitar el destello, habría que leerlo también en el script de index.html.
Ejercicio 9 (propuesto): Migrar de CSS global a CSS Modules
Toma el ejemplo de la colisión de la sección 2 y añade una tercera tarjeta. Pásalo todo a CSS Modules, y extrae lo común de las tres a un comun.module.css usando composes.
Ejercicio 10 (propuesto): Hoja de impresión bajo demanda
Crea una pantalla de informe que cargue una hoja de estilos de impresión con <link rel="stylesheet" media="print" href="/impresion.css" precedence="pagina" />. Comprueba con la vista previa de impresión del navegador si se aplica, qué pasa con la carga dentro de un Suspense, y qué ocurre al salir de la pantalla.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| CSS global | import './x.css' | Vale para toda la página, aunque se importe en un componente |
| CSS Modules | import e from './x.module.css' | Vite renombra las clases: e.tarjeta es una cadena |
| Composición | composes, :global() | Añade clases y permite salir del ámbito |
| Estilos en línea | style={{ width: 10 }} | Objeto en camelCase; los números son píxeles |
| Variables CSS | style={{ '--x': v } as CSSProperties} | Datos en JS, reglas en CSS |
| Clases condicionales | clsx('a', ok && 'b') | Evita "false" y "undefined" en class |
| Estado como atributo | data-*, aria-* | El CSS lee el estado sin clases extra |
| CSS-in-JS en ejecución | styled.div | Coste en cada render; no recomendado para proyectos nuevos |
| Tailwind | @import 'tailwindcss', @theme | Utilidades; el CSS se genera leyendo tu código |
| Fusión de clases | twMerge, cn | Resuelve conflictos entre utilidades |
| Tema | color-scheme, light-dark(), data-tema | Un script en index.html evita el destello |
| Hojas en componentes 🆕 19.0 | <style href precedence>, <link precedence> | Van a <head>, sin duplicados; <link> espera con Suspense |
| Metadatos 🆕 19.0 | <title>, <meta>, <link> | Suben a <head>; <title> debe tener un único hijo |