Raíz, límite y el donut scope
Cómo se define el suelo de un ámbito, por qué el techo es inclusivo y el suelo exclusivo, cómo invertir ambos, y qué problema real resuelve el donut que ninguna otra herramienta resolvía.
El techo de un ámbito es la parte fácil: cualquier selector sirve como raíz. Lo que no existía en ningún mecanismo anterior es el suelo: poder decir “estas reglas llegan hasta aquí y ni un nodo más abajo”. Ese agujero en medio del subárbol —el donut scope— es lo que convierte a @scope en la única respuesta decente al problema más viejo y más caro del CSS de contenido: estilar prosa que no controlas sin destrozar lo que otros han metido dentro.
- Escribir un ámbito con raíz y límite y predecir qué elementos quedan dentro.
- Invertir la inclusividad de cualquiera de los dos bordes.
- Usar
:scopedentro del límite para expresar relaciones con la raíz. - Distinguir lo que el límite detiene de lo que sigue pasando por herencia.
El límite inferior
El preludio completo tiene dos partes: la raíz, que marca el techo, y el límite, que marca el suelo.
@scope (.cuerpo-articulo) to (figure) {
img { border: 1px solid; border-radius: 0.5rem; }
}
Con este árbol, la regla aplica a la primera img y no a la segunda:
<section class="cuerpo-articulo">
<p>Texto</p>
<img src="/a.avif" alt=""> <!-- dentro del ambito -->
<figure>
<img src="/b.avif" alt=""> <!-- fuera: el figure es el limite -->
<figcaption>Pie</figcaption>
</figure>
</section>
flowchart TB R[section cuerpo-articulo es la raiz] --> P[p dentro] R --> I1[img dentro] R --> F[figure es el limite y queda fuera] F --> I2[img fuera] F --> C[figcaption fuera] style R fill:#a6e3a1,color:#11111b style P fill:#a6e3a1,color:#11111b style I1 fill:#a6e3a1,color:#11111b style F fill:#f38ba8,color:#11111b style I2 fill:#f38ba8,color:#11111b style C fill:#f38ba8,color:#11111b
Las dos reglas de pertenencia hay que memorizarlas porque son asimétricas: el borde superior es inclusivo y el inferior es exclusivo. La raíz está dentro del ámbito; el elemento que encaja con el límite está fuera, y todos sus descendientes también.
Esa asimetría no es arbitraria: es la que hace que el patrón habitual funcione sin ceremonias. Quieres poder estilar el contenedor —:scope { padding: 1rem }— y quieres que el widget que marca el límite se quede intacto, incluido él mismo.
Cuando necesites la combinación contraria, se invierte con el selector universal como hijo:
| Preludio | Techo | Suelo |
|---|---|---|
@scope (R) to (L) |
inclusivo | exclusivo |
@scope (R) to (L > *) |
inclusivo | inclusivo |
@scope (R > *) to (L) |
exclusivo | exclusivo |
@scope (R > *) to (L > *) |
exclusivo | inclusivo |
Y tanto la raíz como el límite admiten listas de selectores, lo que permite abrir varios ámbitos de una vez y cerrarlos por varios motivos:
@scope (.portada, .cuerpo-articulo) to (figure, .widget, [data-crudo]) {
img { border-radius: 0.5rem; }
a { text-underline-offset: 0.2em; }
}
Para qué sirve el donut de verdad
El caso canónico es el que arruina la vida a todo el que mantiene un sitio con contenido: estilar prosa que viene de un gestor de contenidos o de un editor, sin tocar lo que alguien haya incrustado dentro.
Un artículo de blog necesita reglas generosas: los párrafos con su interlineado, los enlaces subrayados, las imágenes redondeadas, las listas con su sangría. Y dentro de ese artículo alguien pega un reproductor de vídeo, un componente de código con resaltado, una tabla de precios generada por otro equipo. Todas tus reglas de prosa entran ahí y lo rompen.
La solución tradicional era una carrera armamentística de :not() cada vez más largos, o resetear a la fuerza dentro del widget. Con @scope se declara el suelo una vez:
@scope (.prosa) to (.no-prosa) {
p { line-height: 1.7; margin-block: 1em; }
a { text-decoration: underline; }
ul { padding-inline-start: 1.5em; }
img { max-inline-size: 100%; border-radius: 0.5rem; }
:scope { max-inline-size: 68ch; }
}
Cualquier cosa marcada con .no-prosa queda fuera, con sus descendientes. El componente incrustado no necesita defenderse: basta con que lleve la marca.
El segundo caso frecuente son los componentes con ranura. Un componente de layout quiere estilar sus propias partes pero no el contenido que le pasa el consumidor:
/* El panel estila su cabecera y su pie, pero no lo que le metan en el hueco. */
@scope (.panel) to (.panel__contenido > *) {
h2 { font-size: 1.125rem; margin: 0; }
button { padding: 0.25rem 0.5rem; }
}
El límite puede hablar de la raíz
Dentro del selector del límite, :scope se refiere a la raíz del ámbito. Eso permite dos cosas que no son evidentes.
Restringir cuándo algo cuenta como límite. Una figure solo cierra el ámbito si es hija directa de la raíz, no si está anidada más abajo:
@scope (.cuerpo-articulo) to (:scope > figure) {
img { border-radius: 0.5rem; }
}
Hacer que el límite dependa del contexto de la raíz. El límite puede referirse a elementos que están por encima del ámbito:
/* figure solo cierra el ambito cuando la raiz esta dentro de .portada. */
@scope (.cuerpo-articulo) to (.portada :scope figure) {
img { border-radius: 0.5rem; }
}
Lo que no se puede hacer es escaparse del subárbol desde dentro del bloque. :scope + p es inválido dentro de un @scope, porque el hermano de la raíz está fuera del ámbito por definición. Es la misma garantía de contención que viste en el anidamiento, aplicada aquí.
Esto sorprende siempre. Si escribes :scope { color: crimson }, ese color se hereda hacia abajo y atraviesa el límite: el contenido del figure que dejaste fuera del ámbito seguirá siendo carmesí. El límite decide dónde pueden encajar tus selectores, no hasta dónde llegan los valores heredados. Son dos mecanismos distintos de la cascada y @scope solo toca el primero.
Por eso, cuando de verdad quieras aislar valores heredados, el donut hay que acompañarlo de un reinicio explícito en el elemento límite, que vive fuera del ámbito y por tanto tiene que escribirse fuera del bloque:
@scope (.prosa) to (.no-prosa) {
:scope { color: var(--texto-prosa); font-family: var(--fuente-prosa); }
p { line-height: 1.7; }
}
/* Fuera del ambito: el widget recupera los valores del documento. */
.no-prosa {
color: revert;
font-family: revert;
line-height: revert;
}
Que el elemento límite quede fuera del ámbito parece un detalle de especificación y es en realidad una decisión de arquitectura con consecuencias muy concretas. Significa que desde dentro del bloque @scope no puedes estilar el propio límite, y por tanto no puedes escribir ahí el reinicio que lo protege. El reinicio tiene que vivir fuera, lo cual al principio se siente como una molestia y acaba siendo lo correcto: el reinicio pertenece al componente que se protege, no a la hoja de prosa de la que se protege. Compáralo con lo que se hacía antes. La hoja de contenido acumulaba una lista creciente de excepciones —.prosa p:not(.widget p), .prosa a:not(.codigo a)— porque era la única que podía escribirse. Cada componente nuevo que alguien incrustaba obligaba a editar la hoja de prosa, un archivo que nada tenía que ver con ese componente y que nadie quería tocar, y esa hoja se convertía con el tiempo en un registro histórico de todos los widgets que alguna vez rompieron algo. Con el donut, la hoja de prosa se escribe una vez y no vuelve a cambiar: declara su suelo con un contrato —“me detengo en cualquier cosa marcada como .no-prosa”— y cada componente nuevo se acoge a él poniéndose la marca y llevándose su propio reinicio dentro. La dependencia se ha invertido, igual que ocurría con :has() en el nivel siete: en lugar de que el estilo global conozca a cada componente, cada componente conoce el contrato global. Es la diferencia entre una lista de excepciones que crece sin límite y una interfaz que no cambia nunca.
- Monta un
@scopecon límite y comprueba a mano qué elementos quedan dentro y cuáles fuera. - Invierte la inclusividad de los dos bordes y verifica el cambio.
- Escribe un límite que solo cuente cuando el elemento es hijo directo de la raíz.
- Demuestra que un
colorpuesto en:scopeatraviesa el límite y arréglalo. - Coge la hoja de contenido de un proyecto real y cuenta cuántos
:not()desaparecerían con un donut.