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, composes y :global
  • Usar estilos en línea cuando el valor se calcula en ejecución, y variables CSS cuando además necesitas :hover o @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 @theme y 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 con precedence
  • 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.

Cinco técnicas de estilo agrupadas según si el CSS se genera al compilar o en el navegador

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:

Dos tarjetas iguales en color naranja, aunque la del empleado debería ser azul

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.css y 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 y box-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:

Tarjeta de empleado azul y tarjeta de producto naranja

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.

Dos ficheros con las mismas clases se transforman en clases distintas y en un objeto de JavaScript

Figura 4. Vite renombra las clases en el CSS y te entrega un objeto que traduce cada nombre.

📘 Concepto: estilos es un objeto de JavaScript corriente. estilos.tarjeta es una cadena, y por eso la puedes pasar a className, 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 ... from en 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>
  );
}

Lista de productos con barras de stock de distinta longitud y color, y una barra de ocupación

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, no background-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, undefined y false eliminan 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 style escrito 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: style para 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 tipo Variante limita los valores posibles.
  • className va al final de clsx para que quien use el componente pueda añadir clases propias.
  • {...resto} reenvía onClick, type y cualquier otra prop de <button>. Va al principio para que disabled y className no 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:enabled y :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>
  );
}

Botones en sus variantes y tabla de empleados con una fila resaltada

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.

Comparación de los pasos de un render con CSS estático y con CSS-in-JS en ejecución

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 .css al 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 dark hace que dark: dependa de un atributo data-tema. Por defecto depende de la preferencia del sistema operativo.
  • @theme define tus valores de diseño. Cada --color-marca-500 genera las clases bg-marca-500, text-marca-500 y border-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>
  );
}

Cuatro tarjetas de empleados en una fila, con tema claro

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

El mismo panel de empleados con colores oscuros

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:

El panel de empleados en una sola columna, en una pantalla estrecha

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

Flujo desde la preferencia del usuario o del sistema hasta las variables que usan los componentes

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 :root y 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>
  );
}

Panel en tema claro con un selector de tres opciones

Figura 12. Tema claro.

El mismo panel en tema oscuro, con la opción Oscuro seleccionada

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, localStorage no existe en el servidor: si renderizas en servidor (módulo 23), leerTema debe 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.


🆕 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 y precedence acaba como atributo sin efecto. Importa el CSS con import o colócalo en index.html, y carga los estilos bajo demanda con import().

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:

  • href identifica la hoja de estilos. No es una URL: es la clave con la que React elimina duplicados.
  • precedence indica 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.

Los tres estilos de las gráficas y el enlace de App se agrupan en head, con una sola copia y en orden de precedencia

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, con h2 { color: blue } en un CSS importado por Vite y h2 { 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.

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 href y precedence. Para valores que cambian, usa una variable CSS, como en la sección 4.
  • Requiere href y precedence juntos. Si falta uno, el elemento se trata como uno normal.
  • El href de <style> debe ser único. Dos estilos distintos con el mismo href se 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. Asigna document.title dentro de un useEffect, 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:

  1. El <title> de index.html sigue ahí, pero React inserta el suyo delante, y el navegador usa el primero. Si no se renderiza ninguno, vuelve a valer el de index.html: lo hemos comprobado desmontando el componente.
  2. Al revés que las hojas de estilo, los metadatos sí se retiran cuando el componente se desmonta. El canonical desapareció al cambiar de pantalla.
  3. 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:

  1. ¿Tiene el elemento la clase? Si no, el problema está en tu JavaScript.
  2. ¿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.
  3. ¿La regla está tachada? Otra regla gana por especificidad u orden. El panel indica cuál.
  4. ¿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>
  );
}

Cuatro insignias de colores con los estados de un pedido

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

Tres barras de presupuesto: dos azules y una roja al máximo

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

Cuatro tarjetas de producto, la última tachada y atenuada

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