La especificidad del ampersand
Por qué & pesa lo que pesa el selector padre más específico, cuándo el anidamiento infla la especificidad y cuándo no, y cómo aplanarla sin renunciar a la estructura.
El anidamiento parece una reorganización puramente visual del mismo CSS, y casi siempre lo es. La excepción vive en un sitio muy concreto: cuando el selector padre es una lista con entradas de peso desigual, todas las reglas anidadas debajo heredan el peso de la más pesada. El cambio ocurre al editar la primera línea del bloque y afecta a las cuarenta reglas de dentro sin que ninguna de ellas se modifique.
- Calcular la especificidad de cualquier regla anidada sin abrir el inspector.
- Demostrar que anidar por niveles no infla la especificidad respecto al selector plano.
- Identificar el único caso en que sí la infla y por qué.
- Aplanar el peso con
:where()y con capas sin perder la estructura.
El ampersand pesa lo que el padre más específico
Ya sabes que & equivale a :is() sobre la lista completa del padre. La especificidad se deriva de ahí sin excepciones: la de :is() es la del selector complejo más específico de su argumento.
.a, #b {
& .c { color: red; }
}
El selector resuelto es :is(.a, #b) .c. El más específico del argumento es #b, que vale (1,0,0), así que el total es (1,0,0) + (0,1,0) = (1,1,0). Y ese peso se aplica también cuando el elemento que encaja lo hace por .a, porque la especificidad se calcula sobre el selector escrito, no sobre el motivo por el que encajó.
Es exactamente el mismo mecanismo que ya viste en :has() y en la cláusula of S. Lo que cambia aquí es que el :is() no lo has escrito tú: lo ha puesto el anidamiento.
Cuándo se infla y cuándo no
La noticia buena es que el caso habitual no infla nada. Cuando el padre es un solo selector, :is(X) pesa lo mismo que X, y anidar por niveles da el mismo número que el selector plano equivalente.
.card {
& .cuerpo {
& .titulo { font-weight: 600; }
}
}
El resuelto del nivel más profundo es :is(:is(.card) .cuerpo) .titulo. El argumento del :is() exterior es el selector complejo :is(.card) .cuerpo, que pesa (0,2,0), más (0,1,0) del .titulo: total (0,3,0). Idéntico a .card .cuerpo .titulo. El anidamiento no cobra peaje.
| Escrito | Resuelto | Especificidad |
|---|---|---|
.card { & .x { } } |
:is(.card) .x |
(0,2,0) |
.card { &.x { } } |
:is(.card).x |
(0,2,0) |
h2 { &.x { } } |
:is(h2).x |
(0,1,1) |
.a, .b { & .x { } } |
:is(.a, .b) .x |
(0,2,0) |
.a, #b { & .x { } } |
:is(.a, #b) .x |
(1,1,0) |
.card { && { } } |
:is(.card):is(.card) |
(0,2,0) |
Las cinco primeras filas son tranquilizadoras y la quinta es la que arruina tardes. La única fuente de inflación es una lista de padres con especificidades desiguales. Mientras todas las entradas del padre pesen lo mismo, el anidamiento es neutro.
No hace falta un #id para desequilibrar la lista. .boton, button es una lista desigual: .boton pesa (0,1,0) y button pesa (0,0,1), así que todo lo anidado dentro pesará como si fuera .boton, incluso al aplicar a un button sin clase. Es un desequilibrio pequeño y por eso pasa desapercibido durante meses, hasta que alguien intenta sobrescribir con un selector de tipo y no lo consigue.
Cómo aplanarlo
Tres herramientas, en orden de preferencia.
Separa la lista. La solución más honesta suele ser no tener una lista desigual. Si #b está ahí por un caso heredado, sácalo a su propia regla y deja el bloque anidado con un padre homogéneo.
/* Antes: contamina todo el bloque. */
.panel, #panel-legado { & .titulo { } & .cuerpo { } }
/* Despues: el caso raro se trata como lo que es, un caso raro. */
.panel { & .titulo { } & .cuerpo { } }
#panel-legado { /* lo minimo imprescindible */ }
Envuelve el padre en :where(). Cuando la lista tiene que quedarse como está, :where() la neutraliza a cero y el peso pasa a ser solo el del selector hijo:
:where(.a, #b) {
& .c { color: red; } /* :is(:where(.a, #b)) .c -> (0,1,0) */
}
Ojo con el efecto: ahora la regla pesa (0,1,0), menos que antes de anidar. Eso es lo que quieres en CSS de librería, donde el objetivo es que el consumidor pueda sobrescribir con una clase; puede no serlo en CSS de aplicación.
Usa capas y deja de contar. Si el motivo por el que te preocupa la especificidad es el orden de sobrescritura, la respuesta correcta casi siempre es @layer, porque una capa posterior gana a una anterior con independencia de los números. Anidar dentro de capas te permite escribir el selector que mejor exprese la intención sin auditarlo:
@layer componentes, temas;
@layer componentes {
.card { & .titulo { font-weight: 600; } }
}
@layer temas {
/* Gana sin necesidad de pesar mas. */
.card { & .titulo { font-weight: 500; } }
}
El presupuesto de especificidad de un componente
En un proyecto con anidamiento conviene fijar una regla explícita, escrita en la guía del equipo, porque el coste de no fijarla no aparece hasta que el proyecto es grande:
Un componente declara su raíz con un solo selector de clase. Nada de listas, nada de identificadores, nada de mezclar clase y etiqueta. Con eso, toda la aritmética de dentro es predecible y ninguna edición en la primera línea altera el comportamiento de las de abajo.
Los desvíos van en capas, no en peso. Los temas, los estados excepcionales y los ajustes de una página concreta se resuelven con @layer o con custom properties, nunca subiendo especificidad.
Los selectores prestados van en :where(). Si tienes que reaccionar a una clase que no controlas —una que pone un framework, una que viene del gestor de contenidos—, envuélvela para que no arrastre peso al bloque.
@layer componentes {
.card {
/* Raiz de un solo selector: presupuesto (0,1,0). */
padding: 1rem;
& .titulo { font-weight: 600; }
&:has(> img) { padding-block-start: 0; }
/* Selector prestado, sin peso. */
:where(.tema-compacto) & { padding: 0.5rem; }
}
}
Esta es la patología concreta que hace que la especificidad del & merezca una lección entera, y no la vas a encontrar descrita en la documentación. Imagina un bloque de componente de doscientas líneas, con treinta reglas anidadas, funcionando desde hace un año. Llega una migración y alguien necesita que el componente responda también a un contenedor heredado que solo tiene identificador. La edición es de un carácter: añadir , #legado a la primera línea. El diff es de una línea. La revisión de código lo aprueba en diez segundos. Y en ese momento las treinta reglas de dentro han pasado de pesar en la columna de las clases a pesar en la columna de los identificadores, sin que ninguna de ellas aparezca en el diff. Todo lo que las sobrescribía desde otros archivos —temas, utilidades, ajustes de página— deja de funcionar a la vez, y el síntoma aparece en pantallas que nadie relaciona con esa migración. La causa raíz es que el anidamiento crea una dependencia de la especificidad hacia arriba que no existía en CSS plano: antes, el peso de una regla estaba escrito íntegramente en su propia línea y era auditable leyéndola; ahora está repartido entre esa línea y todos sus ancestros sintácticos. Por eso la disciplina de “raíz de un solo selector” no es purismo: es lo que devuelve la propiedad de que una regla se pueda entender leyéndola. Y por eso, cuando de verdad no haya más remedio que ampliar la lista del padre, la forma correcta es :where(.panel, #legado), que la deja plana y convierte la edición de un carácter en una edición inofensiva.
- Calcula a mano la especificidad de
.a, ul { & > li.x:hover { } }. - Demuestra con un caso real que
.card { & .x { & .y { } } }pesa lo mismo que.card .x .y. - Añade
, #legadoa la raíz de un componente anidado y comprueba cuántas reglas cambian de peso. - Aplana ese mismo bloque con
:where()y verifica que vuelve al peso original. - Reescribe un desvío de tema que hoy sube especificidad para que lo resuelva una capa.