CSS moderno :has() en UI real: selector padre para formularios, tarjetas y filtros

¿Te fue útil?

Entregas una interfaz. Una semana después, alguien añade un ícono de ayuda dentro del wrapper del input, y de repente el borde rojo de “inválido” deja de mostrarse.
O un responsable de producto quiere una cuadrícula de tarjetas que resalte cualquier tarjeta que contenga una insignia de “Oferta” —sin montar diez observadores JS distintos.

El villano silencioso suele ser el mismo: seguimos pidiendo al CSS que estilice hacia arriba en el árbol DOM, y el CSS históricamente se negó. Ahora ya no.
:has() es el selector padre que hemos querido durante veinte años, y por fin es usable en producción—si lo tratas como un problema operativo, no como un truco de demo.

Qué es realmente :has() (y por qué es distinto)

:has() es una pseudo-clase relacional: hace match con un elemento si contiene algo que coincide con la lista de selectores que pongas dentro.
La traducción práctica es simple: por fin puedes estilizar un padre en función del estado de un hijo.

Ejemplo: resaltar el wrapper de un campo si contiene un input inválido.

cr0x@server:~$ cat ui.css
.field:has(input:invalid) {
  border: 2px solid #c1121f;
  background: #fff5f5;
}

Eso se lee como en inglés. Y esa es la principal razón por la que es peligroso: hace que cosas complejas parezcan fáciles.
:has() cambia cómo piensas sobre la estructura del DOM, los límites del componente y la propagación de estado.
Si lo usas bien, eliminas JS. Si lo usas de forma perezosa, creas tickets de “¿por qué se está repintando toda la página?”.

Qué no es

  • No reemplaza un buen HTML. Si tu DOM es un cajón desastre, :has() solo te ayuda a encontrar el desastre más rápido.
  • No es una excusa para dejar de usar clases. Tu yo futuro querrá hooks estables para pruebas y refactors.
  • No es mágico. Sigue siendo coincidencia de selectores, sigue afectado por invalidación y recálculo de estilos.

Un encuadre operativo que funciona: trata :has() como añadir una nueva arista de dependencia en tu grafo de estado.
Cuando un hijo cambia, el estilo del padre puede necesitar recálculo. Ese es todo el punto. También es el coste.

Una idea parafraseada frecuentemente atribuida a círculos de fiabilidad: Optimiza primero para la depurabilidad; afinar el rendimiento es más fácil que razonar sobre una caja negra.
Esa es la postura que debes adoptar con :has(). Úsalo para reducir el estado opaco en JS, pero mantén los selectores legibles y acotados.

Datos y contexto útiles para repetir en revisiones de diseño

Vas a tener que defender :has() en la revisión de código, y surgirán las preguntas clásicas: “¿Tiene soporte?” “¿Es lento?”
“¿Por qué no simplemente añadir una clase?” Aquí están los puntos útiles.

  1. CSS consiguió :has() vía Selectors Level 4. La petición del “selector padre” es antigua; tardó porque afecta al rendimiento y a la invalidación.
  2. jQuery tenía un selector :has() mucho antes que CSS. Eso explica en parte por qué los ingenieros asumían que CSS lo haría “igual”. No es así.
  3. Los navegadores se resistieron años porque es una “dependencia inversa”. Tradicionalmente, las dependencias de estilo fluían de padre a hijo; :has() puede invertir eso.
  4. Safari lo implementó temprano. Sorprendió a quienes piensan que Safari siempre va atrás. Al menos en esto no.
  5. Los engines de navegador tuvieron que mejorar el rastreo de invalidación. Saber eficientemente qué ancestros pueden coincidir cuando un nodo cambia es ingeniería real, no un encogimiento de hombros.
  6. :has() permite “estilado por estado” sin DOM extra. Antes, los equipos añadían wrappers solo por hooks de estilo; eso es básicamente deuda técnica en forma de HTML.
  7. Combina bien con pseudo-clases modernas de formularios. :user-invalid, :invalid, :required, :placeholder-shown y :focus-within son más útiles cuando puedes burbujear su efecto hacia arriba.
  8. Funciona bien con estado ARIA. Apuntar a [aria-expanded="true"] o [aria-invalid="true"] dentro de :has() mapea bien al estado accesible de la UI.

Broma nº1: La gente llama a :has() el “selector padre”, lo cual es exacto—como llamar a un centro de datos “una habitación con ordenadores”.

Formularios: validación, campos requeridos y wrappers de error que “simplemente funcionan”

Las UIs de formularios reales no son un único input y un botón enviar. Son wrappers, íconos, etiquetas, texto de ayuda, errores del servidor, restricciones del cliente,
y el momento en que alguien añade un toggle de “mostrar” dentro de un campo de contraseña.

El patrón estable es: estilizar el wrapper, no solo el input. Pero el wrapper necesita reaccionar al estado del input. Ahí es territorio de :has().

Estado inválido a nivel de wrapper

cr0x@server:~$ cat form.css
.field {
  border: 1px solid #ccd5e1;
  border-radius: 10px;
  padding: 10px;
  display: grid;
  gap: 6px;
}

.field:has(input:invalid) {
  border-color: #c1121f;
  background: #fff5f5;
}

.field:has(input:invalid) .hint {
  color: #c1121f;
}

.field:has(input:focus-visible) {
  box-shadow: 0 0 0 3px rgba(0, 120, 212, 0.2);
}

Esto evita la trampa común: estilizar solo el input cuando el área clicable real es el wrapper.
Ahora tu anillo de error incluye el botón del ícono, la flecha del select, la etiqueta prefijo—todo.

Etiquetado de campo requerido sin clases extra

Puedes marcar campos requeridos en función de la presencia de hijos :required.

cr0x@server:~$ cat required.css
.field label::after { content: ""; }

.field:has(:required) label::after {
  content: " *";
  color: #c1121f;
}

Aquí debes ser disciplinado: si tu wrapper puede contener múltiples inputs (por ejemplo, un rango de fechas),
decide si “requerido” debe reflejar cualquier input requerido o todos los inputs requeridos. Luego codifícalo.
Si no, enviarás spam de asteriscos inconsistentes.

Errores del servidor y estilado impulsado por ARIA

La validez del lado cliente no es toda la historia. En producción, el servidor rechaza cosas: restricciones de unicidad, reglas de política,
“ese código de cupón es de 2019, por favor basta”. Estos estados a menudo vuelven como atributos ARIA en el HTML renderizado.

cr0x@server:~$ cat aria.css
.field:has([aria-invalid="true"]) {
  border-color: #c1121f;
}

.field:has([aria-invalid="true"]) .hint {
  color: #c1121f;
  font-weight: 600;
}

Este también es un buen lugar para establecer una política de equipo: prefiere el estado ARIA a atributos personalizados “data-error”
cuando describen lo mismo. Mantiene accesibilidad y estilado alineados.

UI de ayuda condicional: mostrar advertencia “Bloq Mayús activado” solo cuando exista

Puedes asignar espacio condicionalmente y evitar el desplazamiento de diseño estilizando según la presencia de un elemento de ayuda.
No se necesita JS. También: no hay marcadores vacíos.

cr0x@server:~$ cat helper.css
.field .caps-warning { display: none; }

.field:has(.caps-warning[data-visible="true"]) .caps-warning {
  display: block;
  color: #8a6d3b;
}

Si lo haces bien, el JS solo establece data-visible en la propia advertencia.
El wrapper reacciona, y no estás propagando clases de padre por la mitad del árbol del componente.

Tarjetas: estilo consciente del contenido sin pegamento JS

Las tarjetas son donde los equipos de UI van a morir lentamente. Marketing quiere insignias, editorial quiere un subtítulo,
producto quiere que toda la tarjeta sea clicable pero además tiene un icono de marcador que no debe navegar.
Luego añades tests A/B que insertan etiquetas aleatorias. Genial.

:has() te permite estilizar la tarjeta según lo que hay dentro—sin obligar al sistema de plantillas a inyectar clases de estilo por todas partes.
El enfoque correcto es usar :has() para decisiones internas del componente. Si el estado viene desde fuera del componente,
aún quieres clases/props explícitas.

Resaltar tarjetas que contienen una insignia “Deal”

cr0x@server:~$ cat cards.css
.card {
  border: 1px solid #e5e7eb;
  border-radius: 14px;
  padding: 14px;
  background: white;
}

.card:has(.badge--deal) {
  border-color: #f59e0b;
  box-shadow: 0 8px 26px rgba(245, 158, 11, 0.18);
}

.card:has(.badge--deal) .title {
  color: #92400e;
}

No añadiste una .card--deal. No cambiaste el backend. No escribiste un script “escanea el DOM por insignias”.
Simplemente estilaste un componente real basado en contenido real.

Tarjetas con acciones: cambiar padding cuando exista fila de acciones

Problema clásico: algunas tarjetas tienen un footer con botones; otras no. El padding queda torpe.
Antes los equipos resolvían esto con plantillas duplicadas o props “hasFooter”. Ahora es solo CSS.

cr0x@server:~$ cat card-footer.css
.card { padding-bottom: 14px; }

.card:has(.card__actions) {
  padding-bottom: 10px;
}

.card:has(.card__actions) .card__actions {
  margin-top: 12px;
  border-top: 1px solid #eef2f7;
  padding-top: 10px;
}

Fíjate en el alcance: solo buscamos .card__actions dentro de .card.
Ese acotamiento es la primera decisión de “rendimiento” que tomas.

Tarjetas clicables sin enlaces anidados rotos

Muchos equipos envuelven toda la tarjeta en una etiqueta anchor. Luego incrustan otros anchors, y obtienes HTML inválido o comportamiento de clic extraño.
El patrón mejor es: conserva un enlace primario dentro y estiliza la tarjeta cuando contiene ese enlace.

cr0x@server:~$ cat clickable-card.css
.card:has(a.card__primary-link:hover) {
  transform: translateY(-1px);
  box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}

.card:has(a.card__primary-link:focus-visible) {
  outline: 3px solid rgba(0, 120, 212, 0.35);
  outline-offset: 3px;
}

Esto consigue el efecto de “toda la tarjeta se siente interactiva” sin cometer crímenes HTML.
El enlace sigue siendo el objetivo semántico. La tarjeta reacciona visualmente.

Filtros y búsqueda facetada: toggles, contadores y paneles con estado

Los filtros son la realidad en producción: muchas casillas, toggles, píldoras y lógica de “Limpiar todo”.
El estado de la UI tiende a expandirse: la barra lateral necesita saber si algo dentro está seleccionado; cada grupo de filtro necesita un indicador “sucio”;
el botón aplicar debe habilitarse solo cuando hay cambios; y la fila resumen debe mostrar contadores.

:has() no va a calcular contadores (CSS no es un motor de consultas), pero puede resolver las preguntas binarias que impulsan mucho del pulido UI:
“¿hay algo seleccionado?”, “¿este grupo está activo?”, “¿este grupo contiene un input inválido?”, “¿el panel está expandido?”

Marcar grupos de filtro activos si alguna checkbox está marcada

cr0x@server:~$ cat filters.css
.filter-group {
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  padding: 10px;
}

.filter-group:has(input[type="checkbox"]:checked) {
  border-color: #2563eb;
  background: #eff6ff;
}

.filter-group:has(input[type="checkbox"]:checked) .filter-group__title::after {
  content: " (active)";
  font-weight: 600;
  color: #2563eb;
}

Acabas de eliminar toda una categoría de JS: iterar grupos, alternar clases, manejar mutaciones del DOM.
El marcado impulsa el estado. Esa es la dirección correcta.

Habilitar el botón “Limpiar filtros” cuando algo esté seleccionado

Punto clásico de fricción: UX quiere el botón deshabilitado hasta que tenga sentido.
Puedes estilizar el botón según inputs marcados en el contenedor.

cr0x@server:~$ cat clear.css
.filters .clear {
  opacity: 0.4;
  pointer-events: none;
}

.filters:has(input:checked) .clear {
  opacity: 1;
  pointer-events: auto;
}

No es solo estética. Evita clics inútiles y reduce el ruido en backend (“limpiar” siendo pulsado sin filtros seleccionados).
Pequeñas mejoras de UI se traducen en calma operativa medible.

Estilado de acordeón con ARIA

cr0x@server:~$ cat accordion.css
.filter-group:has(button[aria-expanded="true"]) {
  box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
}

.filter-group:has(button[aria-expanded="true"]) .filter-group__chevron {
  transform: rotate(180deg);
}

De nuevo: el atributo ARIA es el estado. CSS reacciona. JS solo alterna aria-expanded en el botón,
que de todas formas debería hacer por accesibilidad.

Broma nº2: La mejor característica de :has() es que reduce las “pequeñas máquinas de estado” en JS—porque definitivamente necesitabas menos de esas.

Modelo mental de rendimiento: cuándo :has() es barato vs. arriesgado

El miedo alrededor de :has() no es irracional. Puede aumentar el trabajo que hace el navegador cuando el DOM cambia,
porque introduce selectores cuya coincidencia depende de descendientes.

Pero la realidad es matizada: muchas UIs tienen cuellos de botella en JavaScript, layout, imágenes o red.
Si :has() elimina observadores JS y reduce re-renders, puede ser una ganancia neta.
La cuestión no es “¿es :has() lento?” La cuestión es “¿lo hiciste sin acotar?”

Haz: acota :has() a raíces de componente

Bueno: .field:has(input:invalid), .card:has(.badge--deal), .filters:has(input:checked).
Están acotados por una clase raíz de componente y normalmente cubren un subtree pequeño.

No hagas: escribir selectores globales que escanean el mundo

Malo: body:has(input:invalid) (acabas de hacer que toda la página responda a cualquier input inválido),
o main:has(.badge) cuando hay miles de badges.

Sabe qué desencadena recálculo

  • Cambiar atributos que afectan el selector interno (por ejemplo, alternar aria-expanded, checked, disabled).
  • Añadir/quitar nodos descendientes que coincidan con el selector interno (mutaciones DOM).
  • Cambios de estado como :hover, :focus, :invalid—estos pueden ocurrir frecuentemente.

Regla operativa

Si el selector interno puede cambiar en cada movimiento del ratón (:hover) a través de un subtree grande, trátalo como un riesgo de rendimiento.
Si cambia solo en acciones discretas (alternar checkbox, submit, expansión), suele estar bien.

Esto no es palabrería. Es lo mismo que hacemos en ops: puedes permitir trabajo caro en despliegues e incidentes.
No puedes permitirlo en cada petición.

Tareas prácticas (comandos, salida y la decisión que tomas)

Pediste grado de producción. Eso significa que necesitas comprobaciones repetibles, no sensaciones.
A continuación hay tareas prácticas que puedes ejecutar localmente o en CI para validar el uso de :has(), riesgo de rendimiento y comportamiento de fallback.
Cada una incluye: un comando, qué significa la salida y la decisión que tomas.

Tarea 1: Encontrar usos de :has() en el repo

cr0x@server:~$ rg -n --hidden --glob '!**/node_modules/**' ':has\(' .
src/styles/forms.css:12:.field:has(input:invalid) {
src/styles/cards.css:41:.card:has(.badge--deal) {
src/styles/filters.css:7:.filters:has(input:checked) .clear {

La salida significa: Tienes tres sitios de selectores usando :has().
Decisión: Requerir que cada uso esté acotado a una clase raíz de componente y revisar la volatilidad del selector interno.

Tarea 2: Detectar patrones globales arriesgados de :has()

cr0x@server:~$ rg -n ':has\(' src/styles | rg -n '^(html|body|main|#app|\.app|\.page)'
src/styles/layout.css:3:body:has(.modal-open) {

La salida significa: Hay un uso global body:has(...).
Decisión: O lo justificas (el estado modal abierto puede estar bien si el elemento coincidente es estable) o refactorizas a una clase de estado más explícita en body.

Tarea 3: Verificar que la herramienta de build no “polyfill” :has() en basura

cr0x@server:~$ node -p "require('./package.json').browserslist"
[ 'defaults', 'not IE 11', 'maintained node versions' ]

La salida significa: Tu Browserslist es moderna. Bien.
Decisión: Confirma que tu pipeline CSS no intenta transformar :has(). Prefiere dejarlo intacto; polyfills parciales pueden ser peores que sin soporte.

Tarea 4: Comprobar qué contiene realmente tu CSS compilado

cr0x@server:~$ ls -lh dist/assets/*.css
-rw-r--r-- 1 cr0x cr0x 182K dist/assets/app.css

La salida significa: Tienes una hoja de estilos compilada para inspeccionar.
Decisión: Haz grep para asegurar que los selectores :has() sobreviven la minificación y no están duplicados excesivamente.

Tarea 5: Confirmar que :has() sobrevivió compilación/minificación

cr0x@server:~$ rg -n ':has\(' dist/assets/app.css | head
1:.field:has(input:invalid){border-color:#c1121f}
1:.filters:has(input:checked) .clear{opacity:1;pointer-events:auto}

La salida significa: El selector está presente en los assets enviados.
Decisión: Procede con mejora progresiva; no dependas de una reescritura en build-time.

Tarea 6: Medir deltas de tamaño de CSS al eliminar clases de estado JS

cr0x@server:~$ gzip -c dist/assets/app.css | wc -c
42191

La salida significa: El tamaño gzipped del CSS es ~42KB.
Decisión: Si cambiar toggles de clase JS por :has() reduce más JS de lo que aumenta CSS, suele ser ganancia neta para la interactividad de la página.

Tarea 7: Encontrar código JS que alterna clases en wrappers (candidato a eliminación)

cr0x@server:~$ rg -n "classList\.add\(|classList\.toggle\(" src | rg -n "(invalid|error|has-|active|dirty)"
src/ui/formField.ts:88:wrapper.classList.toggle("is-invalid", !input.checkValidity())
src/ui/filters.ts:52:group.classList.toggle("is-active", anyChecked)

La salida significa: Estás propagando manualmente el estado de hijo a clases de padre.
Decisión: Reemplaza donde sea seguro con :has() y mantén JS enfocado en comportamiento y estado de accesibilidad, no en estado de estilado.

Tarea 8: Confirmar que no dependes de navegadores no soportados en tráfico de producción (vía logs)

cr0x@server:~$ zgrep -h "User-Agent" /var/log/nginx/access.log* | head -n 3
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36

La salida significa: Motores modernos dominan tu muestra.
Decisión: Si aún tienes una larga cola (navegadores empresariales antiguos), diseña fallbacks: UI funcional primero, estilado mejorado después.

Tarea 9: Verificar que tu UI crítica sigue funcionando sin :has()

cr0x@server:~$ chromium --user-data-dir=/tmp/chromium-has-test --disable-features=CSSHasPseudoClass https://localhost:5173/
[19023:19023:1229/120102.103129:INFO:chrome_main_delegate.cc(785)] Starting Chromium...

La salida significa: Lanzaste Chromium con :has() deshabilitado (el nombre del flag puede variar por versión).
Decisión: Si los errores de formulario se vuelven invisibles, fallaste en la mejora progresiva. Mantén los mensajes de error visibles por defecto; usa :has() para pulir.

Tarea 10: Grabar un trazo de rendimiento centrado en recálculo de estilos

cr0x@server:~$ chromium --enable-logging=stderr --v=1 https://localhost:5173/
[19077:19077:1229/120222.411283:INFO:content_main_runner_impl.cc(1007)] Starting content main runner

La salida significa: Chrome está registrando; aún necesitas el panel Performance de DevTools para un trazo real.
Decisión: Si alternar una checkbox causa largos slices de “Recalculate Style”, audita selectores: reduce alcance, evita :has() basado en hover en contenedores grandes.

Tarea 11: Lint para “bombas de selectores” (selectores descendientes muy largos)

cr0x@server:~$ rg -n ':has\([^)]{60,}\)' src/styles
src/styles/legacy.css:19:.page:has(.content .grid .card .meta .badge[data-type="x"])

La salida significa: Alguien escribió una cadena descendiente larga y frágil dentro de :has().
Decisión: Reemplaza con un hook de clase estable como .badge--x o reestructura el marcado. Cadenas largas son fragilidad, no ingenio.

Tarea 12: Validar que tu lógica de selectores CSS no coincida accidentalmente con múltiples estados

cr0x@server:~$ node -e 'const s=[".field:has(input:invalid)",".field:has(:required)","body:has(.modal-open)"]; console.log(s.join("\n"))'
.field:has(input:invalid)
.field:has(:required)
body:has(.modal-open)

La salida significa: Imprime los selectores que pretendes enviar (úsalo en un smoke step CI junto al grep).
Decisión: Requiere revisión explícita de cualquier selector que apunte a body/html o use pseudo-clases muy dinámicas como :hover dentro de :has().

Tarea 13: Verificar que tu HTML del componente contiene los hooks que esperan los selectores

cr0x@server:~$ rg -n 'class="field"' src | head
src/pages/signup.html:21:<div class="field">
src/pages/settings.html:44:<div class="field">

La salida significa: Tus plantillas tienen clases wrapper consistentes.
Decisión: Estandariza nombres de clase wrapper (.field, .filter-group, .card) para que :has() permanezca local y predecible.

Tarea 14: Capturar regresiones haciendo diff de la estructura DOM para componentes críticos

cr0x@server:~$ git diff --stat origin/main...HEAD -- src/components/FormField.html
 src/components/FormField.html | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

La salida significa: La estructura del componente cambió (incluso si el conteo de líneas se mantiene).
Decisión: Ejecuta una prueba visual rápida o snapshot DOM cuando la estructura cambie, porque :has() depende de ella.

Guía rápida de diagnóstico

Cuando alguien dice “después de añadir :has(), la página se siente lenta”, no discutas. Diagnostica.
El cuello de botella suele ser uno de tres: alcance del selector, frecuencia de invalidación o thrash de layout por los estilos aplicados.

Primero: identifica el selector que amplía el conjunto de coincidencias

  • Busca raíces globales: html:has, body:has, main:has, #app:has.
  • Busca selectores internos amplios: :has(*:hover), :has(:focus), :has(.thing) donde .thing es común en todo el sitio.
  • Decisión: acótalo a una raíz de componente o añade una clase hook dedicada que solo aparezca donde haga falta.

Segundo: comprueba qué cambios desencadenan recálculo

  • Si es :hover o :focus, lo hiciste dependiente de la frecuencia de eventos.
  • Si es :checked o aria-expanded, es dependiente de acciones discretas (por lo general más seguro).
  • Decisión: evita :has() basado en hover en contenedores grandes; mantén efectos hover en el elemento hoverado o en un ancestro pequeño.

Tercero: inspecciona los estilos aplicados (la trampa de “parece inocente”)

  • Box shadows y filtros pueden ser caros cuando se aplican ampliamente.
  • Cambiar propiedades que afectan layout (como display, position, height) puede disparar reflows. A veces está bien, a veces es catastrófico.
  • Decisión: mantén los efectos de :has() mayormente a propiedades solo de pintura (color de borde, fondo, outline, color de texto) a menos que hayas perfilado.

Cuarto: confirma el comportamiento de fallback

  • Si el navegador no soporta :has(), ¿la UI todavía comunica errores y estado?
  • Decisión: muestra el texto de error por defecto (o al enviar), y usa :has() para decoración del wrapper como mejora.

Errores comunes: síntoma → causa raíz → solución

1) “Toda la página parpadea cuando hago hover en una cuadrícula de tarjetas.”

Síntoma: Al pasar el cursor por cualquier tarjeta se nota repintado o jitter.

Causa raíz: Un ancestro grande usa :has(:hover) o similar, causando recálculos frecuentes de estilo en un subtree grande.

Solución: Aplica estilos hover al elemento hoverado directamente, o restringe :has() a la propia tarjeta: .card:has(:hover) sigue siendo raro; prefiere .card:hover y usa :has() para estados no hover.

2) “Los campos inválidos no se resaltan en algunos navegadores.”

Síntoma: Los bordes del wrapper no se ponen rojos; los usuarios pierden los errores.

Causa raíz: Confiar en :has() para visibilidad de errores esencial sin fallback.

Solución: Haz los mensajes de error visibles y los inputs estilados de forma base; trata la decoración del wrapper como mejora. Si necesitas soporte amplio, mantén una clase mínima .is-invalid como fallback.

3) “Un wrapper de campo está rojo aunque el input parece válido.”

Síntoma: El wrapper muestra estilo inválido, pero el input previsto está bien.

Causa raíz: El wrapper contiene múltiples inputs, y uno oculto o no relacionado está inválido.

Solución: Reduce el selector interno al control específico: .field:has(input.field__control:invalid). No uses input:invalid si incrustas más inputs dentro.

4) “Nuestra tarjeta obtiene estilo ‘deal’ porque hay una insignia no relacionada dentro de un componente anidado.”

Síntoma: Falsos positivos: el estilo se dispara cuando no debería.

Causa raíz: Selector interno demasiado genérico (por ejemplo, :has(.badge) en vez de :has(.badge--deal)).

Solución: Usa clases modificadoras explícitas para semántica. :has() no es licencia para selectores vagos.

5) “El botón Limpiar filtros está habilitado, pero en realidad no hay filtros aplicados.”

Síntoma: La UI indica un estado que el backend no comparte.

Causa raíz: La barra lateral contiene checkboxes tanto para selecciones “aplicadas” como “pendientes”; :has(input:checked) no puede distinguir.

Solución: Añade un atributo o clase para marcar estado aplicado: .filters:has(input[data-applied="true"]:checked), o separa regiones DOM de aplicado vs borrador.

6) “Un pequeño refactor DOM rompió la mitad del estilado.”

Síntoma: Un cambio de UI mueve marcado; el comportamiento visual cambia inesperadamente.

Causa raíz: :has() depende de relaciones DOM; las cadenas descendientes frágiles amplificaron esa dependencia.

Solución: Mantén los selectores :has() superficiales, y confía en hooks estables dentro del componente. Añade pruebas snapshot/visual para componentes con selectores relacionales.

Listas de verificación / plan paso a paso

Adoptar :has() de forma segura (paso a paso)

  1. Elige un tipo de componente. Empieza con campos de formulario o grupos de filtro. No hagas un refactor a toda la web.
  2. Define el selector raíz. Ejemplo: .field, .filter-group, .card. Si no tienes uno, añádelo.
  3. Elige selectores internos estables y específicos. Ejemplo: input.field__control:invalid, .badge--deal, button[aria-expanded="true"].
  4. Haz la UX base funcional sin :has(). Los errores deben ser legibles. Los botones deben funcionar. Ningún estado esencial debe comunicarse solo por decoración de wrapper.
  5. Usa @supports selector(:has(*)) para mejoras arriesgadas. No siempre es necesario, pero es una salvaguarda limpia cuando cambias layout o ocultas/mostras elementos.
  6. Perfil una interacción. Alterna una checkbox, enfoca inputs, expande un acordeón. Busca largos slices de “Recalculate Style”.
  7. Elimina JS que solo propaga clases. Conserva JS que controle comportamiento y estado de accesibilidad.
  8. Añade una prueba de regresión. Snapshots visuales para los estados del componente: por defecto, enfocado, inválido, error de servidor.

Checklist de calidad de selectores (versión mental imprimible)

  • ¿El lado izquierdo es una raíz de componente (no body)?
  • ¿El selector interno es estrecho (no una cadena descendiente larga)?
  • ¿El selector interno cambia con alta frecuencia (hover/mouse move)? Si sí, replantea.
  • ¿Descendientes ocultos o no relacionados coincidirán accidentalmente?
  • ¿Seguirá siendo aceptable si el componente se renderiza 200 veces en una página?
  • ¿La UX sigue siendo aceptable sin :has()?

Tres mini-historias corporativas desde la trinchera

Mini-historia 1: El incidente causado por una suposición equivocada

Un equipo modernizó una gran página de ajustes: muchas secciones de formulario repetidas, componentes anidados, y un patrón inline de “Añadir otro”.
Reemplazaron un mecanismo JS basado en “wrapper inválido” por .section:has(input:invalid) y lo desplegaron detrás de una bandera de feature pequeña.
Parecía limpio. Las pruebas pasaron. Todos siguieron con su trabajo.

Luego empezaron los tickets de soporte: “No puedo guardar ajustes; me devuelve.” El bug no estaba en guardar.
La página hacía scroll hasta la primera sección inválida al enviar. Esa lógica de scroll buscaba wrappers .is-invalid
(una clase que el viejo JS solía establecer). El nuevo estilado solo por CSS no establecía la clase—porque era CSS.

La suposición equivocada fue sutil: “Si se ve inválido, es inválido, y el código puede encontrarlo.”
Pero el estilado no es estado. CSS no puede ser consultado por JS de forma fiable como quieres mantener.
Habían eliminado accidentalmente una señal semántica que otro código necesitaba.

La solución fue aburrida y correcta: mantener un atributo aria-invalid="true" explícito y actualizar la lógica de scroll para apuntar a eso,
manteniendo :has() para la decoración del wrapper. El JS que togglaba clases siguió eliminado. El estado permaneció descubrible.

La lección: usa :has() para expresar presentación derivada del estado, no para reemplazar el estado en sí.
Si otro código necesita reaccionar, dale algo semántico como ARIA o un data-attribute.

Mini-historia 2: La optimización que salió mal

Otra organización lanzó una nueva página de “catálogo” con miles de tarjetas (sí, miles).
Un ingeniero decidió reducir trabajo DOM quitando clases modificadoras por tarjeta generadas en servidor.
En lugar de renderizar .card--featured, la plantilla renderizaría un elemento .badge y dejaría que CSS hiciera lo demás:
.card:has(.badge--featured). Elegante.

Una semana después, los dashboards de rendimiento mostraron peor latencia en interacción durante scroll y filtrado.
No un incendio total, pero suficiente para molestar a usuarios móviles. Los trazos de DevTools mostraron mayores costes de recálculo de estilo durante actualizaciones de lista.
La razón no fue mística: la UI de filtrado actualizaba el DOM frecuentemente, y cada actualización implicaba más trabajo de matching de selectores
a través de una lista enorme.

La “optimización” también tenía un coste oculto: el marcado de insignias era más dinámico que la vieja clase modificadora.
Tests A/B insertaron nuevos tipos de insignia, y ahora múltiples reglas :has() competían. La cascade se volvió complicada.
El CSS seguía siendo correcto, pero había que ser mago para predecirlo.

La estrategia de rollback fue pragmática: mantener :has() para listas pequeñas y componentes locales,
pero restaurar clases modificadoras explícitas para listas masivas repetidas. El CSS quedó más simple y el engine hizo menos matching relacional.

La lección: :has() no es automáticamente más barato que una clase. En colecciones grandes con churn frecuente de DOM,
las clases de estado explícitas pueden ser un mejor trade-off de rendimiento.

Mini-historia 3: La práctica aburrida pero correcta que salvó el día

Un equipo de pagos introdujo :has() para mejorar sus wrappers de formulario. Fueron cautelosos: cada regla estaba detrás de
@supports selector(:has(*)) y el estilado de fallback era intencionalmente “suficientemente bueno”.
También escribieron una pequeña prueba de componente que renderizaba el campo en cuatro estados: por defecto, enfocado, inválido, error de servidor.

Seis meses después, un partner embebió el formulario de pago dentro de un WebView con un motor más antiguo.
El estilado del wrapper no se aplicó. Pero el formulario siguió funcionando. Los mensajes de error eran visibles, existían anillos de foco,
y el flujo de envío estuvo bien. Ningún incidente. Ningún mensaje a medianoche. Solo una nota menor de “se ve menos pulido”.

Lo decisivo: otro equipo sin esas guardas había enviado una característica similar en otro lugar, y en motores antiguos el texto de error
estaba oculto por defecto y solo se revelaba por selectores :has(). Los usuarios no podían ver qué falló.
Eso sí se convirtió en un problema de soporte real.

La práctica aburrida no fue heroica. Fue: mejora progresiva, fallback explícito y pruebas de estados.
El retorno operativo fue real: menos fallos visibles por el usuario en entornos cliente impredecibles.

Preguntas frecuentes

1) ¿Es :has() seguro para usar en producción?

Sí, si usas mejora progresiva y evitas selectores sin acotar. Trátalo como cualquier característica moderna de plataforma:
define una experiencia base y luego mejora donde haya soporte.

2) ¿Debo envolver reglas :has() en @supports?

Si la regla afecta la usabilidad esencial (mostrar/ocultar errores, cambios de layout), sí. Si es puramente decorativa y aceptas que no se aplique, es opcional.
La guardia se ve así: @supports selector(:has(*)) { ... }.

3) ¿Puedo usar :has() como reemplazo del estado en JS?

Reemplaza la propagación de estado de presentación, no el estado de dominio. Si el código necesita saber que algo está inválido/expandido/activo,
exprésalo mediante atributos o clases. CSS puede entonces derivar lo visual usando :has().

4) ¿Hace :has() daño al rendimiento?

Puede, especialmente cuando se usa en contenedores grandes con descendientes que cambian frecuentemente.
Mantén los selectores acotados a raíces de componente y evita pseudo-clases de alta frecuencia como :hover dentro de :has() en subtrees grandes.
Perfila la interacción específica que te importa.

5) ¿Es :has() mejor que añadir una clase como .is-invalid?

Es mejor cuando el estado ya está presente en el DOM (p. ej. :invalid, :checked, atributos ARIA) y quieres evitar pegamento JS.
Una clase es mejor cuando tratas con listas pesadas, estado entre componentes, o cuando JS ya calcula el estado de todos modos.

6) ¿Puedo usar :has() para contar filtros seleccionados?

No directamente. CSS no puede computar contadores de forma mantenible. Usa JS para calcular contadores, renderiza un número y usa :has() para estilado binario como “activo/inactivo”.

7) ¿Cómo depuro selectores :has()?

Empieza aislando la coincidencia: aplica temporalmente un outline fuerte al selector izquierdo, luego afina el selector interno.
Mantén selectores internos cortos y anclados a clases explícitas para razonar rápidamente por qué coincidió.

8) ¿Puede :has() reemplazar :focus-within?

No reemplaza, pero complementa. :focus-within es una forma eficiente y específica de estilizar ancestros cuando el foco está dentro.
Úsalo para foco. Usa :has() cuando necesites condiciones más ricas que “cualquier descendiente con foco”.

9) ¿Cuál es el mejor primer caso de uso para :has()?

Wrappers de campos de formulario que reaccionan a :invalid, :required y estado ARIA inválido. Elimina JS desordenado e inmediatamente mejora la consistencia UX.

Siguientes pasos que puedes enviar esta semana

Haz esto en orden, porque los sistemas de producción recompensan la secuenciación aburrida.

  1. Elige una familia de componentes: wrappers de campo, grupos de filtro o tarjetas.
  2. Añade o confirma una clase raíz de componente para que tu :has() permanezca local.
  3. Implementa una regla relacional que elimine un toggle de clase JS existente. Mantén el comportamiento antiguo detrás de una feature flag si eres cauto.
  4. Protege mejoras arriesgadas con @supports selector(:has(*)) y asegura que la UX base sigue comunicando estado.
  5. Ejecuta las comprobaciones del repo: grep por selectores globales, cadenas descendientes largas y patrones basados en hover.
  6. Perfila una interacción real (alternar filtros, input inválido, expandir acordeón) y verifica que el recálculo de estilos no domine.
  7. Añade una pequeña suite de regresión para estados UI clave que dependan de :has().

:has() es una de esas características que hace que la plataforma parezca finalmente ponerse al día con cómo construimos UI.
Úsala como un adulto: acotada, testeable, perfilada, con degradación elegante. Tu bundle JS se hace más pequeño, tu DOM más sensato,
y tu rotación de on-call más tranquila—que es el único KPI que importa cuando son las 2 a.m.

← Anterior
Intel vs AMD en juegos: dónde los benchmarks te engañan
Siguiente →
Error ‘exec format’ de Docker: imágenes de arquitectura equivocada y la solución limpia

Deja un comentario