Contar especificidad sin equivocarse
Los casos donde la cuenta sale mal: repeticiones, pseudo-elementos, selectores funcionales, nth-child con of, y el anidamiento que puntúa como su padre más fuerte.
Contar tres columnas parece trivial hasta que aparecen los selectores funcionales, el anidamiento nativo o un nth-child con lista. Ahí la aritmética deja de ser obvia y empiezan las predicciones equivocadas, que en la práctica se manifiestan como reglas que ganan cuando esperabas que perdieran. Esta lección recorre exactamente los casos que se cuentan mal, con la cifra correcta al lado.
- Calcular la especificidad de selectores con repeticiones, pseudo-elementos y atributos.
- Aplicar la regla del argumento más específico a
:is(),:not()y:has(). - Calcular la especificidad de
nth-childcon la cláusulaof. - Predecir la especificidad de una regla anidada a partir de su lista de selectores padre.
La tabla de los casos que se cuentan mal
| Selector | A-B-C | Detalle que se olvida |
|---|---|---|
* |
0-0-0 | El universal no puntúa |
article > p ~ span |
0-0-3 | Los combinadores no puntúan |
li::marker |
0-0-2 | El pseudo-elemento cuenta como tipo |
.aviso.aviso |
0-2-0 | Las repeticiones sí se cuentan |
[type="email"] |
0-1-0 | Un atributo vale lo mismo que una clase |
[id="principal"] |
0-1-0 | Seleccionar el id por atributo evita la columna A |
#principal |
1-0-0 | Lo mismo en el documento, otra columna |
:root |
0-1-0 | Es una pseudo-clase, no un tipo |
:nth-child(3) |
0-1-0 | El argumento numérico no aporta |
:nth-child(2n+1 of .destacada) |
0-2-0 | Pseudo-clase más el argumento de la lista |
:is(h1, h2, h3) |
0-0-1 | El más específico de tres tipos es un tipo |
:is(.caja, #app) |
1-0-0 | Manda el identificador aunque case por la clase |
:where(.caja, #app) |
0-0-0 | Siempre cero, pase lo que pase dentro |
:not(.a, #b) |
1-0-0 | Igual que :is() |
:has(> img) |
0-0-1 | El combinador no puntúa, la imagen sí |
a:not(:where(.externo)) |
0-0-1 | El cero de :where() se propaga hacia fuera |
Dos filas merecen comentario aparte porque son técnicas de verdad, no curiosidades.
La de [id="principal"] es la salida limpia cuando tienes que apuntar a un elemento que solo se distingue por su identificador y no quieres el escalón de la columna A. El documento no cambia, el elemento es el mismo, y la regla pasa a competir en la liga de las clases, donde otros pueden sobrescribirla sin escalar.
La de a:not(:where(.externo)) muestra que el cero de :where() se propaga hacia fuera: como :not() toma la especificidad de su argumento más específico y ese argumento vale cero, el :not() entero vale cero. Puedes por tanto excluir casos arbitrariamente complicados sin pagar un solo punto, lo que convierte a la pareja en la herramienta más precisa del lenguaje para acotar sin encarecer.
Pseudo-clases funcionales: manda el argumento más fuerte
La regla es una sola y cubre :is(), :not() y :has(): la especificidad de la pseudo-clase es la del selector más específico de su lista de argumentos. La pseudo-clase en sí no aporta nada; solo transmite la del argumento ganador. :where() es la excepción explícita: su especificidad es siempre cero, hagas lo que hagas dentro.
Y hay un matiz que es la fuente de la mayoría de las sorpresas: el argumento que fija la especificidad no tiene por qué ser el que casó. La cuenta se hace mirando el texto del selector, sin mirar el documento, así que el más caro de la lista impone su precio a todos los elementos que casen por cualquier vía.
Este es el caso que produce el peor tipo de bug de cascada: uno que aparece en una parte del sitio sin que nada de esa parte del sitio lo explique. Escribes una regla razonable que agrupa dos contextos, uno de los cuales por motivos históricos se identifica con un id:
:is(#app, .contenido) p { color: slategray; }y en otro sitio, sin relación aparente, una regla normal para una variante:
.contenido .destacado p { color: crimson; }Esperas que gane la segunda: dos clases y un tipo, 0-2-1, frente a lo que parece una clase y un tipo. Pero la primera vale 1-0-1 para todos los elementos que casan, incluidos los que casaron por .contenido y que jamás estuvieron dentro de #app. El identificador no participó en la coincidencia y aun así impone su columna. La regla del argumento más específico se aplica al selector, no a la coincidencia concreta, y por eso el coste se cobra en sitios donde el motivo no está a la vista. Diagnosticarlo es especialmente ingrato, porque el selector culpable puede estar en otro fichero y no menciona ninguna de las clases involucradas en el conflicto. La regla de higiene que se deduce es tajante: no mezcles identificadores con clases dentro de un :is(). Si necesitas agrupar contextos y uno de ellos es un identificador, envuélvelo aparte en :where(#app) para neutralizarlo, o selecciónalo por atributo con [id="app"], y así la lista entera se queda en la liga de las clases. La misma advertencia vale para :not() y para :has(), que comparten la regla y por tanto comparten la trampa.
El anidamiento cuenta como su lista de padres
El anidamiento nativo introduce una fuente de especificidad que no se ve en el texto de la regla anidada, y conviene tenerla presente desde el primer día. Una regla anidada equivale a la misma regla con el selector padre insertado, y ese selector padre se comporta a efectos de cuenta como si estuviera dentro de un :is(). Es decir: si la lista de padres tiene varios selectores, manda el más específico de todos.
.tarjeta, #destacado {
& .titulo { font-size: 1.25rem; }
}
La regla anidada equivale a :is(.tarjeta, #destacado) .titulo, con especificidad 1-1-0. Cualquier .titulo dentro de una .tarjeta corriente arrastra la columna de identificador que aportó un hermano de la lista con el que ese elemento no tiene nada que ver.
Es una diferencia real con los preprocesadores clásicos, donde el anidamiento se resolvía por sustitución textual y habría generado dos selectores independientes, .tarjeta .titulo y #destacado .titulo, cada uno con su propia especificidad. El anidamiento nativo no expande: agrupa, y agrupar cuesta lo que cuesta el más caro del grupo.
La consecuencia práctica es sencilla de aplicar: cuando anides bajo una lista mixta, o separas la lista en dos bloques, o neutralizas el elemento caro:
:where(#destacado), .tarjeta {
& .titulo { font-size: 1.25rem; } /* ahora 0-2-0 */
}
Una regla mental para no fallar
Cuando la cuenta se complique, tres preguntas resuelven casi todo.
¿Hay algún # en algún sitio, incluido dentro de paréntesis? Si lo hay y no está envuelto en :where(), la columna A es al menos 1 y probablemente ya has terminado la comparación.
¿Hay algún :where()? Todo lo que esté dentro vale cero, y ese cero se propaga hacia fuera si el :where() es el argumento de otra funcional.
¿Hay anidamiento? Entonces suma la especificidad del padre más caro de la lista, aunque no aparezca escrito en la regla que estás mirando.
Con esas tres, y sabiendo que las columnas se comparan de izquierda a derecha sin sumarse, no quedan casos ambiguos en CSS de autor.
- Escribe en un documento las diez primeras filas de la tabla como reglas reales que compitan por
colorsobre el mismo elemento y comprueba en el inspector cuál gana. - Cambia
:is(.caja, #app)por:is(.caja, :where(#app))y verifica que el ganador se invierte. - Anida una regla bajo una lista mixta de clase e identificador y confirma en el panel de estilos la especificidad resultante.