9 de diciembre de 20255 min de lectura

Container queries y :has() en Tailwind 4

Compartir

Hace unos meses escribí sobre por qué deberías adoptar mobile first. Sigo pensando lo mismo: diseñar primero para la pantalla pequeña te obliga a decidir qué es importante.

Lo que ha cambiado es la herramienta. Aquel artículo daba por hecho que adaptarse era cosa de @media, y desde entonces los navegadores permiten hacer una pregunta bastante más útil.

El problema: la pantalla no es el contenedor

Una media query pregunta “¿cuánto mide la ventana?”. Pero tu tarjeta de artículo no vive en la ventana: vive dentro de una columna, que a veces es el contenido principal y a veces es una barra lateral de 300px.

Con @media, esa tarjeta no tiene forma de saberlo. En una pantalla de escritorio la media query dice “grande”, así que la tarjeta se pone en horizontal, también dentro de la barra lateral estrecha, donde se ve fatal.

La respuesta clásica era pasar una prop: <Card variant="compact" />. Funciona, pero obliga a que cada sitio donde uses el componente se acuerde de decirle cómo verse. Y cuantas más variantes hay, más fácil es que dejen de estar sincronizadas.

Una container query cambia la pregunta: “¿cuánto mide el espacio que me han dado?”. El componente se adapta solo, lo pongas donde lo pongas.

Lo mínimo que hay que saber

Son dos piezas. Primero, marcar un elemento como contenedor consultable:

.columna {
  container-type: inline-size;
}

Y después, preguntar por su ancho:

@container (min-width: 30rem) {
  .tarjeta { flex-direction: row; }
}

inline-size significa “consulta solo el ancho”. Es lo que vas a querer casi siempre; más abajo explico por qué el otro valor puede romperte el layout.

En Tailwind 4 esto ya viene incluido, sin plugin. La clase @container marca el contenedor y las variantes @sm:, @md:, @lg: consultan su ancho:

<div class="@container">
  <article class="flex flex-col @md:flex-row @md:items-center gap-4">
    <img class="w-full @md:w-2/6 rounded-2xl" src="/portada.webp" alt="" />
    <h2 class="text-xl @md:text-2xl">Un título que se adapta a su columna</h2>
  </article>
</div>

Ese componente ya no necesita saber dónde está. Con el mismo markup, en la barra lateral se pone en vertical y en el contenido principal, en horizontal.

Cuidado: @md no es md

Es el error más fácil de cometer. Los breakpoints de contenedor y los de la pantalla son escalas distintas, aunque se llamen igual. Salen de dos grupos de variables separados en Tailwind:

Variante Consulta Valor
sm: viewport 40rem
md: viewport 48rem
lg: viewport 64rem
@sm: contenedor 24rem
@md: contenedor 28rem
@lg: contenedor 32rem

Es decir, @md: cambia a partir de 28rem, no de 48rem. Si traduces tus breakpoints uno a uno, todo va a cambiar mucho antes de lo que esperas y vas a pensar que las container queries no funcionan.

Y tiene sentido: un contenedor casi nunca es tan ancho como la pantalla, así que su escala útil es más pequeña. Cuando necesites un valor concreto, es más claro usar la sintaxis arbitraria:

<div class="@min-[30rem]:grid-cols-2"></div>

Contenedores con nombre

En cuanto anidas contenedores aparece una ambigüedad: una container query consulta el contenedor más cercano, y a veces no es el que quieres. Para eso se les pone nombre:

<aside class="@container/panel">
  <div class="@container/tarjeta">
    <!-- consulta el panel, saltándose la tarjeta -->
    <p class="@lg/panel:text-lg">…</p>
  </div>
</aside>

Tailwind lo compila a container: panel/inline-size y a @container panel (min-width: 32rem). Mi consejo es ponerles nombre en cuanto haya dos contenedores anidados. Depurar una query que consulta al contenedor equivocado es bastante molesto, porque todo parece correcto.

Lo que implica container-type

Esto explica muchos casos de “se me rompió el layout y no sé por qué”.

Declarar container-type: inline-size aplica contención: le dices al navegador que el ancho de ese elemento se puede calcular sin mirar lo que tiene dentro. Es lo que hace posible la consulta, pero también le quita algo que quizá estabas usando: el elemento ya no puede encogerse para ajustarse a su contenido.

En la práctica, un div con container-type: inline-size y width: fit-content se comporta distinto de como esperas. Por eso:

  • Pon el contenedor en el envoltorio de layout (la columna, la celda del grid), no en el componente que quieres adaptar.
  • Nunca uses container-type: size a menos que sepas exactamente lo que haces: consulta también la altura y exige contención vertical, lo que colapsa elementos sin altura explícita.

Resumido: el padre es el contenedor y el hijo es el que se adapta.

:has(), el selector de padre

:has() selecciona un elemento según lo que contiene. Durante años se pidió un selector así, y resuelve cosas que hasta ahora necesitaban JavaScript.

/* una tarjeta con imagen se maqueta distinto que una sin imagen */
.tarjeta:has(img) { padding-top: 0; }

En Tailwind es la variante has-*:

<article class="p-6 has-[img]:p-0 has-[img]:overflow-hidden">
  <img src="/portada.webp" alt="" />
</article>

Donde más se nota es en los formularios, porque permite reaccionar al estado sin JavaScript:

<label class="flex gap-3 rounded-xl border p-4 has-[:checked]:border-mint-500 has-[:checked]:bg-mint-50 has-[:focus-visible]:ring-2">
  <input type="radio" name="plan" />
  <span>Plan mensual</span>
</label>

Fíjate en has-[:focus-visible]:ring-2: el anillo de foco aparece en toda la tarjeta cuando el radio de dentro recibe el foco del teclado. Antes eso requería un listener de JavaScript; ahora es una clase, y además mejora la accesibilidad.

También existen las combinaciones con group y peer:

  • group-has-[img]:: el hijo cambia porque el grupo contiene una imagen.
  • peer-has-checked:: un elemento cambia porque su hermano contiene algo marcado.

Las dos juntas

Las dos ideas se complementan bien. La container query resuelve cuánto espacio tengo; :has() resuelve qué contengo:

<div class="@container">
  <article
    class="flex flex-col gap-4 rounded-2xl p-6
           @md:flex-row @md:items-center
           has-[img]:p-0 has-[img]:@md:pr-6"
  >
    <img class="w-full rounded-2xl @md:w-2/6" src="/portada.webp" alt="" />
    <h2 class="text-xl @md:text-2xl">Se adapta a su columna y a su contenido</h2>
  </article>
</div>

Ese componente no recibe ninguna prop de presentación y funciona igual en la portada, en la barra lateral y en la página de una etiqueta. Antes eso se resolvía con tres variantes.

¿Entonces las media queries sobran?

No. Quedan repartidas así:

  • Media queries para lo que depende del dispositivo: el número de columnas de la página, si la navegación es una barra o un menú hamburguesa, y todo lo que no es tamaño, como prefers-reduced-motion, prefers-color-scheme o print.
  • Container queries para los componentes reutilizables, que es donde suele estar la mayor parte del CSS adaptativo de un sitio y donde más se simplifica.

El enfoque mobile first sigue igual. Lo que cambia es quién decide: antes la página le decía a cada componente cómo verse, y ahora cada componente lo decide según el espacio que tiene.

Compartir