wandres.dev
EL SELECTOR DE PADRE · :has() y lo que desbloquea

Catálogo de patrones con :has()

Doce patrones completos y funcionales: contenedores que se adaptan a su contenido, consultas de cantidad, el hermano anterior, formularios que reaccionan y estado global sin una línea de JavaScript.

⏱ 25 min

La teoría de :has() cabe en dos párrafos; lo que cuesta adquirir es el catálogo. Hay una docena de patrones que aparecen una y otra vez en interfaces reales, y hasta 2022 cada uno de ellos exigía o bien una clase modificadora que la plantilla tenía que calcular, o bien un trozo de JavaScript vigilando el DOM. Aquí están todos, con el HTML y el CSS completos, listos para pegar y probar.

🎯 Al terminar esta lección sabrás
  • Escribir contenedores que cambian de layout según lo que contienen.
  • Construir consultas de cantidad exactas y por rango sin contar en la plantilla.
  • Resolver el hermano anterior y la vecindad inmediata en listas y acordeones.
  • Montar formularios y estado global reactivos sin JavaScript.

Contenedores que se adaptan a su contenido

El patrón fundacional. Una tarjeta que trae imagen necesita una rejilla distinta a una que no la trae, y hasta ahora eso se resolvía con una clase card--con-imagen que alguien tenía que poner desde la plantilla.

<article class="card">
  <img src="/foto.avif" alt="" width="640" height="360">
  <h3>Con imagen</h3>
  <p>El contenedor se reorganiza solo.</p>
</article>

<article class="card">
  <h3>Sin imagen</h3>
  <p>Y aqui el titulo sube al hueco que ha quedado libre.</p>
</article>
.card {
  display: grid;
  gap: 0.75rem;
  padding: 1rem;
  border: 1px solid;
  border-radius: 0.75rem;
  /* Por defecto: solo texto, contenido arriba. */
  grid-template-rows: auto auto 1fr;
  align-content: start;
}

.card:has(> img) {
  /* Con imagen: una banda fija arriba y el texto debajo. */
  grid-template-rows: 12rem auto 1fr;
  padding-block-start: 0;
  overflow: clip;
}

.card:has(> img) > img {
  /* Sangra la imagen hasta los bordes de la tarjeta. */
  margin-inline: -1rem;
  inline-size: calc(100% + 2rem);
  block-size: 100%;
  object-fit: cover;
}

La variante negativa es igual de útil y se lee mejor con :not() por fuera: el estado vacío. Una lista sin elementos no necesita una clase especial, necesita que la hoja de estilos se dé cuenta.

/* El mensaje de lista vacia vive en el HTML y se oculta si hay contenido. */
.lista-vacia { display: none; }
.panel:not(:has(li)) .lista-vacia { display: block; }
.panel:not(:has(li)) ul { display: none; }

Y la versión semántica pura, que no depende de ninguna clase porque HTML ya distingue los casos:

/* Una figura con pie necesita separar el pie; una sin pie, no. */
figure:has(figcaption) { padding-block-end: 0.5rem; }
figure:has(figcaption) img { border-end-start-radius: 0; border-end-end-radius: 0; }

/* Una tabla con caption reserva espacio arriba. */
table:has(caption) { margin-block-start: 2rem; }

/* Un article que contiene un formulario recibe mas aire alrededor. */
article:has(form) { padding-block: 2.5rem; }
💡
Empieza por el caso simple

Fíjate en el orden de las tres primeras reglas del ejemplo de la tarjeta: primero la versión sin imagen, sin :has(), y después el ajuste. Escribir el caso base sin condición y usar :has() solo para el desvío tiene dos ventajas: el navegador sin soporte se queda con algo razonable, y la regla condicional siempre gana en el orden de aparición sin necesidad de subir especificidad.

Consultas de cantidad

Contar hijos era territorio exclusivo de la plantilla. Con :has() y :nth-child() se hace en CSS, y es la base de las rejillas que se reorganizan según cuántos elementos hay.

/* Al menos seis: si existe el sexto hijo, hay seis o mas. */
.galeria:has(> :nth-child(6)) { --columnas: 3; }

/* Al menos tres pero menos de seis. */
.galeria:has(> :nth-child(3)):not(:has(> :nth-child(6))) { --columnas: 2; }

/* Exactamente uno: existe el primero y no existe el segundo. */
.galeria:has(> :only-child) { --columnas: 1; }

.galeria {
  --columnas: 1;
  display: grid;
  grid-template-columns: repeat(var(--columnas), 1fr);
  gap: 1rem;
}

La forma canónica de “exactamente N” combina existencia y ausencia:

/* Exactamente cuatro hijos. */
.grupo:has(> :nth-child(4)):not(:has(> :nth-child(5))) { }

/* Numero par de hijos: el ultimo esta en posicion par. */
.grupo:has(> :last-child:nth-child(even)) { }

Este último merece una lectura despacio: :last-child:nth-child(even) describe a un elemento que es simultáneamente el último y ocupa una posición par, lo cual solo puede pasar si el total es par. Es el tipo de razonamiento que hace que las consultas de cantidad parezcan difíciles la primera vez y triviales después.

Un uso menos obvio pero muy rentable: detectar desbordamiento lógico antes de que ocurra visualmente. Si una barra de herramientas tiene más de cinco botones, cambia a un menú desplegable en lugar de dejar que se rompa.

.barra { display: flex; gap: 0.5rem; }
.barra .desplegable { display: none; }

.barra:has(> button:nth-child(6)) > button:nth-child(n+5) { display: none; }
.barra:has(> button:nth-child(6)) .desplegable { display: block; }

El hermano anterior y la vecindad

Con + y ~ dentro del argumento, el sujeto se queda a la izquierda y obtienes relaciones que CSS no podía expresar.

/* El elemento que va justo ANTES del que tiene el puntero encima. */
.menu li:has(+ li:hover) { --sombra: 0 4px 12px rgb(0 0 0 / 0.15); }

/* Ocultar el separador que precede a un elemento oculto. */
.hr:has(+ [hidden]) { display: none; }

/* Un titulo seguido inmediatamente de una lista no necesita margen inferior. */
h2:has(+ ul, + ol) { margin-block-end: 0.25rem; }

/* Un parrafo que va justo despues de un titulo arranca sin sangria. */
h2 + p { text-indent: 0; }

El caso del acordeón es el que más se agradece, porque afecta a los bordes compartidos entre elementos adyacentes y no había forma de resolverlo sin JavaScript:

<div class="acordeon">
  <details name="faq"><summary>Primera</summary><p>Contenido.</p></details>
  <details name="faq"><summary>Segunda</summary><p>Contenido.</p></details>
  <details name="faq"><summary>Tercera</summary><p>Contenido.</p></details>
</div>
.acordeon details { border-block-end: 1px solid; }
.acordeon details:last-child { border-block-end: 0; }

/* El panel abierto se destaca... */
.acordeon details[open] { background: color-mix(in oklch, canvas 92%, currentColor); }

/* ...y el que va justo ANTES pierde su borde para que no se doble la linea. */
.acordeon details:has(+ details[open]) { border-block-end-color: transparent; }

El atributo name compartido en varios details los convierte en un grupo exclusivo: abrir uno cierra los demás, sin JavaScript. Es una adición reciente de HTML que combina especialmente bien con :has(), porque el estado exclusivo vive en el DOM y la hoja de estilos puede leerlo.

/* Marcar la lista entera mientras alguno de sus elementos esta desplegado. */
.acordeon:has(details[open]) { --intensidad-fondo: 0.04; }

Formularios y estado global

Aquí es donde :has() sustituye a JavaScript de verdad, no como demostración sino en producción.

Un campo que se revela al marcar una casilla. El patrón “Otro: especifique” de toda la vida.

<fieldset class="motivo">
  <label><input type="radio" name="motivo" value="error"> Un error</label>
  <label><input type="radio" name="motivo" value="otro" id="otro"> Otro</label>
  <div class="revelable">
    <label>Cuentanos cual
      <input type="text" name="detalle">
    </label>
  </div>
</fieldset>
/* La animacion de 0fr a 1fr es la forma portable de animar hacia auto. */
.motivo .revelable {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 200ms, opacity 200ms;
  opacity: 0;
}

.motivo .revelable > * {
  overflow: hidden;
  /* Evita que el control colapsado siga siendo enfocable. */
  visibility: hidden;
  transition: visibility 0s 200ms;
}

.motivo:has(#otro:checked) .revelable {
  grid-template-rows: 1fr;
  opacity: 1;
}

.motivo:has(#otro:checked) .revelable > * {
  visibility: visible;
  transition-delay: 0s;
}

Etiquetas que reaccionan al estado de su control. El label va antes que el input en el DOM, así que solo :has() puede alcanzarlo.

<div class="campo">
  <label for="correo">Correo</label>
  <input id="correo" type="email" required>
</div>
.campo:has(> input:required) > label::after {
  content: " *";
  color: color-mix(in oklch, red 70%, canvas);
}

.campo:has(> input:focus-visible) > label { font-weight: 600; }

.campo:has(> input:user-invalid) > label { color: color-mix(in oklch, red 80%, canvas); }
.campo:has(> input:user-invalid) > input { border-color: color-mix(in oklch, red 80%, canvas); }

.campo:has(> input:disabled) { opacity: 0.5; }

El formulario entero sabe si es enviable. Sin escuchar un solo evento.

form:has(:invalid) button[type="submit"] {
  opacity: 0.5;
  cursor: not-allowed;
}

form:not(:has(:invalid)) button[type="submit"] {
  background: color-mix(in oklch, currentColor 12%, canvas);
}

Estado global desde la raíz. El patrón de mayor rendimiento por línea escrita. Un diálogo abierto bloquea el desplazamiento del documento, y un menú desplegado atenúa el fondo.

html:has(dialog[open]) { overflow: hidden; }

html:has(dialog[open]) body { scrollbar-gutter: stable; }

body:has(.menu[aria-expanded="true"]) main {
  filter: brightness(0.7);
  transition: filter 150ms;
}

Tema conmutado por una casilla, para el caso en que quieras una preferencia local sin persistencia:

<input type="checkbox" id="oscuro" class="visualmente-oculto">
<label for="oscuro">Modo oscuro</label>
:root { color-scheme: light; }
html:has(#oscuro:checked) { color-scheme: dark; }

Fíjate en que la regla no cambia veinte colores: cambia color-scheme, y los colores del sistema —canvas, canvastext, field, y los formularios nativos— se reorganizan solos. :has() es el interruptor; el trabajo de verdad lo hacen los colores del sistema.

⚠️
Sin JavaScript no significa accesible por defecto

Varios de estos patrones sustituyen JavaScript por estado del DOM, pero el estado del DOM también tiene que ser correcto para la tecnología de asistencia. Un campo colapsado con overflow: hidden sigue siendo enfocable y sigue anunciándose si no le añades visibility: hidden, y por eso ese visibility del ejemplo anterior no es decorativo. Y si conmutas un menú con aria-expanded, ese atributo tiene que reflejar la realidad, porque en este patrón es a la vez la fuente del estilo y la información que lee el lector de pantalla.

La inversión de control que nadie anunció

Todos estos patrones comparten una propiedad que va más allá de ahorrarse JavaScript, y que es la razón por la que :has() cambia la arquitectura de un sistema de diseño. Antes, la información fluía en una sola dirección: la plantilla sabía qué iba a renderizar, calculaba las clases modificadoras —card--con-imagen, lista--vacia, barra--desbordada— y se las pasaba al CSS, que se limitaba a obedecer. Eso obligaba a que la capa que genera el HTML conociera las decisiones visuales, y por tanto a que cada nueva variante de estilo exigiera tocar la plantilla, el componente de React o el bucle de Astro que lo pinta. :has() invierte la dirección: ahora el contenedor pregunta por su contenido y decide él. La plantilla vuelve a hacer lo único que debería hacer —emitir el contenido con su marcado semántico— y toda la lógica de presentación se queda donde le corresponde. El efecto secundario más valioso aparece en los componentes con contenido arbitrario, esos que reciben una ranura y no saben qué les van a meter: un slot de Astro, un children de React, un bloque de un gestor de contenidos. Ese componente ya no necesita una API de props booleanas para adaptarse, porque puede mirar. Y el efecto secundario más peligroso es el que viste en la lección anterior: esa mirada crea un acoplamiento que ningún sistema de tipos comprueba. La regla que equilibra ambas cosas es tratar el argumento de :has() como una interfaz —semántica de HTML y atributos con significado, nunca clases de presentación—, porque lo que estás haciendo, aunque no lo parezca, es declarar un contrato entre el contenedor y todo lo que pueda caer dentro de él.

⚔️ Monta los doce
  1. Construye una tarjeta que además detecte si trae un video en lugar de un img y reserve otra proporción.
  2. Escribe la consulta de cantidad para “exactamente entre tres y cinco hijos”.
  3. Resuelve, con un solo selector, ocultar el separador que precede al último elemento visible de una lista con elementos ocultos.
  4. Haz que un fieldset completo se atenúe cuando todos sus controles están deshabilitados.
  5. Monta el bloqueo de desplazamiento con dialog y comprueba en el inspector que el recálculo solo ocurre al abrir y cerrar.