wandres.dev
ÁMBITO DE ESTILOS · @scope, raíz y límite

: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.

⏱ 16 min

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.

🎯 Al terminar esta lección sabrás
  • Describir qué antepone el motor a cada selector dentro del bloque.
  • Usar :scope para 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 @scope sin 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 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.

⚠️
El ampersand dentro de @scope todavía no es estable

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.

Precisión y peso dejaron de ser la misma cosa

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.

⚔️ Pesa el ámbito
  1. Calcula la especificidad de p y de :scope p dentro de @scope (#app .zona).
  2. Reescribe una regla de tu proyecto que pese (0,3,0) para que pese (0,0,1) con el mismo alcance.
  3. Comprueba que un selector desnudo no encaja con la propia raíz y arréglalo con :scope.
  4. Anida un @container dentro de un @scope y verifica que la condición se aplica a la raíz.
  5. Escribe un componente completo con @scope (&) dentro de su propia regla y su límite.