Módulo 10: Diseño responsive

Objetivos del módulo

  • Entender por qué sin <meta name="viewport"> ninguna variante responsive funciona en un móvil
  • Pensar en mobile first: la clase sin prefijo es la de móvil, y md: significa “desde 48 rem”
  • Saber qué CSS genera un punto de ruptura y por qué el orden de las clases en el HTML no decide quién gana
  • Mostrar y ocultar contenido sin duplicarlo y sin olvidar a quien usa lector de pantalla
  • Limitar un estilo hacia abajo con max-* y a una franja con md:max-lg:
  • Usar valores puntuales con min-[...] y max-[...], y conocer la diferencia de max-[...] entre v3 y v4
  • Crear, sustituir y eliminar puntos de ruptura con @theme, y evitar el error de mezclar unidades
  • Evitar puntos de ruptura con clamp(), auto-fill y otras técnicas fluidas
  • Hacer componentes que se adaptan a su contenedor con @container, @md: y contenedores con nombre
  • Resolver los errores típicos de las consultas de contenedor: el elemento que se consulta a sí mismo y el contenedor que mide 0
  • Consultar también la altura con @container-size
  • Adaptar tablas de tres formas: desplazamiento, columnas ocultas y tarjetas
  • Construir menús adaptables con JavaScript mínimo y sin JavaScript usando popover
  • Servir imágenes del tamaño correcto con srcset, sizes y <picture>
  • Montar un panel de control completo que combina consultas de medios y de contenedor

1. Preparación del laboratorio

Sigues con el proyecto Vite del módulo 01. Los ejemplos usan las mismas dos clases de decoración de los módulos anteriores, .caja y .marco, que no forman parte de la técnica que se enseña:

@import "tailwindcss";

@layer components {
  .caja {
    border-radius: 0.375rem;
    background-color: #6366f1;
    padding: 0.5rem 0.75rem;
    color: #fff;
    font-size: 0.875rem;
    font-weight: 500;
    text-align: center;
  }
  .marco {
    border: 2px dashed #cbd5e1;
    border-radius: 0.5rem;
    background-color: #f8fafc;
    padding: 0.25rem;
  }
}

Todas las páginas de este módulo empiezan igual. La segunda línea de <head> es la que importa, y la siguiente sección explica por qué:

<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Tienda EMPRESA</title>
  <link rel="stylesheet" href="/app.css">
</head>
<body class="bg-slate-50 p-4 text-slate-900">
  <!-- Aquí va cada ejemplo -->
</body>
</html>

Para saber en todo momento qué punto de ruptura está activo, pega este indicador al final del <body> mientras trabajas. Solo usa clases que aprenderás en las próximas secciones:

<div class="fixed right-2 bottom-2 z-50 rounded bg-slate-900 px-2 py-1 font-mono text-xs text-white">
  <span class="sm:hidden">base</span>
  <span class="hidden sm:inline md:hidden">sm</span>
  <span class="hidden md:inline lg:hidden">md</span>
  <span class="hidden lg:inline xl:hidden">lg</span>
  <span class="hidden xl:inline 2xl:hidden">xl</span>
  <span class="hidden 2xl:inline">2xl</span>
</div>

Los rótulos “Ventana de N px” de las capturas son solo leyendas añadidas al hacer la figura. Cada ejemplo se ha abierto a esa anchura real.

⚠️ Importante: Vite no detecta siempre los HTML creados con el servidor en marcha. Si una clase nueva no aparece, guarda app.css sin cambiar nada para que Tailwind vuelva a escanear el proyecto.


2. Antes de nada: la etiqueta viewport

Diseño responsive significa que una misma página se adapta al espacio disponible: un solo HTML, sin versión móvil aparte. Esa adaptación se apoya en una pregunta que el navegador se hace constantemente: ¿cuánto mide la ventana?

En un móvil, esa pregunta tiene una trampa. Los primeros teléfonos con navegador completo tenían que mostrar webs pensadas para escritorio, así que fingen tener una ventana de unos 980 px y encogen el resultado para que quepa en la pantalla. Si la página no dice lo contrario, ese es el ancho que ven tus consultas de medios.

La etiqueta viewport desactiva esa simulación:

<meta name="viewport" content="width=device-width, initial-scale=1">
  • width=device-width: la ventana mide lo que mide la pantalla en píxeles CSS.
  • initial-scale=1: sin zoom inicial.

He abierto la misma página con Chromium emulando un móvil de 375 px, con y sin la etiqueta. El contenido es una rejilla con grid-cols-1 md:grid-cols-3:

Página window.innerWidth Columnas de la rejilla
Sin viewport 981 3 de 308 px
Con viewport 375 1 de 343 px

Móvil sin etiqueta viewport: tres columnas con el texto diminuto

Figura 1. Sin la etiqueta, el móvil simula una ventana de 980 px, se activa md:grid-cols-3 y todo se ve diminuto.

Móvil con etiqueta viewport: una columna con texto legible

Figura 2. Con la etiqueta, la ventana mide 375 px y la rejilla queda en una columna.

💡 Consejo: Si tu página se ve bien al estrechar el navegador de escritorio pero mal en el móvil de verdad, comprueba esta etiqueta lo primero. Es un fallo frecuente y ninguna herramienta lo avisa al compilar. Nunca añadas user-scalable=no ni maximum-scale=1: impiden hacer zoom a quien lo necesita para leer.


3. Mobile first

La idea

Tailwind sigue el enfoque mobile first, “primero el móvil”. Una clase sin prefijo se aplica a todas las anchuras, y un prefijo como md: añade un estilo desde esa anchura hacia arriba.

Cuesta acostumbrarse porque casi todo el mundo lee sm: como “en pantallas pequeñas”, y es justo al revés:

<!-- MAL: esto NO centra el texto en móviles.
     Lo centra desde 40 rem hacia arriba -->
<h1 class="sm:text-center">Ofertas</h1>

<!-- BIEN: centrado en móvil, a la izquierda desde 40 rem -->
<h1 class="text-center sm:text-left">Ofertas</h1>

📘 Concepto: Lee md:flex como “desde md, flex”. El prefijo nunca dice “solo en”. Por debajo de esa anchura, la clase no existe, y queda lo que hayas dicho sin prefijo.

Los puntos de ruptura 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) los puntos de ruptura están en píxeles (640px, 768px…) y compilan a @media (min-width: 768px); en 4.0 están en rem y compilan a @media (width >= 48rem). Si usas la LTS: los nombres de las clases son los mismos y con la configuración normal del navegador cortan en los mismos sitios.

Un punto de ruptura (breakpoint) es la anchura en la que cambia el diseño. Tailwind trae cinco:

Prefijo Anchura en v4 Equivale a En v3 CSS que genera en v4
sm: 40rem 640 px 640px @media (width >= 40rem)
md: 48rem 768 px 768px @media (width >= 48rem)
lg: 64rem 1024 px 1024px @media (width >= 64rem)
xl: 80rem 1280 px 1280px @media (width >= 80rem)
2xl: 96rem 1536 px 1536px @media (width >= 96rem)

Barras que empiezan en cada punto de ruptura y llegan hasta el borde derecho; una línea en 800 px cruza las de base, sm y md

Figura 3. Cada variante empieza en su punto de ruptura y no termina nunca. A 800 px hay tres clases compitiendo.

Qué CSS se genera

Una clase con prefijo es la misma utilidad metida dentro de una consulta de medios:

<div class="p-4 md:p-8"></div>
/* v4 */
.p-4 {
  padding: calc(var(--spacing) * 4);
}

@media (width >= 48rem) {
  .md\:p-8 {
    padding: calc(var(--spacing) * 8);
  }
}
/* v3 */
.p-4 {
  padding: 1rem;
}

@media (min-width: 768px) {
  .md\:p-8 {
    padding: 2rem;
  }
}

width >= 48rem es la sintaxis de rangos de las consultas de medios: dice lo mismo que min-width: 48rem, pero se lee mejor. Se mantiene también en el CSS de producción: he compilado el catálogo de este módulo con vite build y el resultado contiene @media (width>=40rem). La admiten Chrome 104, Firefox 102 y Safari 16.4 según los datos de compatibilidad de MDN, todas dentro del mínimo que exige v4.

Por qué el orden del HTML no importa

A 800 px de ventana, en p-4 md:p-8 se cumplen las dos reglas. Las dos tienen la misma especificidad (una clase), así que gana la que aparece más tarde en el CSS. Y Tailwind escribe siempre las utilidades sin prefijo antes, seguidas de sm, md, lg, xl y 2xl, en ese orden.

Por eso estas dos líneas dan exactamente el mismo resultado:

<div class="p-4 md:p-8 lg:p-12"></div>
<div class="lg:p-12 p-4 md:p-8"></div>

El atributo class es un conjunto, no una lista ordenada. Lo que decide es la hoja de estilos. Si quieres verlo con tus ojos, en las herramientas del navegador la regla ganadora aparece arriba y las perdedoras tachadas.

Tu primera página adaptable

Escribe primero la versión de móvil, sin prefijos, y añade solo lo que cambia. Esta portada es una columna centrada en móvil, y desde md pasa a fila alineada a la izquierda:

<section class="flex flex-col items-center gap-4 rounded-2xl bg-indigo-700 p-6 text-center text-white md:flex-row md:p-10 md:text-left">
  <div class="md:flex-1">
    <h1 class="text-2xl font-bold md:text-4xl">Rebajas de otoño</h1>
    <p class="mt-2 text-indigo-100">Hasta un 20 % en portátiles y monitores.</p>
  </div>
  <a href="#" class="w-full rounded-lg bg-white px-5 py-3 font-semibold text-indigo-700 md:w-auto">Ver ofertas</a>
</section>

Portada a 375 px en columna y a 768 px en fila con el botón a la derecha

Figura 4. Solo lo que cambia lleva prefijo. El botón ocupa todo el ancho en móvil, porque ahí es más fácil de pulsar.

Fíjate en lo que no hay: no se repite bg-indigo-700 ni rounded-2xl. Un diseño mobile first bien hecho tiene pocos prefijos. Si en un elemento ves md: repetido en casi todo, probablemente la base está mal elegida.

Por qué empezar por el móvil

La razón no es de moda, es de complejidad:

  1. Una columna es el caso simple. Los elementos de bloque ya se apilan solos: gran parte del móvil te sale sin escribir nada de disposición.
  2. Añadir es más fácil que deshacer. Con desktop first escribes grid-cols-4 y luego, para móvil, tienes que anular columnas, márgenes y alineaciones.
  3. Obliga a priorizar. Si algo no cabe en 375 px, tienes que decidir si importa.

Una rejilla de tres saltos

Las variantes valen para casi cualquier utilidad. Lo más habitual es cambiar el número de columnas. Si necesitas repasar las clases de rejilla, están en el módulo 09:

<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
  <article class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
    <div class="mb-3 grid aspect-video place-items-center rounded-lg bg-indigo-100 text-2xl font-bold text-indigo-700">UB</div>
    <h3 class="font-semibold">Portátil UltraBook 14</h3>
    <p class="text-sm text-slate-600">Ligero, 16 GB de RAM</p>
    <p class="mt-2 font-bold text-indigo-700">899 €</p>
  </article>
  <!-- ...el resto, con la misma estructura... -->
</div>

El catálogo a 375, 768 y 1300 px con una, dos y cuatro columnas

Figura 5. Una, dos y cuatro columnas, con el marco de 375 px recortado. Entre lg y xl habría tres.

Combinar con otras variantes

El prefijo responsive se combina con cualquier otra variante, que verás con detalle en el módulo 11 y el módulo 12:

<a class="text-slate-700 md:hover:text-indigo-700 dark:md:text-slate-200">Catálogo</a>

md:hover:text-indigo-700 significa: desde md, al pasar el ratón. Si usas print:hidden en cabeceras y menús, también es una variante de medios, para cuando se imprime la página.


4. Mostrar y ocultar

Dos patrones cubren casi todos los casos:

Quiero Clases
Oculto en móvil, visible desde md hidden md:flex (o md:block, md:grid…)
Visible en móvil, oculto desde md md:hidden
<div class="flex items-center justify-between rounded-xl border border-slate-200 bg-white p-3">
  <p class="font-bold text-indigo-700">EMPRESA</p>

  <!-- Enlaces: solo desde md -->
  <nav class="hidden gap-6 md:flex">
    <a href="#">Catálogo</a>
    <a href="#">Ofertas</a>
    <a href="#">Contacto</a>
  </nav>

  <!-- Botón de menú: solo por debajo de md -->
  <button class="rounded border border-slate-300 px-3 py-1 md:hidden">Menú</button>

  <!-- Carrito: icono siempre; el texto se ve desde sm, pero el lector de pantalla lo lee siempre -->
  <a href="#" class="rounded bg-indigo-600 px-3 py-1 text-white">
    <span aria-hidden="true">🛒</span>
    <span class="sr-only sm:not-sr-only">Carrito (3)</span>
  </a>
</div>

Barra de navegación a 375, 500 y 800 px: el botón Menú desaparece al llegar a md y el texto del carrito aparece desde sm

Figura 6. El botón “Menú” y los enlaces se turnan en md. El texto del carrito aparece antes, en sm.

Tres detalles que suelen fallar:

  • Di qué display quieres recuperar. hidden md:block en una barra que debía ser flex deja de ser flex. Por eso aquí es hidden gap-6 md:flex.
  • hidden no es lo mismo que invisible. hidden quita el elemento de la página; invisible lo oculta, pero conserva su hueco. Lo viste en el módulo 07.
  • Lo que está oculto con hidden tampoco lo lee un lector de pantalla. Si solo quieres ahorrar sitio en el texto, usa sr-only sm:not-sr-only, que en la barra anterior deja el nombre “Carrito (3)” disponible a cualquier anchura, aunque no se vea.
/* v4: md:not-sr-only deshace, propiedad por propiedad, lo que hace sr-only */
@media (width >= 48rem) {
  .md\:not-sr-only {
    position: static;
    width: auto;
    height: auto;
    padding: 0;
    margin: 0;
    overflow: visible;
    clip-path: none;
    white-space: normal;
  }
}

⚠️ Importante: Duplicar un bloque entero, uno md:hidden y otro hidden md:block, funciona, pero te obliga a mantener dos copias, descarga todo dos veces y puede repetir identificadores. Hazlo solo si las dos versiones son realmente distintas. Casi siempre basta con cambiar la disposición del mismo HTML.


5. Hacia abajo y entre dos puntos: max-*

Con mobile first cubres la mayoría de casos, pero a veces un estilo pertenece solo a la zona pequeña. Para eso están las variantes max-*, una por cada punto de ruptura: max-sm:, max-md:, max-lg:, max-xl: y max-2xl:.

Y como las variantes se apilan, poner una mínima y una máxima crea una franja:

<div class="grid grid-cols-3 gap-3">
  <div class="caja bg-slate-400 max-md:bg-orange-500">max-md</div>
  <div class="caja bg-slate-400 md:max-lg:bg-orange-500">md:max-lg</div>
  <div class="caja bg-slate-400 lg:bg-orange-500">lg</div>
</div>

Tres cajas a 600, 900 y 1100 px; en cada anchura se ilumina solo una

Figura 7. Cada franja se ilumina solo en su tramo. Las tres cubren todas las anchuras sin hueco ni solape.

/* v4 */
@media (width < 48rem) {
  .max-md\:bg-orange-500 { background-color: var(--color-orange-500); }
}

@media (width >= 48rem) {
  @media (width < 64rem) {
    .md\:max-lg\:bg-orange-500 { background-color: var(--color-orange-500); }
  }
}
/* v3: mismo efecto, escrito con una negación */
@media not all and (min-width: 768px) {
  .max-md\:bg-orange-500 { /* ... */ }
}

Esquema de las franjas de md, max-md, md:max-lg y sm:max-lg sobre un eje de anchura

Figura 8. max-md termina justo donde empieza md.

La frontera es exacta. Medido en Chromium, en las dos versiones: a 767 px se cumple max-md y no md; a 768 px, al revés.

Para terminar, algo que sorprende a más de uno. En el CSS, las max-* van antes que las sm, md…: primero las máximas, de mayor a menor, y luego las mínimas, de menor a mayor. Es decir, si en un mismo elemento compiten max-md: y sm: y se cumplen a la vez, gana sm:. Lo comprobarás en el ejercicio 2.

💡 Consejo: Usa max-* para una excepción puntual, no como forma de trabajar. Si llenas una página de max-md: estás escribiendo desktop first con otro nombre, y pierdes las ventajas de la sección 3. Encaja bien en un caso que verás luego: la tabla que solo se transforma en móvil, porque evita tener que deshacer esa transformación en cada clase.


6. Valores puntuales y otras consultas

min-[...] y max-[...] 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) max-[900px]: compila a @media (max-width: 900px), que incluye el valor exacto; en 4.0 compila a width < 900px, que lo excluye. Si usas la LTS: escribe max-[899.98px]: si combinas min-[900px]: y max-[900px]: en la misma frontera, porque a 900 px se cumplirían las dos.

A veces el diseño se rompe en una anchura que no coincide con ningún punto de ruptura, y solo una vez en toda la web. Para eso admiten un valor arbitrario:

<div class="min-[320px]:text-center max-[600px]:bg-amber-100">...</div>

Esta es la prueba real con las dos versiones, en la frontera de 900 px:

Ventana v3: min-[900px] v3: max-[900px] v4: min-[900px] v4: max-[900px]
899 px no sí no sí
900 px sí sí sí no
901 px sí no sí no

En v3, justo a 900 px, se cumplen ambas. Es un falso amigo: la clase existe en las dos versiones, tiene el mismo aspecto, y la diferencia solo aparece en una anchura exacta. Curiosamente, las max-md: con nombre nunca tuvieron este problema, porque en v3 se generan con la negación que has visto antes.

Valores arbitrarios en rem 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) los puntos de ruptura están en píxeles, y min-[900px]: se ordena bien entre ellos; en 4.0 están en rem, y un valor en píxeles sale antes que todos. Si usas la LTS: escribe min-[900px]:; en v4, escribe min-[56.25rem]:, y el orden es correcto.

Los valores arbitrarios tienen una trampa que no se ve en el nombre. Tailwind solo ordena entre sí los valores de la misma unidad, y los puntos de ruptura de v4 están en rem. Mira qué ocurre a 1000 px, donde se cumplen las dos clases de cada línea:

<!-- v4: 2 columnas. md: va después en el CSS y gana -->
<div class="grid md:grid-cols-2 min-[900px]:grid-cols-3">...</div>

<!-- v4: 3 columnas. Misma unidad, orden por valor -->
<div class="grid md:grid-cols-2 min-[56.25rem]:grid-cols-3">...</div>

Son medidas reales en Chromium. En v3 la primera línea da 3 columnas, porque allí todo son píxeles. Con max-* pasa lo mismo: max-[900px]: sale antes que max-lg:, y a 800 px, donde se cumplen ambas, gana max-lg:.

💡 Consejo: En v4, escribe los valores arbitrarios en rem. Y si repites el mismo valor en varios sitios, ese valor es un punto de ruptura y debería tener nombre: la sección 7 explica cómo dárselo.

Orientación, altura e impresión

Variante CSS Para qué sirve
portrait: @media (orientation: portrait) Alto mayor o igual que el ancho
landscape: @media (orientation: landscape) Ancho mayor que el alto
print: @media print Hoja de impresión

Las tres existen igual en v3. No hay variantes de altura con nombre, pero puedes escribir una con una variante arbitraria. Los teléfonos en horizontal son el caso típico, con muy poca altura:

<!-- Una cabecera pegada solo si hay altura de sobra -->
<header class="[@media(height>=40rem)]:sticky [@media(height>=40rem)]:top-0">...</header>

Si la vas a repetir, dale un nombre. En v4, con @custom-variant en tu CSS (lo verás a fondo en el módulo 15):

@custom-variant alto (@media (height >= 40rem));
<header class="alto:sticky alto:top-0">...</header>

En v3 se declara dentro de screens, aunque con la consecuencia que verás en la sección siguiente:

// tailwind.config.js (v3)
module.exports = {
  theme: { extend: { screens: { alto: { raw: '(min-height: 40rem)' } } } },
};

7. Personalizar los puntos de ruptura 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) se configuran en theme.screens de tailwind.config.js; en 4.0, con variables --breakpoint-* en @theme. Si usas la LTS: escribe theme: { extend: { screens: { xs: '480px', '3xl': '1920px' } } }, y usa una sola unidad.

Añadir, cambiar y quitar

Los puntos de ruptura son variables del tema, del espacio de nombres --breakpoint-*. Viste el mecanismo en el módulo 02, y el módulo 14 trata @theme a fondo. Cada variable crea su variante:

@import "tailwindcss";

@theme {
  --breakpoint-xs: 30rem;     /* nuevo: xs: y max-xs: */
  --breakpoint-3xl: 120rem;   /* nuevo: 3xl: y max-3xl: */
  --breakpoint-md: 50rem;     /* cambia md: de 48 a 50 rem */
  --breakpoint-2xl: initial;  /* elimina 2xl: */
}
<div class="grid grid-cols-1 gap-3 xs:grid-cols-2 lg:grid-cols-4 3xl:grid-cols-8">
  <div class="caja">1</div><div class="caja">2</div><div class="caja">3</div><div class="caja">4</div>
  <div class="caja">5</div><div class="caja">6</div><div class="caja">7</div><div class="caja">8</div>
</div>

Ocho cajas en una, dos y cuatro columnas a 400, 560 y 1100 px gracias al punto xs

Figura 9. xs: a 30 rem (480 px) ya funciona en la ventana de 560 px. 3xl: no se ve aquí: solo actuaría desde 1920 px.

Tailwind ordena la salida por valor, no por el orden en el que los declares. He compilado 3xl:, xs:, sm:, un tablet: de 52 rem y md:, y salieron así: 30, 40, 48, 52 y 120 rem. También funcionan las max-* de los puntos nuevos.

Para partir de cero y dejar solo los tuyos:

@theme {
  --breakpoint-*: initial;
  --breakpoint-movil: 30rem;
  --breakpoint-pc: 64rem;
}

Con esto, md:flex deja de generar nada. Hay un efecto que conviene tener presente: la utilidad container lee estas mismas variables, así que sus saltos pasan a ser 30 y 64 rem.

No mezcles unidades

Este es el error importante de la sección. Tailwind compara los valores para ordenarlos, y solo sabe comparar los que tienen la misma unidad. Si declaras uno en px junto a los de fábrica, en rem:

@theme {
  --breakpoint-xs: 480px;
  --breakpoint-3xl: 1920px;
}

El orden real de la salida es este:

@media (width >= 480px)  { /* xs  */ }
@media (width >= 1920px) { /* 3xl */ }
@media (width >= 40rem)  { /* sm  */ }
@media (width >= 48rem)  { /* md  */ }

3xl: va antes que md:, así que con md:grid-cols-2 3xl:grid-cols-8 a 2000 px gana md:. No hay ningún aviso: simplemente, 3xl parece no hacer nada. Es el mismo problema de min-[900px]: que viste en la sección 6.

⚠️ Importante: En v4 escribe tus puntos de ruptura en rem. Para convertir, divide entre 16: 480 px son 30 rem; 1920 px, 120 rem.

Por qué rem y no px

Parece un cambio de unidad sin más, pero tiene un efecto real. Un rem en una consulta de medios vale el tamaño de letra que configura el navegador, no el de tu CSS. Si una persona con problemas de visión sube la letra por defecto, los puntos de ruptura se desplazan con ella y la página pasa antes a menos columnas.

He comprobado los dos casos en Chromium, con una ventana de 800 px y el catálogo de la sección 3:

Letra del navegador ¿Se cumple width >= 40rem? ¿Se cumple min-width: 640px? Columnas
16 px (por defecto) Sí Sí 2
24 px No (40 rem son 960 px) Sí 1

Con 24 px de letra, 800 px de ventana equivalen a menos de 40 rem, así que la página da una sola columna. Es lo que te conviene: letra grande en dos columnas estrechas sería peor. Un diseño en píxeles, como el de v3, se mantiene en dos columnas.

En cambio, html { font-size: 32px } en tu CSS no cambia ninguna consulta. También lo he comprobado: la raíz pasa a medir 32 px, y width >= 48rem sigue dando el mismo resultado.

v3: lo que hay que saber de screens

En v3 se escribe así, y el orden se resuelve también por valor. En la 3.4.19, xs añadido con extend queda antes que sm, sin necesidad de reordenar nada:

// tailwind.config.js (v3)
module.exports = {
  theme: {
    extend: {
      screens: { xs: '480px', '3xl': '1920px' },
    },
  },
};

Lo que sí hay que vigilar en v3 es que max-* y min-[...] dependen de que todos los valores sean simples y de la misma unidad. Lo he comprobado con estas tres configuraciones:

Configuración v3 md:flex max-md:flex min-[500px]:flex
xs: '480px' Sí Sí Sí
xs: '30rem' (mezcla unidades) Sí No se genera No se genera
alto: { raw: '...' } Sí No se genera No se genera
tablet: { min: '768px', max: '1023px' } Sí No se genera No se genera

La clase desaparece en silencio. Si después de añadir un punto de ruptura dejan de funcionar tus max-*, mira aquí.

Usar un punto de ruptura desde tu CSS 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) se escribe @screen md { ... } o @media screen(md) { ... }. Si usas la LTS: usa @screen md donde aquí aparece @variant md, y theme('screens.md') donde aparece theme(--breakpoint-md).

Si tienes CSS propio, no copies el valor a mano. La directiva @variant lo toma del tema:

@layer components {
  .banner {
    padding: 1rem;

    @variant md {
      padding: 3rem;
    }
  }
}

Si necesitas escribir la consulta completa, usa la función theme():

@media (width >= theme(--breakpoint-lg)) {
  .banner { padding: 4rem; }
}

⚠️ Importante: @media (width >= var(--breakpoint-lg)) no funciona, y no es culpa de Tailwind: CSS no permite variables en la condición de una consulta de medios. Tailwind lo deja tal cual en la salida y el navegador lo descarta. theme() funciona porque se sustituye al compilar.

Para limitar un ancho al de un punto de ruptura sí sirve la variable, porque va en el valor de una propiedad: max-w-(--breakpoint-md). La antigua max-w-screen-md sigue generándose en 4.3.3 como clase obsoleta, y es lo que debes usar en v3.


8. Responsive sin puntos de ruptura

Antes de añadir otro prefijo, pregúntate si el diseño puede adaptarse solo. Un punto de ruptura es un salto; muchas cosas funcionan mejor sin saltos:

<h1 class="font-bold text-[clamp(1.5rem,1rem+3vw,3rem)]">Nuestro catálogo</h1>
<div class="mt-4 grid grid-cols-[repeat(auto-fill,minmax(min(12rem,100%),1fr))] gap-3">
  <div class="caja">Portátiles</div>
  <div class="caja">Torres</div>
  <div class="caja">Monitores</div>
  <div class="caja">Teléfonos</div>
  <div class="caja">Periféricos</div>
</div>

A 375 px el título es menor y las categorías van en una columna; a 800 px el título es mayor y hay tres columnas

Figura 10. Ni un solo prefijo: el título crece poco a poco y las columnas son las que quepan.

La rejilla usa repeat(auto-fill, minmax(min(12rem, 100%), 1fr)), que significa: “mete tantas columnas de al menos 12 rem como quepan, y reparte lo que sobre”. El min(12rem, 100%) evita que la rejilla desborde en un contenedor de menos de 12 rem. La tienes explicada en el módulo 09.

El título usa clamp(mínimo, ideal, máximo). El valor ideal mezcla una parte fija y otra proporcional a la ventana (1rem + 3vw). La parte fija importa: un text-[5vw] puro no crece cuando la persona aumenta el zoom, porque vw depende de la ventana.

Otras clases que adaptan sin prefijos, ya conocidas: flex-wrap y basis-* (el módulo 08), max-w-* con w-full y columns-xs, que crea las columnas que quepan.

Técnica Clases Cuándo usarla
Rejilla fluida grid-cols-[repeat(auto-fill,minmax(...))] Listas de tarjetas iguales
Fila que se parte flex flex-wrap gap-* Etiquetas, botones, filtros
Tipografía fluida text-[clamp(...)] Títulos grandes de portada
Límite de ancho w-full max-w-* Formularios y texto largo
Punto de ruptura md:* Cambios de estructura, como pasar de una columna a dos con barra lateral

9. Consultas de contenedor 🆕 v4.0

🆕 Desde Tailwind 4.0. No existe en la LTS (Tailwind 3.4) sin ayuda. Si usas la LTS: instala el plugin @tailwindcss/container-queries y añádelo a plugins; da @container, @md: y los nombres, aunque no @max-*, @min-*, @3xs ni @2xs.

El problema

Las consultas de medios preguntan por la ventana. Pero un componente no sabe dónde lo van a colocar. Mira esta tarjeta, escrita con md:flex-row y puesta en dos sitios de la misma página:

<div class="grid grid-cols-[18rem_1fr] gap-4">
  <aside class="rounded-xl bg-slate-200 p-3">
    <h2 class="mb-2 text-sm font-semibold text-slate-600">Barra lateral</h2>
    <article class="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-3 md:flex-row">
      <div class="grid aspect-video place-items-center rounded-lg bg-indigo-100 font-bold text-indigo-700 md:w-40">M4</div>
      <div>
        <h3 class="font-semibold">Monitor 27 4K</h3>
        <p class="text-sm text-slate-600">Alta resolución para diseño y edición de vídeo.</p>
      </div>
    </article>
  </aside>
  <main class="rounded-xl bg-slate-200 p-3">
    <h2 class="mb-2 text-sm font-semibold text-slate-600">Zona principal</h2>
    <article class="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-3 md:flex-row">
      <div class="grid aspect-video place-items-center rounded-lg bg-indigo-100 font-bold text-indigo-700 md:w-40">M4</div>
      <div>
        <h3 class="font-semibold">Monitor 27 4K</h3>
        <p class="text-sm text-slate-600">Alta resolución para diseño y edición de vídeo.</p>
      </div>
    </article>
  </main>
</div>

La tarjeta en la barra lateral está en fila y apretada; en la zona principal, en fila y holgada

Figura 11. A 1000 px, la ventana dice “hay sitio” y las dos se ponen en fila. En la barra lateral eso es falso.

Las clases no pueden saber que esta tarjeta en concreto solo dispone de 264 px, y si le pones otras clases, ya tienes dos componentes.

La solución

Una consulta de contenedor hace la pregunta a un antepasado: “¿cuánto mides tú?”. Hay dos piezas:

  1. @container marca el elemento que se va a medir.
  2. @md: (con arroba) aplica el estilo según lo que mida ese contenedor.

Dos esquemas: a la izquierda, las tarjetas preguntan a la ventana; a la derecha, cada una pregunta a su contenedor

Figura 12. La misma ventana, y dos respuestas distintas porque cada tarjeta pregunta a un sitio diferente.

<div class="grid grid-cols-[18rem_1fr] gap-4">
  <aside class="@container rounded-xl bg-slate-200 p-3">
    <h2 class="mb-2 text-sm font-semibold text-slate-600">Barra lateral</h2>
    <article class="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-3 @md:flex-row">
      <div class="grid aspect-video place-items-center rounded-lg bg-indigo-100 font-bold text-indigo-700 @md:w-40">M4</div>
      <div>
        <h3 class="font-semibold">Monitor 27 4K</h3>
        <p class="text-sm text-slate-600">Alta resolución para diseño y edición de vídeo.</p>
      </div>
    </article>
  </aside>
  <main class="@container rounded-xl bg-slate-200 p-3">
    <h2 class="mb-2 text-sm font-semibold text-slate-600">Zona principal</h2>
    <article class="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-3 @md:flex-row">
      <div class="grid aspect-video place-items-center rounded-lg bg-indigo-100 font-bold text-indigo-700 @md:w-40">M4</div>
      <div>
        <h3 class="font-semibold">Monitor 27 4K</h3>
        <p class="text-sm text-slate-600">Alta resolución para diseño y edición de vídeo.</p>
      </div>
    </article>
  </main>
</div>

La tarjeta en la barra lateral está en columna y en la zona principal en fila, con el mismo código

Figura 13. Mismas clases en las dos tarjetas. Cada una se adapta a su sitio.

El CSS generado es casi idéntico al de los puntos de ruptura:

.\@container {
  container-type: inline-size;
}

@container (width >= 28rem) {
  .\@md\:flex-row {
    flex-direction: row;
  }
}

container-type: inline-size declara que el elemento se puede consultar por su anchura. Cuando aparece @container (...), el navegador busca el antepasado contenedor más cercano y pregunta por el ancho de su caja de contenido, sin el relleno. Por eso la barra de 288 px con p-3 mide 264 px.

Son compatibles con Chrome 105, Firefox 110 y Safari 16 según los datos de MDN, versiones anteriores al mínimo de v4. Con v3 y un navegador más antiguo, estas clases simplemente no se cumplen y se queda el estilo base. Por eso conviene que la base sea una versión digna, como la columna de este ejemplo.

La escala de contenedores

Los tamaños son distintos de los puntos de ruptura, porque los componentes son más pequeños que las ventanas:

Variante Anchura Píxeles Plugin v3
@3xs: 16rem 256 No
@2xs: 18rem 288 No
@xs: 20rem 320 Sí
@sm: 24rem 384 Sí
@md: 28rem 448 Sí
@lg: 32rem 512 Sí
@xl: 36rem 576 Sí
@2xl: 42rem 672 Sí
@3xl: 48rem 768 Sí
@4xl: 56rem 896 Sí
@5xl: 64rem 1024 Sí
@6xl: 72rem 1152 Sí
@7xl: 80rem 1280 Sí

⚠️ Importante: @md y md no son el mismo tamaño: md: son 48 rem de ventana y @md: son 28 rem de contenedor. Quien pasa un diseño de una a otra sin revisar se encuentra con que todo cambia antes de tiempo.

Dos errores que te vas a encontrar

Un elemento no puede consultarse a sí mismo. La consulta busca un antepasado. Si pones @container y @md: en el mismo elemento, la clase mira hacia arriba, no encuentra contenedor y no se cumple:

<!-- MAL: la consulta mira a un ANTEPASADO, no al propio elemento -->
<div class="@container flex flex-col gap-2 @md:flex-row">
  <div class="caja flex-1">A</div>
  <div class="caja flex-1">B</div>
</div>

<!-- BIEN: un envoltorio mide y el hijo reacciona -->
<div class="@container mt-4">
  <div class="flex flex-col gap-2 @md:flex-row">
    <div class="caja flex-1">A</div>
    <div class="caja flex-1">B</div>
  </div>
</div>

Arriba, A y B siguen en columna a pesar de @md:flex-row; abajo, con un envoltorio, se ponen en fila

Figura 14. Arriba no pasa nada: el @md: y el @container están en el mismo elemento. Abajo, un envoltorio mide y el hijo reacciona.

El contenedor no puede medir lo que mide su contenido. Al declarar container-type: inline-size, el navegador calcula la anchura del elemento como si no tuviera contenido. Si no lo hiciera, habría un círculo: el contenido depende del contenedor, y el contenedor del contenido. En un bloque normal no se nota, porque un bloque ocupa todo el ancho de su padre. En cualquier elemento que se ajusta a su contenido, como un hijo de flex, w-fit o inline-block, el resultado es 0:

<!-- MAL: el contenedor es hijo de un flex y su ancho dependía del contenido -->
<div class="flex gap-3 rounded-xl bg-slate-200 p-3">
  <div class="@container">
    <p class="caja">Etiqueta</p>
  </div>
  <p class="text-sm">El contenedor de la izquierda mide 0 de ancho.</p>
</div>

<!-- BIEN: se le da un ancho que no dependa del contenido -->
<div class="mt-3 flex gap-3 rounded-xl bg-slate-200 p-3">
  <div class="@container flex-1">
    <p class="caja">Etiqueta</p>
  </div>
  <p class="flex-1 text-sm">Con flex-1 el ancho sale del reparto.</p>
</div>

Arriba, la etiqueta queda aplastada en un contenedor de ancho 0 y se monta con el texto; abajo se ve bien

Figura 15. Medido en Chromium: el primer contenedor mide 0 px y el segundo, con flex-1, 366 px. El contenido se desborda sin aviso.

💡 Consejo: Si un componente con @container “desaparece” o se monta con lo de al lado, revisa su ancho en las herramientas del navegador. Si es 0, dale un ancho que no dependa del contenido: flex-1, w-full o una columna de rejilla.

Contenedores con nombre

@md: responde al contenedor más cercano. Si hay varios anidados y quieres preguntar por uno concreto, ponle nombre con /nombre, en los dos lados:

<section class="@container/catalogo rounded-xl bg-slate-200 p-3">
  <div class="grid grid-cols-2 gap-3">
    <article class="@container/tarjeta rounded-xl bg-white p-3">
      <h3 class="font-semibold">Monitor 27 4K</h3>
      <!-- Reacciona a la tarjeta, la anidada más cercana con ese nombre -->
      <p class="text-sm text-slate-600 @max-3xs/tarjeta:hidden">Alta resolución.</p>
      <!-- Reacciona al catálogo, aunque haya un contenedor más cerca -->
      <p class="mt-1 font-bold @2xl/catalogo:text-2xl">329 €</p>
    </article>
    <article class="@container/tarjeta rounded-xl bg-white p-3">
      <h3 class="font-semibold">Monitor 24 FHD</h3>
      <p class="text-sm text-slate-600 @max-3xs/tarjeta:hidden">Para oficina.</p>
      <p class="mt-1 font-bold @2xl/catalogo:text-2xl">139 €</p>
    </article>
  </div>
</section>

A 420 px las tarjetas ocultan la descripción y el precio es pequeño; a 800 px se ve la descripción y el precio es grande

Figura 16. Cada línea atiende a un contenedor distinto: la descripción, a su tarjeta; el precio, al catálogo.

.\@container\/catalogo {
  container-type: inline-size;
  container-name: catalogo;
}

@container catalogo (width >= 42rem) {
  .\@2xl\/catalogo\:text-2xl { /* ... */ }
}

En una página real esto evita sorpresas: un @md: sin nombre cambia de significado si mañana alguien mete otro @container en medio. Si la pregunta es sobre un contenedor lejano, da siempre el nombre.

@max-*, rangos y valores arbitrarios

Todo lo aprendido con los puntos de ruptura se repite con arroba:

Clase CSS generado (condición) En el plugin v3
@md:flex (width >= 28rem) Sí
@max-md:flex (width < 28rem) No
@sm:@max-md:flex >= 24rem y < 28rem No
@[30rem]:flex (width >= 30rem) Sí
@min-[30rem]:flex (width >= 30rem) No
@max-[30rem]:flex (width < 30rem) No
@md/lateral:flex lateral (width >= 28rem) Sí

Equivalente en v3 de @max-* 🆕 v4.0

🆕 Desde Tailwind 4.0. No existe en la LTS (Tailwind 3.4): el plugin no genera @max-*, @min-*, @3xs ni @2xs. Si usas la LTS: invierte la lógica (hidden @md:block en lugar de @max-md:hidden), o usa una variante arbitraria como [@container(max-width:28rem)]:hidden.

Contenedores a medida 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) con el plugin, los tamaños se añaden con theme.extend.containers en tailwind.config.js. Si usas la LTS: escribe theme: { extend: { containers: { tarjeta: '22rem' } } }.

La escala sale de las variables --container-*, las mismas que usan max-w-*:

@theme {
  --container-tarjeta: 22rem;
}

Esto te da a la vez @tarjeta:flex, @max-tarjeta:flex y max-w-tarjeta.

Unidades de contenedor

Dentro de un contenedor puedes medir con unidades relativas a él. cqi es el 1 % de su anchura (más exactamente, de su eje en línea). Es como vw, pero relativa al componente:

<div class="grid grid-cols-[1fr_2fr] gap-3">
  <div class="@container rounded-xl bg-indigo-700 p-3 text-white">
    <p class="text-[12cqi] font-bold">Rebajas</p>
  </div>
  <div class="@container rounded-xl bg-indigo-700 p-3 text-white">
    <p class="text-[12cqi] font-bold">Rebajas</p>
  </div>
</div>

El mismo título se ve pequeño en la caja estrecha y grande en la ancha

Figura 17. text-[12cqi]: 27,36 px en la caja estrecha y 57,6 px en la ancha, medidos en Chromium.

Unidad Significado
cqw / cqh 1 % de la anchura / altura del contenedor
cqi / cqb 1 % del eje en línea / de bloque
cqmin / cqmax 1 % del lado menor / mayor

Son CSS estándar, así que funcionan como valores arbitrarios también en v3 (text-[12cqi] genera lo mismo). Sin ningún contenedor por encima se comportan como las de la ventana: a 400 px, text-[12cqi] da 48 px.

Cuál elegir: medios o contenedor

Situación Usa Por qué
Barra lateral que aparece o desaparece lg: Es la estructura de la página
Menú de la cabecera md: La cabecera siempre ocupa toda la ventana
Tarjeta que se usa en varias zonas @md: No sabe dónde la van a poner
Widget de un panel que el usuario redimensiona @md: La ventana no cambia, pero el panel sí
Tabla dentro de una tarjeta @lg: Depende de la tarjeta
Componente de una biblioteca @md: Quien lo use decide dónde colocarlo

La regla práctica: la ventana decide la estructura de la página y el contenedor decide cómo se adapta cada componente. Si el elemento se puede reutilizar en otro sitio, usa contenedor.

Orden entre medios y contenedor 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) con el plugin, las consultas de contenedor salen antes que las de medios; en 4.0 salen después. Si usas la LTS: con md:block @md:flex en v3 gana md:block, y en 4.0, @md:flex. Evita que las dos compitan por la misma propiedad.

Comprobado en un elemento donde se cumplen las dos, a 1000 px: display vale block en v3 con el plugin y flex en 4.3.3. Si necesitas mezclar las dos clases en el mismo elemento y la misma propiedad, casi siempre hay un diseño más simple.

Instalar el plugin en v3

npm install -D @tailwindcss/container-queries
// tailwind.config.js (v3)
module.exports = {
  content: ['./*.html'],
  plugins: [require('@tailwindcss/container-queries')],
};

La versión actual del plugin es la 0.1.1. Genera @container (min-width: 28rem) y .\@container { container-type: inline-size }, y todos los ejemplos de esta sección se adaptan a él salvo las clases @max-*.


10. Consultar la altura: @container-size 🆕 v4.3

🆕 Desde Tailwind 4.3. No existe en la LTS (Tailwind 3.4) ni en 4.0 a 4.2. Si usas la LTS: usa la propiedad arbitraria [container-type:size], que genera el mismo CSS y también funciona en 4.0 a 4.2.

@container solo permite consultar la anchura. @container-size declara container-type: size, que deja consultar los dos ejes:

.\@container-size {
  container-type: size;
}

Con el nombre se escribe igual: @container-size/mosaico. Y @container-normal devuelve container-type: normal, para desactivar la medida en un punto de ruptura.

Un caso real son los mosaicos de un panel de control, donde el tamaño del número debe ajustarse tanto al ancho como al alto:

<div class="grid grid-cols-[10rem_1fr] grid-rows-[8rem_16rem] gap-3">
  <div class="@container-size rounded-xl bg-white p-3 shadow-sm">
    <p class="text-slate-600 text-[min(12cqw,15cqh)]">Pedidos</p>
    <p class="font-bold leading-none text-indigo-700 text-[min(30cqw,35cqh)]">24</p>
  </div>
  <div class="@container-size rounded-xl bg-white p-3 shadow-sm">
    <p class="text-slate-600 text-[min(12cqw,15cqh)]">Pedidos</p>
    <p class="font-bold leading-none text-indigo-700 text-[min(30cqw,35cqh)]">24</p>
  </div>
  <div class="@container-size col-span-2 rounded-xl bg-white p-3 shadow-sm">
    <p class="text-slate-600 text-[min(12cqw,15cqh)]">Pedidos</p>
    <p class="font-bold leading-none text-indigo-700 text-[min(30cqw,35cqh)]">24</p>
    <p class="hidden text-sm text-slate-600 [@container(height>=12rem)]:block">Tres pendientes de enviar. El detalle solo aparece si la tarjeta tiene altura suficiente.</p>
  </div>
</div>

Tres mosaicos: dos bajos con el número pequeño y uno alto con número grande y una línea de detalle

Figura 18. El número grande usa min(30cqw, 35cqh): en los mosaicos bajos manda la altura, y en el alto puede crecer. Solo este muestra el detalle.

Tailwind 4.3.3 no trae variantes de altura como @h-md: ni @min-h-md:: las he probado y no generan nada. Para preguntar por la altura hay dos opciones:

  • Las unidades cqh y cqmin, como en este ejemplo.
  • Una variante arbitraria, escrita a mano: [@container(height>=12rem)]:block.

⚠️ Importante: @[height>=10rem]:flex parece la forma lógica, pero genera @container (width >= height>=10rem), que no es CSS válido. Dentro de @[...] solo cabe un valor de anchura.

La exigencia de size

El mismo razonamiento de la sección anterior se aplica ahora en los dos ejes. Con container-type: size, ni el ancho ni el alto del elemento pueden depender de su contenido. Si no le das una altura explícita, mide 0:

<div class="rounded-xl bg-slate-200 p-3">
  <!-- Sin altura explícita: su contenido ya no cuenta y mide 0 -->
  <div class="@container-size bg-orange-200">
    <p class="caja">Sin altura</p>
  </div>
</div>
<div class="mt-8 rounded-xl bg-slate-200 p-3">
  <!-- Con altura explícita -->
  <div class="@container-size h-20 bg-orange-200">
    <p class="caja">h-20</p>
  </div>
</div>

Con @container-size sin altura, el fondo naranja no se ve porque la caja mide 0; con h-20 sí

Figura 19. Medido en Chromium: 0 px sin altura, y 80 px con h-20. Sin altura, el contenido se desborda por encima.

Por eso @container-size solo tiene sentido si la altura viene dada desde fuera: h-*, aspect-*, una fila de rejilla como en el ejemplo, o flex-1 en una columna con altura. Para el caso general usa @container, que no tiene este problema.


11. Tablas adaptables

Una tabla es el componente que peor lleva el móvil: cinco columnas no caben en 375 px, y no se pueden apilar como unas tarjetas sin perder información. Hay tres estrategias, de menos a más trabajo. Todas usan datos reales de los pedidos de EMPRESA; el módulo 17 completa el estilo de las tablas.

Estrategia 1: desplazamiento horizontal

Es la más rápida y no oculta nada. La tabla conserva un ancho mínimo y su envoltorio se desplaza:

<div tabindex="0" role="region" aria-label="Pedidos recientes" class="overflow-x-auto rounded-xl border border-slate-200 bg-white">
  <table class="w-full min-w-xl text-left text-sm">
    <thead class="bg-slate-100 text-slate-600">
      <tr>
        <th class="sticky left-0 bg-slate-100 px-3 py-2">Pedido</th>
        <th class="px-3 py-2">Cliente</th>
        <th class="px-3 py-2">Ciudad</th>
        <th class="px-3 py-2">Fecha</th>
        <th class="px-3 py-2 text-right">Total</th>
      </tr>
    </thead>
    <tbody class="divide-y divide-slate-200">
      <tr>
        <th class="sticky left-0 bg-white px-3 py-2">5001</th>
        <td class="px-3 py-2">Talleres Martín S.L.</td>
        <td class="px-3 py-2">Valencia</td>
        <td class="px-3 py-2">15/01/2025</td>
        <td class="px-3 py-2 text-right">1816,80 €</td>
      </tr>
      <!-- ...el resto, con la misma estructura... -->
    </tbody>
  </table>
</div>

A 375 px la tabla se corta y se desplaza dentro de su caja; a 800 px se ve completa

Figura 20. La columna “Pedido” se queda fija al desplazar, porque sin ella pierdes la referencia de cada fila.

Cada detalle tiene su motivo:

  • overflow-x-auto va en el envoltorio, no en la tabla.
  • min-w-xl (36 rem) impide que la tabla se comprima hasta que no quepa el texto. En v3 no existe esta clase: escribe min-w-[36rem].
  • sticky left-0 necesita bg-* propio. Sin fondo, las demás columnas se verían a través de la celda al pasar por debajo.
  • tabindex="0", role="region" y aria-label permiten desplazar con el teclado y dan nombre a la zona.

Estrategia 2: ocultar las columnas secundarias

Si hay columnas prescindibles, se ocultan según la anchura. Hay que ocultar la cabecera y todas las celdas de esa columna. Si te olvidas de una, las columnas se descuadran:

<table class="w-full rounded-xl bg-white text-left text-sm">
  <thead class="bg-slate-100 text-slate-600">
    <tr>
      <th class="px-3 py-2">Pedido</th>
      <th class="px-3 py-2">Cliente</th>
      <th class="px-3 py-2 max-md:hidden">Ciudad</th>
      <th class="px-3 py-2 max-lg:hidden">Fecha</th>
      <th class="px-3 py-2 text-right">Total</th>
    </tr>
  </thead>
  <tbody class="divide-y divide-slate-200">
    <tr>
      <td class="px-3 py-2">5001</td>
      <td class="px-3 py-2">Talleres Martín S.L.</td>
      <td class="px-3 py-2 max-md:hidden">Valencia</td>
      <td class="px-3 py-2 max-lg:hidden">15/01/2025</td>
      <td class="px-3 py-2 text-right whitespace-nowrap">1816,80 €</td>
    </tr>
    <!-- ...el resto, con la misma estructura... -->
  </tbody>
</table>

Tres anchuras: a 375 px hay tres columnas, a 800 cuatro y a 1100 las cinco

Figura 21. Fecha desaparece por debajo de lg y Ciudad por debajo de md.

La columna oculta deja de estar disponible para todo el mundo, también con lector de pantalla. Úsalo solo con datos que se puedan consultar en otra pantalla, como el detalle del pedido.

Estrategia 3: de filas a tarjetas

La tabla se convierte en una pila de tarjetas por debajo de md, y cada dato lleva su rótulo. Aquí max-md: es la herramienta adecuada: en escritorio la tabla queda intacta, y solo se transforma en móvil.

<table role="table" class="w-full text-left text-sm max-md:block">
  <thead role="rowgroup" class="bg-slate-100 text-slate-600 max-md:sr-only">
    <tr role="row">
      <th role="columnheader" class="px-3 py-2">Pedido</th>
      <th role="columnheader" class="px-3 py-2">Cliente</th>
      <th role="columnheader" class="px-3 py-2">Fecha</th>
      <th role="columnheader" class="px-3 py-2 text-right">Total</th>
    </tr>
  </thead>
  <tbody role="rowgroup" class="bg-white max-md:block max-md:space-y-3 max-md:bg-transparent md:divide-y md:divide-slate-200">
    <tr role="row" class="max-md:block max-md:rounded-xl max-md:border max-md:border-slate-200 max-md:bg-white max-md:p-3">
      <td role="cell" data-etiqueta="Pedido" class="px-3 py-2 max-md:flex max-md:justify-between max-md:p-1 max-md:before:font-semibold max-md:before:content-[attr(data-etiqueta)]">5001</td>
      <td role="cell" data-etiqueta="Cliente" class="px-3 py-2 max-md:flex max-md:justify-between max-md:p-1 max-md:before:font-semibold max-md:before:content-[attr(data-etiqueta)]">Talleres Martín S.L.</td>
      <td role="cell" data-etiqueta="Fecha" class="px-3 py-2 max-md:flex max-md:justify-between max-md:p-1 max-md:before:font-semibold max-md:before:content-[attr(data-etiqueta)]">15/01/2025</td>
      <td role="cell" data-etiqueta="Total" class="px-3 py-2 text-right max-md:flex max-md:justify-between max-md:p-1 max-md:before:font-semibold max-md:before:content-[attr(data-etiqueta)]">1816,80 €</td>
    </tr>
    <!-- ...el resto, con la misma estructura... -->
  </tbody>
</table>

A 375 px cada pedido es una tarjeta con rótulos y a 800 px es una tabla normal

Figura 22. El mismo HTML en las dos anchuras. Los rótulos salen del atributo data-etiqueta.

Las piezas:

  • max-md:block en table, tbody y tr, y max-md:flex en td: rompen la disposición de tabla.
  • max-md:sr-only en thead: oculta los títulos a la vista, pero no a un lector de pantalla.
  • max-md:before:content-[attr(data-etiqueta)]: el pseudoelemento ::before escribe el texto del atributo. Así el rótulo no se duplica en el HTML.
  • role="table", role="row", role="cell"…: al cambiar display, algunos navegadores dejan de exponer la tabla como tal a las tecnologías de asistencia. Los roles la declaran de forma explícita.

Tiene un coste: muchas clases repetidas por celda. En un proyecto real, esa fila la generará un bucle de tu plantilla o de tu componente, como verás en los módulos 16 y 18.

Estrategia Se pierde Trabajo Úsala si
Desplazamiento Visión completa de la fila Mínimo Hay que comparar datos entre filas
Ocultar columnas Datos secundarios Poco Hay columnas prescindibles
Tarjetas Comparación entre filas Mucho Cada fila se lee de forma independiente

12. Menús adaptables

Con JavaScript mínimo

El patrón de siempre: enlaces en fila en escritorio, botón que despliega en móvil. Tailwind no trae comportamiento, así que necesitas unas pocas líneas para abrir y cerrar:

<header class="border-b border-slate-200 bg-white">
  <div class="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-x-6 px-4 py-3">
    <a href="#" class="text-lg font-bold text-indigo-700">EMPRESA</a>

    <button id="boton-menu" aria-expanded="false" aria-controls="menu" class="rounded-lg border border-slate-300 px-3 py-1.5 md:hidden">
      <span aria-hidden="true">☰</span>
      <span class="sr-only">Menú</span>
    </button>

    <nav id="menu" aria-label="Principal" class="hidden w-full md:block md:w-auto">
      <ul class="mt-3 flex flex-col gap-1 md:mt-0 md:flex-row md:gap-6">
        <li><a href="#" class="block rounded px-2 py-2 hover:bg-slate-100">Catálogo</a></li>
        <li><a href="#" class="block rounded px-2 py-2 hover:bg-slate-100">Ofertas</a></li>
        <li><a href="#" class="block rounded px-2 py-2 hover:bg-slate-100">Pedidos</a></li>
        <li><a href="#" class="block rounded px-2 py-2 hover:bg-slate-100">Contacto</a></li>
      </ul>
    </nav>
  </div>
</header>

<script>
  const boton = document.querySelector('#boton-menu');
  const menu = document.querySelector('#menu');

  boton.addEventListener('click', () => {
    const oculto = menu.classList.toggle('hidden');
    boton.setAttribute('aria-expanded', String(!oculto));
  });
</script>

Cabecera a 375 px con el logotipo y el botón de menú

Figura 23. Móvil, menú cerrado.

Cabecera a 375 px con los cuatro enlaces desplegados bajo el logotipo

Figura 24. Móvil, tras pulsar el botón.

Cabecera a 900 px con los enlaces en fila y sin botón

Figura 25. Escritorio: el botón desaparece y el menú queda siempre visible.

Cómo funciona:

  • nav empieza con hidden, y md:block lo recupera desde md, abierto o no.
  • El JavaScript solo quita o pone hidden. Como md:block está dentro de una consulta y va después en el CSS, ese hidden no afecta al escritorio.
  • flex-wrap en la cabecera y w-full en nav hacen que el menú baje a su propia línea en móvil.
  • aria-expanded indica a los lectores de pantalla si el menú está abierto; hay que actualizarlo a mano. Tras el clic, comprobado, vale "true".

⚠️ Importante: Compara con la forma ingenua: class="flex hidden md:flex". Ahí flex y hidden compiten sin prefijo, y gana el que Tailwind haya escrito después en su CSS, no el último de tu atributo. Quita hidden y deja que display lo decida un solo prefijo, como en el ejemplo.

Sin JavaScript: popover

El atributo HTML popover da gratis la apertura, el cierre con Escape y el cierre al pulsar fuera. La idea es que el navegador oculta el elemento con display: none en sus estilos por defecto, y md:flex, que es CSS de autor, gana a esos estilos. Así, por debajo de md es un panel desplegable, y por encima una barra normal:

<header class="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-3">
  <a href="#" class="text-lg font-bold text-indigo-700">EMPRESA</a>

  <button popovertarget="menu" class="rounded-lg border border-slate-300 px-3 py-1.5 md:hidden">
    <span aria-hidden="true">☰</span>
    <span class="sr-only">Menú</span>
  </button>

  <nav id="menu" popover aria-label="Principal"
       class="h-dvh w-64 bg-white p-4 shadow-xl max-md:ms-auto
              md:static md:flex md:h-auto md:w-auto md:gap-6 md:bg-transparent md:p-0 md:shadow-none">
    <a href="#" class="block py-2">Catálogo</a>
    <a href="#" class="block py-2">Ofertas</a>
    <a href="#" class="block py-2">Pedidos</a>
    <a href="#" class="block py-2">Contacto</a>
  </nav>
</header>

Panel lateral blanco abierto a la derecha sobre la página

Figura 26. En móvil, un panel lateral sobre la capa superior de la página.

Enlaces en fila en la cabecera a 900 px

Figura 27. Desde md, el mismo nav es una fila normal.

Medido en Chromium a 375 px: cerrado es display: none, abierto es un panel fixed de 256 px de ancho en el borde derecho, y se cierra con Escape y al pulsar fuera. A 900 px sin abrirlo es display: flex y position: static.

Dos detalles de este truco:

  • Por defecto, un popover se centra con margin: auto, pero Preflight pone margin: 0 a todo, y queda arriba a la izquierda. max-md:ms-auto lo lleva a la derecha.
  • Hay que deshacer en md: todo lo que define al panel: md:static, md:h-auto, md:w-auto, md:p-0, md:shadow-none.

⚠️ Importante: popover llegó a Chrome 114, Firefox 125 y Safari 17, según los datos de MDN. Es posterior al Safari 16.4 que exige v4. En un navegador sin soporte, el atributo se ignora y el nav se muestra siempre en móvil, sin poder cerrarse. Si necesitas esos navegadores, usa la versión con JavaScript. Verás popover y <dialog> con más detalle en el módulo 17.


13. Imágenes responsive

Preflight ya pone max-width: 100% y height: auto en las imágenes, de modo que nunca desbordan. Lo que no resuelve es el peso: una imagen de 2000 px que se ve a 375 px gasta datos sin motivo. Eso se arregla en HTML, y Tailwind colabora con las clases de tamaño:

<picture>
  <source media="(width >= 48rem)" srcset="/banner-ancho.svg">
  <img src="/banner-cuadrado.svg" alt="Rebajas de otoño en portátiles"
       class="aspect-square w-full rounded-xl object-cover md:aspect-21/9">
</picture>

<img src="/foto-400.svg" alt="Monitor 27 4K" class="mt-4 w-full rounded-xl sm:w-1/2 lg:w-1/3"
     srcset="/foto-400.svg 400w, /foto-800.svg 800w, /foto-1200.svg 1200w"
     sizes="(width >= 64rem) 33vw, (width >= 40rem) 50vw, 100vw">

A 375 px, banner cuadrado y foto de ancho completo; a 800 px, banner 21:9 y foto a la mitad

Figura 28. <picture> cambia de imagen entera. srcset deja que el navegador elija el tamaño.

Son dos herramientas para dos problemas distintos:

  • <picture> con media cambia el encuadre: una imagen cuadrada en móvil y otra apaisada en escritorio. El navegador obedece: a 375 px carga banner-cuadrado.svg, y a 800 px, banner-ancho.svg.
  • srcset con sizes cambia el tamaño del archivo de la misma imagen. El navegador elige según sizes y la densidad de la pantalla.

sizes es una promesa que le haces al navegador sobre lo que medirá la imagen, porque la elige antes de leer tu CSS. Por eso debe coincidir con tus clases:

Clase Condición en sizes Valor
lg:w-1/3 (width >= 64rem) 33vw
sm:w-1/2 (width >= 40rem) 50vw
w-full (por defecto) 100vw

Si cambias las clases y no cambias sizes, nada falla a la vista, pero se descargará un archivo que no corresponde. A pantalla de densidad 1, en las tres anchuras que he probado (375, 800 y 1200 px), el navegador ha escogido foto-400.svg, porque los tamaños previstos son 375, 400 y 396 px.

La proporción puede cambiar con la anchura: aspect-square md:aspect-21/9. La forma aspect-21/9 es de v4; en v3, escribe aspect-[21/9], que funciona en las dos. object-cover evita que la imagen se deforme, y lo viste en el módulo 07.


14. Caso completo: panel de control

Todo junto, en una pantalla del panel de EMPRESA. La decisión importante es quién manda en cada parte:

Parte Consulta Por qué
Barra de navegación: inferior o lateral Medios (lg:) Es la estructura de la página
Mosaicos: 2 o 4 columnas Contenedor (@2xl/panel:) Dependen del espacio que deja la barra
Pedidos y stock: apilados o en fila Contenedor (@3xl/panel:) Igual
Columna “Ciudad” de la tabla Contenedor (@max-lg/pedidos:) Depende de la tarjeta, no de la ventana
<div class="min-h-dvh lg:grid lg:grid-cols-[14rem_1fr]">
  <!-- Barra inferior en móvil; columna lateral desde lg -->
  <nav aria-label="Panel" class="fixed inset-x-0 bottom-0 z-10 flex justify-around border-t border-slate-200 bg-white lg:sticky lg:top-0 lg:h-dvh lg:flex-col lg:justify-start lg:gap-1 lg:border-t-0 lg:border-r lg:p-3">
    <p class="hidden px-3 py-2 text-lg font-bold text-indigo-700 lg:block">EMPRESA</p>
    <a href="#" aria-current="page" class="flex flex-col items-center gap-0.5 px-3 py-2 text-xs font-semibold text-indigo-700 lg:flex-row lg:gap-3 lg:rounded-lg lg:bg-indigo-50 lg:text-sm">
      <span aria-hidden="true">▦</span> Resumen
    </a>
    <a href="#" class="flex flex-col items-center gap-0.5 px-3 py-2 text-xs text-slate-600 lg:flex-row lg:gap-3 lg:text-sm">
      <span aria-hidden="true">▤</span> Pedidos
    </a>
    <a href="#" class="flex flex-col items-center gap-0.5 px-3 py-2 text-xs text-slate-600 lg:flex-row lg:gap-3 lg:text-sm">
      <span aria-hidden="true">▣</span> Productos
    </a>
    <a href="#" class="flex flex-col items-center gap-0.5 px-3 py-2 text-xs text-slate-600 lg:flex-row lg:gap-3 lg:text-sm">
      <span aria-hidden="true">◉</span> Clientes
    </a>
  </nav>

  <!-- pb-20 reserva el hueco de la barra fija en móvil -->
  <main class="@container/panel min-w-0 space-y-4 p-4 pb-20 lg:p-6">
    <h1 class="text-xl font-bold lg:text-2xl">Resumen de ventas</h1>

    <div class="grid grid-cols-2 gap-3 @2xl/panel:grid-cols-4">
      <div class="rounded-xl bg-white p-3 shadow-sm"><p class="text-xs text-slate-600">Pedidos</p><p class="text-2xl font-bold">24</p></div>
      <div class="rounded-xl bg-white p-3 shadow-sm"><p class="text-xs text-slate-600">Pendientes</p><p class="text-2xl font-bold">3</p></div>
      <div class="rounded-xl bg-white p-3 shadow-sm"><p class="text-xs text-slate-600">Clientes</p><p class="text-2xl font-bold">15</p></div>
      <div class="rounded-xl bg-white p-3 shadow-sm"><p class="text-xs text-slate-600">Sin stock</p><p class="text-2xl font-bold text-red-600">1</p></div>
    </div>

    <div class="grid gap-4 @3xl/panel:grid-cols-[2fr_1fr]">
      <section class="@container/pedidos rounded-xl bg-white p-3 shadow-sm">
        <h2 class="mb-2 font-semibold">Últimos pedidos</h2>
        <table class="w-full text-left text-sm">
          <thead class="text-slate-600">
            <tr>
              <th class="py-1">Pedido</th>
              <th class="py-1">Cliente</th>
              <th class="py-1 @max-lg/pedidos:hidden">Ciudad</th>
              <th class="py-1 text-right">Total</th>
            </tr>
          </thead>
          <tbody class="divide-y divide-slate-200">
            <tr><td class="py-1.5">5024</td><td class="py-1.5">Gestoría Delta</td><td class="py-1.5 @max-lg/pedidos:hidden">Zaragoza</td><td class="py-1.5 text-right">1497,00 €</td></tr>
            <tr><td class="py-1.5">5023</td><td class="py-1.5">Laura Benítez Sanz</td><td class="py-1.5 @max-lg/pedidos:hidden">Bilbao</td><td class="py-1.5 text-right">418,80 €</td></tr>
            <tr><td class="py-1.5">5022</td><td class="py-1.5">Diseños Creativos S.L.</td><td class="py-1.5 @max-lg/pedidos:hidden">Barcelona</td><td class="py-1.5 text-right">1499,00 €</td></tr>
          </tbody>
        </table>
      </section>

      <section class="rounded-xl bg-white p-3 shadow-sm">
        <h2 class="mb-2 font-semibold">Stock bajo</h2>
        <ul class="space-y-2 text-sm">
          <li class="flex justify-between"><span>Altavoz Inteligente</span><span class="font-semibold text-red-600">0</span></li>
          <li class="flex justify-between"><span>Torre Workstation</span><span class="font-semibold">4</span></li>
          <li class="flex justify-between"><span>Smartphone Gama Alta</span><span class="font-semibold">8</span></li>
        </ul>
      </section>
    </div>
  </main>
</div>

Panel a 375 px con barra inferior y a 1100 px con barra lateral, cuatro mosaicos y dos columnas

Figura 29. Móvil y escritorio. El HTML es el mismo; solo cambia quién ocupa qué.

Algunos detalles que se aprecian en el código:

  • fixed inset-x-0 bottom-0 en móvil se convierte en lg:sticky lg:top-0 lg:h-dvh. Son cambios de posición normales, con prefijo.
  • pb-20 en main reserva el hueco de la barra fija. Sin él, la última fila quedaría tapada.
  • min-w-0 en main es imprescindible: como hijo de rejilla, su mínimo sería el contenido, y una tabla ancha estiraría la columna. Es el mismo problema que min-w-0 en flexbox, visto en el módulo 08.
  • Los dos contenedores tienen nombre, porque están anidados: @container/panel y @container/pedidos.

Si quitaras la barra lateral, o abrieras este contenido en un panel más estrecho, los mosaicos se adaptarían solos. Con lg:grid-cols-4 no ocurriría: la ventana seguiría siendo igual de ancha.

En v3, cambia las dos clases @max-lg/pedidos:hidden por hidden @lg/pedidos:table-cell, y el resto funciona igual con el plugin.


15. Depurar un diseño responsive

Cuando algo no se adapta como esperabas, sigue este orden:

  1. Abre el modo dispositivo de las herramientas del navegador (Ctrl+Mayús+M en Chrome o Firefox) y arrastra el borde. Estrechar la ventana tiene un mínimo; este modo no.
  2. Inspecciona el elemento y mira en “Estilos” qué @media o @container se aplica. Si la regla no aparece, el CSS no se ha generado; si aparece tachada, ha perdido contra otra.
  3. Prueba en 320 px. Es la anchura en la que sale casi todo lo que desborda.
  4. Busca el culpable de un desbordamiento horizontal con este snippet en la consola, que marca lo que sobresale:
document.querySelectorAll('*').forEach((e) => {
  if (e.getBoundingClientRect().right > document.documentElement.clientWidth) {
    e.style.outline = '2px solid red';
  }
});
Síntoma Causa probable Solución
Se ve bien en escritorio, diminuto en el móvil real Falta <meta viewport> Sección 2
sm: no afecta al móvil Se lee sm: como “pequeño” Pon el estilo sin prefijo
3xl: o min-[900px]: no hacen nada, aunque se generan Valor en px con los demás en rem Usa rem en todos
max-md: no se genera (v3) Hay screens en objeto o con otra unidad Sección 7
Un @md: no reacciona No hay @container en un antepasado, o está en el mismo elemento Sección 9
Elemento con @container de ancho 0 Su ancho dependía del contenido flex-1, w-full
@container-size de altura 0 Falta altura explícita h-*, fila de rejilla
La página entera se desplaza en horizontal Un elemento más ancho que la ventana Snippet anterior; min-w-0
La clase existe, pero no aparece en el CSS Nombre construido con plantillas (md:grid-cols-${n}) Escribe la clase completa; módulo 18
La letra grande rompe el diseño Puntos de ruptura en px o alturas fijas Usa rem y evita h-* en texto

💡 Consejo: No diseñes para modelos concretos de teléfono. Estrecha poco a poco la ventana y, donde el diseño empiece a verse mal, ahí va tu punto de ruptura.


16. Ejercicios

Ejercicio 1: De desktop first a mobile first

Reescribe esta ficha de producto con utilidades de Tailwind, empezando por el móvil. El CSS original está pensado para escritorio, y el @media deshace lo anterior:

.ficha { display: flex; gap: 2rem; padding: 2rem; }
.ficha .imagen, .ficha .texto { width: 50%; }
.ficha h1 { font-size: 1.875rem; }

@media (max-width: 767px) {
  .ficha { flex-direction: column; gap: 1rem; padding: 1rem; }
  .ficha .imagen, .ficha .texto { width: auto; }
  .ficha h1 { font-size: 1.25rem; }
}
Ver solución

Lo que estaba en el @media pasa a ser la base, sin prefijo, y lo que estaba fuera pasa a md:. Desaparece la necesidad de “deshacer”: width: auto ya no se escribe.

<article class="flex flex-col gap-4 rounded-xl border border-slate-200 bg-white p-4 md:flex-row md:gap-8 md:p-8">
  <div class="grid aspect-video place-items-center rounded-lg bg-indigo-100 text-3xl font-bold text-indigo-700 md:w-1/2">UB</div>
  <div class="md:w-1/2">
    <h1 class="text-xl font-bold md:text-3xl">Portátil UltraBook 14</h1>
    <p class="mt-2 text-slate-600">Portátil ligero de 14 pulgadas, 16 GB de RAM.</p>
    <p class="mt-4 text-2xl font-bold text-indigo-700">899 €</p>
  </div>
</article>

Ficha a 375 px en columna y a 800 px en dos mitades

Figura 30. La solución a 375 y 800 px.

El @media original corta en 767 px y md: en 768: son el mismo punto, visto desde lados opuestos.

Ejercicio 2: ¿De qué color soy?

Sin ejecutarlo, di de qué color es la caja a 300, 500, 700, 900 y 1100 px. Después compruébalo.

<div id="prueba" class="caja bg-red-500 sm:bg-green-500 max-md:bg-yellow-500 lg:bg-blue-500">¿De qué color soy?</div>
Ver solución
Ventana Se cumplen Resultado real
300 px base, max-md Amarillo
500 px base, max-md Amarillo
700 px base, sm, max-md Verde
900 px base, sm Verde
1100 px base, sm, lg Azul

El caso interesante es 700 px. Muchos dicen amarillo, por ser la “más específica”. Pero las max-* van antes en el CSS que las sm, md…, así que sm: gana. El orden en el atributo class no interviene. Medido con getComputedStyle en Chromium. En v3, las max-* también salen antes, y el resultado es el mismo.

Ejercicio 3: Aviso solo en tabletas

Escribe un aviso con fondo ámbar que solo se vea entre md y lg. ¿Qué cambia en v3?

Ver solución
<p class="hidden rounded-lg bg-amber-100 p-3 text-amber-900 md:max-lg:block">Consejo: gira la tableta para ver la tabla completa.</p>

hidden es la base y md:max-lg:block lo recupera en la franja. Comprobado: display vale none a 700 px, block a 900 y none a 1100.

En v3 las clases son las mismas: md:max-lg:block genera @media (min-width: 768px) anidando @media not all and (min-width: 1024px).

Ejercicio 4: Tu punto de ruptura

La cabecera de la tienda necesita mostrar los enlaces desde 832 px. Crea un punto de ruptura tablet y úsalo, en v4 y en v3.

Ver solución

832 / 16 = 52 rem:

@theme {
  --breakpoint-tablet: 52rem;
}
<nav class="hidden gap-4 tablet:flex"><a href="#">Catálogo</a><a href="#">Ofertas</a></nav>
<p class="tablet:hidden">Menú compacto</p>
// v3: tailwind.config.js
module.exports = {
  theme: { extend: { screens: { tablet: '832px' } } },
};

Comprobado en v4: a 800 px nav vale none, y a 840 px, flex. En v3 se escribe en píxeles para no mezclar unidades, porque si no max-* dejarían de generarse.

Ejercicio 5: Resumen de pedido adaptable

Crea una tarjeta de pedido con este comportamiento según su propio contenedor: en columna por defecto; en fila, con el precio a la derecha, desde @sm; sin la línea de cliente por debajo de @xs; y con el precio mayor desde @lg.

Ver solución
<div class="@container rounded-xl bg-slate-200 p-3">
  <div class="flex flex-col gap-2 rounded-xl bg-white p-3 @sm:flex-row @sm:items-center @sm:justify-between">
    <div>
      <p class="font-semibold">Pedido 5024</p>
      <p class="text-sm text-slate-600 @max-xs:hidden">Gestoría Delta, Zaragoza</p>
    </div>
    <p class="font-bold text-indigo-700 @lg:text-2xl">1497,00 €</p>
  </div>
</div>

A 300 px en columna sin la línea de cliente; a 600 px en fila con precio grande

Figura 31. El contenedor de la izquierda mide menos de @xs y se oculta la línea de cliente.

Ventana Dirección Línea de cliente Precio
300 px Columna Oculta 16 px
420 px Columna Visible 16 px
600 px Fila Visible 24 px

Observa el envoltorio: @container va en el div exterior, y las clases @sm: en el interior. En v3 con plugin, @max-xs:hidden no existe: escribe hidden @xs:block en la línea de cliente.

Ejercicio 6: Dos contenedores anidados

Dentro de una página con contenedor pagina, una tarjeta con contenedor tarjeta debe poner nombre y precio en fila cuando la tarjeta mida @md, y agrandar el precio cuando la página mida @3xl.

Ver solución
<section class="@container/pagina">
  <article class="@container/tarjeta rounded-xl bg-white p-3">
    <div class="flex flex-col @md/tarjeta:flex-row @md/tarjeta:justify-between">
      <h3 class="font-semibold">Teclado mecánico</h3>
      <p class="font-bold text-indigo-700 @3xl/pagina:text-2xl">79,90 €</p>
    </div>
  </article>
</section>
Ventana Dirección Precio
400 px Columna 16 px
700 px Fila 16 px
900 px Fila 24 px

A 700 px la página mide 668 px, menos que @3xl (768 px): la fila ya está activa, pero el precio no. Son dos preguntas distintas a dos elementos distintos. Sin nombres, las dos irían a la tarjeta, que es el contenedor más cercano.

Ejercicio 7: Encuentra el error

Cada fragmento tiene un fallo. Di cuál es y corrígelo.

<!-- A: se quiere texto pequeño en móvil -->
<p class="sm:text-sm text-lg">Oferta</p>

<!-- B: se quiere pasar a fila según el contenedor -->
<div class="@container flex flex-col @lg:flex-row">...</div>

<!-- C: los n vienen de JavaScript -->
<div class="grid md:grid-cols-${n}">...</div>

<!-- D: tarjeta en una fila flexible -->
<div class="flex"><div class="@container"><p>Hola</p></div></div>
Ver solución
  • A. sm: es “desde 40 rem”, y aquí pone el texto pequeño en pantallas grandes. Correcto: text-sm sm:text-lg.
  • B. El elemento se consulta a sí mismo. Mueve las clases flex a un hijo: <div class="@container"><div class="flex flex-col @lg:flex-row">...</div></div>.
  • C. Tailwind escanea los archivos como texto y nunca ve md:grid-cols-3. Escribe las clases completas en una tabla de correspondencias, o usa grid-cols-[repeat(var(--n),1fr)] con la variable en style. Tienes los detalles en el módulo 18.
  • D. Hijo de flex con @container: su ancho dependía del contenido, de modo que mide 0. Añade flex-1.

Ejercicio 8: Tabla que se adapta a su sitio

Toma la tabla de la estrategia 2 y haz que oculte columnas según el ancho de su contenedor, no de la ventana, para poder ponerla en una barra lateral o en una zona ancha. Escribe también la versión para v3.

Ver solución

Envuelve la tabla en un elemento con @container y sustituye max-md:hidden y max-lg:hidden:

<div class="@container">
  <table class="w-full text-left text-sm">
    <thead>
      <tr>
        <th>Pedido</th>
        <th>Cliente</th>
        <th class="@max-lg:hidden">Ciudad</th>
        <th class="@max-3xl:hidden">Fecha</th>
        <th class="text-right">Total</th>
      </tr>
    </thead>
    <!-- en cada fila, las mismas clases en esas celdas -->
  </table>
</div>

No se pueden reutilizar los mismos nombres: @md son 28 rem, no 48. Por eso max-md pasa a @max-lg (32 rem) y max-lg a @max-3xl (48 rem).

Comprobado a 400, 600 y 900 px: se ocultan las dos columnas, solo Fecha, y ninguna, respectivamente.

@container debe ir en el div, no en la propia table. Lo he probado: con class="@container" en la tabla, a 400 px @max-lg:hidden no oculta la celda, aunque la tabla mide 368 px. La propiedad se aplica, pero la consulta no responde.

Para v3, invierte la lógica, porque el plugin no tiene @max-*:

<th class="hidden @lg:table-cell">Ciudad</th>
<th class="hidden @3xl:table-cell">Fecha</th>

Es el mismo método que usa el panel de la sección 14.

Ejercicio 9 (propuesto): Mosaico de indicadores

Crea un panel de 3 x 2 mosaicos con @container-size, de tamaños distintos gracias a col-span-* y row-span-*. El número debe ajustarse con cqmin y cada mosaico debe mostrar un gráfico de barras CSS solo si mide más de 14 rem de alto. Añade el equivalente para v3 con [container-type:size].

Ejercicio 10 (propuesto): Portada de la tienda

Monta la portada completa de la tienda EMPRESA combinando lo de este módulo: cabecera con menú popover, portada de la sección 3, catálogo con @container y pie en columnas. Compruébala a 320, 375, 768, 1024 y 1440 px con el indicador de la sección 1, y sin desbordamiento horizontal.

Ejercicio 11 (propuesto): Elimina prefijos

Toma el catálogo de la sección 3 y consigue un resultado parecido sin ningún prefijo, usando auto-fill. Explica qué ganas y qué pierdes: ¿puedes garantizar un número exacto de columnas en una anchura dada?


Resumen

Concepto API Idea clave
Ventana real en móvil <meta name="viewport"> Sin ella, el móvil simula 980 px
Mobile first Sin prefijo, luego sm:, md:… El prefijo significa “desde”, nunca “solo en”
Puntos de ruptura sm 40, md 48, lg 64, xl 80, 2xl 96 rem En v3, en píxeles
Quién gana Orden en el CSS El orden del atributo class no influye
Mostrar y ocultar hidden md:flex, md:hidden, sr-only md:not-sr-only Indica qué display recuperas
Hacia abajo max-md: Para excepciones; va antes en el CSS
Franjas md:max-lg: Sin hueco ni solape en las fronteras
Valores puntuales min-[...], max-[...] En v4, en rem; max-[...] incluye el valor en v3 y no en v4
Altura y orientación [@media(height>=40rem)]:, portrait:, landscape:, print: Sin variantes de altura con nombre
Personalizar 🔄 v4.0 --breakpoint-* en @theme; screens en v3 No mezcles unidades
Puntos en tu CSS 🔄 v4.0 @variant md, theme(--breakpoint-md) var() no vale en una @media
Letra del usuario Puntos en rem Con letra grande se pasa antes a menos columnas
Diseño fluido clamp(), auto-fill, flex-wrap Antes de añadir otro prefijo
Contenedor 🆕 v4.0 @container, @md: Plugin en v3; @md son 28 rem, no 48
Antepasado obligatorio @container en un envoltorio Un elemento no se consulta a sí mismo
Contenedor con ancho 0 flex-1, w-full Su tamaño no puede depender del contenido
Con nombre @container/x, @md/x: Imprescindible si se anidan
Hasta y rangos 🆕 v4.0 @max-md:, @sm:@max-md: En v3, invierte la lógica
Tamaños propios 🔄 v4.0 --container-*; containers en v3 Sirven también para max-w-*
Unidades cqi, cqw, cqh, cqmin Como vw, relativas al contenedor
Altura 🆕 v4.3 @container-size Exige altura explícita; en v3, [container-type:size]
Tablas overflow-x-auto, max-md:hidden, max-md:block Desplazar, ocultar o convertir en tarjetas
Menús hidden md:block + JS, o popover + md:flex popover es posterior a Safari 16.4
Imágenes srcset, sizes, <picture> sizes debe coincidir con tus clases