wandres.dev
SELECTORES MODERNOS · :is, :where y la especificidad cero

:is() y la lista de selectores tolerante

Qué agrupa realmente :is(), por qué su lista descarta lo que no entiende en lugar de invalidar la regla, y cómo eso convierte al selector en un mecanismo de compatibilidad hacia adelante.

⏱ 16 min

:is() parece una abreviatura para no repetir selectores y es bastante más que eso. Su capacidad de factorizar combinadores elimina una explosión combinatoria que antes había que escribir a mano, su lista es tolerante —descarta lo que no entiende sin llevarse la regla por delante— y esa tolerancia resuelve un problema de compatibilidad que durante años no tuvo solución limpia.

🎯 Al terminar esta lección sabrás
  • Factorizar selectores repetidos con :is() incluyendo los que llevan combinadores.
  • Explicar la diferencia entre una lista de selectores clásica y una tolerante.
  • Usar la tolerancia para escribir selectores que sobrevivan a motores que no los conocen.
  • Reconocer qué no se puede meter dentro de :is().

Lo que agrupa de verdad

:is() casa con un elemento si ese elemento casa con alguno de los selectores de su lista. La ganancia obvia es de escritura:

/* Antes */
header h1, header h2, header h3,
footer h1, footer h2, footer h3 { margin-block: 0; }

/* Ahora */
:is(header, footer) :is(h1, h2, h3) { margin-block: 0; }

Seis selectores se convierten en uno. Pero la ganancia importante no es la brevedad, es que la versión clásica crece como el producto de las alternativas. Con tres contenedores y cuatro encabezados serían doce selectores escritos a mano, y cada vez que alguien añada un contenedor tendrá que acordarse de multiplicar. Con :is() se añade un nombre a una lista.

El caso donde la diferencia es más marcada es el de los combinadores, porque ahí la versión sin :is() no solo es larga sino que induce a error:

:is(h2, h3, h4) + p { margin-block-start: 0.25em; }

Escrito a mano son tres selectores, y basta olvidar uno para que un caso concreto se comporte distinto sin que nadie lo note durante meses.

:is() también acepta selectores complejos completos, no solo compuestos simples:

.prosa :is(blockquote > p:first-child, figure > figcaption) {
  font-style: italic;
}

Y su especificidad ya la conoces del nivel anterior: la del argumento más específico de la lista, se haya usado ese o no. Es la fuente de la mayoría de las sorpresas y conviene tenerla presente cada vez que mezcles tipos de selector distintos dentro de la misma llamada.

La lista tolerante

Aquí está la propiedad que distingue a :is() de una simple agrupación. En una lista de selectores clásica separada por comas, un solo selector inválido invalida la regla entera, incluidos los selectores válidos que la acompañan:

/* Si el motor no conoce :flotante-magico, se pierde TODA la regla */
p, :flotante-magico, li { line-height: 1.6; }

Ni los párrafos ni los elementos de lista reciben nada. Es un comportamiento con lógica —si el navegador no entiende parte de un selector, no puede saber a qué debía aplicarse— y en la práctica ha causado un daño enorme.

:is() acepta una lista tolerante: los selectores que el motor no entiende se descartan uno a uno y el resto sigue funcionando.

/* Los párrafos y los elementos de lista reciben el estilo pase lo que pase */
:is(p, :flotante-magico, li) { line-height: 1.6; }

:where() comparte esta propiedad. Conviene saber que :not() no la tiene: su lista es de las clásicas, así que un selector desconocido dentro de un :not() invalida la regla completa igual que antes.

La tolerancia convierte a :is en un mecanismo de compatibilidad hacia adelante, y casi nadie lo usa así

Piensa en lo que esa tolerancia te permite escribir, porque va mucho más allá de protegerte de una errata. Puedes incluir en un selector algo que todavía no existe en todos los motores y la regla seguirá funcionando hoy, aplicándose por los selectores que sí se entienden, y empezará a cubrir el caso nuevo el día que el motor lo implemente, sin que tengas que tocar nada. Es despliegue progresivo de selectores, y no había forma de hacerlo antes. El ejemplo canónico es el estado de validación de formularios: la pseudo-clase que marca un campo que el usuario ya ha tocado y ha dejado inválido convive con la clase que tu aplicación pone por su cuenta, y quieres estilar los dos casos igual. Escrito con una lista clásica, si algún motor de tu parque no conoce la pseudo-clase, se pierde también el estilo de tu clase, es decir, la característica más nueva rompe la más antigua. Escrito con :is(), cada motor aplica lo que sabe:

input:is(:user-invalid, .invalido) {
  border-color: oklch(58% 0.2 25);
}

Este mecanismo es exactamente lo que faltaba en la época de los prefijos de proveedor, cuando agrupar los pseudo-elementos de marcador de posición de tres navegadores en una sola regla era imposible: cada motor descartaba la regla entera al toparse con el pseudo-elemento de otro, y había que escribir tres reglas idénticas. La ironía es que :is() sigue sin poder resolver aquel caso concreto, porque no admite pseudo-elementos dentro; lo resuelve para todo lo demás. La conclusión práctica: cuando en un selector conviva algo nuevo con algo asentado, envuélvelo en :is(), aunque no estés agrupando nada. Cuesta ocho caracteres y te compra un plan alternativo que no hay que mantener.

Qué no cabe dentro

Dos restricciones que hay que conocer porque su ausencia de mensaje de error las hace difíciles de diagnosticar.

No admite pseudo-elementos. :is(::before, ::after) no es válido y, como la lista es tolerante, el argumento se descarta en silencio y la regla se aplica a menos cosas de las que creías. La razón es conceptual: :is() casa con elementos, y un pseudo-elemento no es un elemento sino una caja generada. Para agrupar pseudo-elementos sigue haciendo falta una lista de selectores clásica:

/* Correcto */
.boton::before, .boton::after { content: ""; }

/* No funciona */
.boton:is(::before, ::after) { content: ""; }

No funciona con lo que exige estar al principio. Selectores que solo tienen sentido en una posición concreta no se pueden reubicar dentro de un :is() en cualquier sitio del selector.

Si tienes dudas sobre si un motor concreto entiende una construcción, la comprobación es directa desde CSS y no requiere JavaScript:

@supports selector(:is(a, b)) {
  /* aqui va lo que dependa de :is */
}

Su relación con el anidamiento

El anidamiento nativo usa el mismo mecanismo por dentro. Una regla anidada se comporta como si el selector padre estuviera envuelto en :is(), lo que tiene dos consecuencias que conviene unificar mentalmente.

La primera, de especificidad: si la lista de padres es mixta, manda el más específico, tal y como viste en el nivel de especificidad.

La segunda, de significado: por eso una regla anidada bajo una lista de padres se aplica a la unión de los contextos, y no genera un selector por cada padre. Son el mismo mecanismo con dos sintaxis.

/* Estas dos reglas son equivalentes */
.tarjeta, .panel {
  & > .titulo { font-weight: 600; }
}

:is(.tarjeta, .panel) > .titulo { font-weight: 600; }

Saber que son lo mismo evita la sorpresa más común al adoptar anidamiento, que es esperar el comportamiento de los preprocesadores clásicos —que sí expandían a varios selectores independientes— y encontrarse con una especificidad más alta de la prevista.

La pieza que falta de esta familia es la que renuncia a la especificidad por completo, y es la que cambia la forma de escribir CSS destinado a otros: :where(), la siguiente lección.