:scope, el sujeto implícito y la especificidad cero
Qué antepone el motor a cada selector dentro de un @scope, cuándo hace falta :scope explícito, y por qué el ámbito da precisión sin cobrar peso en la cascada.
La aportación conceptual de @scope no es acotar: es haber separado dos cosas que llevaban veinticinco años soldadas. Hasta ahora, apuntar con precisión a un elemento significaba nombrar a sus ancestros, y nombrar ancestros significaba pesar más en la cascada. Cuanto más exacta era tu regla, más difícil resultaba sobrescribirla. Dentro de un @scope, la restricción de ancestros sale del selector y se va al contexto de la regla, donde no cuenta.
- Describir qué antepone el motor a cada selector dentro del bloque.
- Usar
:scopepara estilar la raíz y saber cuándo es imprescindible. - Calcular la especificidad de una regla acotada y compararla con la equivalente sin ámbito.
- Anidar dentro de
@scopesin caer en el comportamiento inestable del&.
El sujeto implícito
Dentro de un bloque @scope, cada selector se comporta como si llevase :where(:scope) delante.
@scope (.cuerpo-articulo) {
img { border-radius: 0.5rem; }
}
El selector efectivo de esa regla es :where(:scope) img, donde :scope es la raíz del ámbito. Es decir: una img descendiente de un .cuerpo-articulo.
De ahí salen dos consecuencias inmediatas. La primera es que el prefijo establece una relación de descendencia, así que la raíz no queda cubierta por los selectores desnudos: dentro de @scope (.cuerpo-articulo), un selector .cuerpo-articulo encajaría con un .cuerpo-articulo anidado dentro de otro, no con el propio. La segunda es que el prefijo va envuelto en :where(), que pesa cero, y por tanto la raíz del ámbito no aporta ni un punto de especificidad.
:scope y la raíz explícita
Para estilar el propio elemento raíz hace falta nombrarlo, y para eso está :scope:
@scope (.tarjeta) {
/* El propio .tarjeta. */
:scope {
display: grid;
gap: 0.75rem;
padding: 1rem;
}
/* Descendientes. */
h3 { margin: 0; font-weight: 600; }
/* Hijos directos de la raiz. */
:scope > img { aspect-ratio: 16 / 9; }
/* La raiz en un estado. */
:scope:hover { border-color: currentColor; }
}
:scope es una pseudo-clase normal y por tanto sí aporta especificidad de clase, (0,1,0). Esa es la diferencia con el prefijo implícito, que va en :where() y aporta cero. Escrito como tabla:
Regla dentro de @scope (.tarjeta) |
Selector efectivo | Especificidad |
|---|---|---|
img { } |
:where(:scope) img |
(0,0,1) |
:scope img { } |
:scope img |
(0,1,1) |
:scope { } |
:scope |
(0,1,0) |
.destacada { } |
:where(:scope) .destacada |
(0,1,0) |
Elegir entre img y :scope img no es cuestión de gusto: son la misma selección con pesos distintos. Usa la forma desnuda salvo que necesites el punto extra para ganar a otra regla, que casi nunca es el caso.
La especificación dice que & dentro de un bloque @scope se comporta igual que el prefijo implícito, es decir, como :where(:scope) y con peso cero. En la práctica, distintos motores y distintas versiones lo han tratado de formas diferentes. Mientras eso se asienta, la recomendación es sencilla: dentro de @scope, usa selectores desnudos para los descendientes y :scope para la raíz, y reserva el & para el anidamiento normal fuera del ámbito. No pierdes expresividad y te ahorras una diferencia entre navegadores muy difícil de diagnosticar.
Especificidad: la diferencia que lo justifica
Compara las dos formas de decir “las imágenes del cuerpo del artículo, dentro de este componente, pero no las de la portada”:
/* Sin ambito: la precision se paga en peso. */
.feature > .cuerpo-articulo > img { border-radius: 0.5rem; }
/* Especificidad (0,2,1). Para sobrescribirla necesitas dos clases y una etiqueta. */
/* Con ambito: la misma precision, el peso de una etiqueta. */
@scope (.feature > .cuerpo-articulo) {
img { border-radius: 0.5rem; }
}
/* Especificidad (0,0,1). Una sola clase la sobrescribe. */
Las dos apuntan exactamente a los mismos elementos. La primera es casi inamovible; la segunda cede ante cualquier .redondeo-cuadrado { border-radius: 0 } que escriba el consumidor. Y aquí no hay truco ni pérdida: la restricción de ancestros sigue existiendo y sigue siendo obligatoria para que la regla encaje. Simplemente ha dejado de contar en el cómputo.
Eso convierte a @scope en la herramienta correcta para CSS de librería y de sistema de diseño, donde el objetivo permanente es ser preciso y a la vez fácil de sobrescribir. Es el mismo objetivo que perseguía envolver todo en :where(), pero sin tener que envolver nada.
También es la razón por la que @scope y @layer se complementan en lugar de solaparse: la capa decide quién gana entre bloques, y el ámbito decide dónde puede encajar cada regla sin inflar su peso. Un sistema de diseño moderno usa los dos.
Anidamiento dentro del ámbito
El bloque de un @scope admite anidamiento normal, y ahí el & recupera su significado habitual respecto de la regla padre:
@scope (.tarjeta) {
h3 {
margin: 0;
font-weight: 600;
/* Aqui & es el h3, no la raiz del ambito. */
&:hover { text-decoration: underline; }
}
@container (min-inline-size: 30rem) {
:scope { grid-template-columns: 12rem 1fr; }
}
}
Y en la dirección contraria, @scope puede vivir dentro de una regla de estilo, con la raíz expresada en función del padre:
.tarjeta {
border: 1px solid;
@scope (&) to (.tarjeta__slot > *) {
h3 { margin: 0; }
p { margin-block: 0.5rem; }
}
}
Esa combinación —el componente declara su ámbito y su suelo dentro de su propio bloque— es probablemente la forma más limpia de escribir un componente en CSS moderno, y no era expresable de ninguna manera hace tres años.
Durante veinticinco años, CSS tuvo un defecto de diseño del que todas las metodologías eran síntomas y ninguna era cura: el único mecanismo para ser más preciso era también el único mecanismo para ganar en la cascada. Si querías apuntar a una imagen concreta y no a todas, añadías ancestros al selector; y al añadir ancestros, tu regla se volvía más difícil de sobrescribir para todos los demás. Precisión y autoridad eran la misma palanca. Esa es la raíz de todo lo que vino después: BEM existe para poder ser preciso con una sola clase, la especificidad cero de :where() existe para poder ser preciso sin autoridad, la utilidad con !important de los frameworks existe para recuperar la autoridad que se perdió al bajar la precisión, y las guerras de !important existen porque alguien fue preciso antes que tú. @scope desacopla las dos cosas por primera vez: la restricción de ancestros se traslada del selector al contexto de la regla, y ahí es una condición de encaje que no participa en el cómputo. Puedes ser todo lo específico que necesites en la restricción —.feature > .cuerpo > .zona como raíz— y tu regla seguirá pesando lo que pese su selector interno, que puede ser una simple etiqueta. La consecuencia práctica para quien diseña sistemas es enorme: por fin puedes publicar reglas exactas que el consumidor sobrescribe con una clase, sin :where() envolviéndolo todo, sin inventar convenciones de nombres y sin renunciar a apuntar donde quieres. Y la consecuencia mental es que conviene dejar de leer la especificidad como “cuánto encaja este selector” y empezar a leerla como lo que siempre debió ser: cuánta autoridad reclama esta declaración. Son preguntas distintas, y ahora el lenguaje por fin permite responderlas por separado.
- Calcula la especificidad de
py de:scope pdentro de@scope (#app .zona). - Reescribe una regla de tu proyecto que pese
(0,3,0)para que pese(0,0,1)con el mismo alcance. - Comprueba que un selector desnudo no encaja con la propia raíz y arréglalo con
:scope. - Anida un
@containerdentro de un@scopey verifica que la condición se aplica a la raíz. - Escribe un componente completo con
@scope (&)dentro de su propia regla y su límite.