wandres.dev
ESTILOS · scoped, global, CSS

El scoping por dentro

El mecanismo que hace posible el aislamiento: el atributo data-astro-cid que Astro genera para cada componente, la reescritura de selectores que lo acompaña, el peldaño de especificidad que introduce y su ajuste con scopedStyleStrategy, y por qué los hijos y el HTML dinámico quedan fuera del ámbito.

⏱ 15 min

En la lección anterior tratamos el aislamiento como una promesa: los estilos de un componente no se derraman. Ahora abrimos la caja para ver cómo se cumple. No hay magia ni Shadow DOM: hay un atributo generado en tiempo de build y una reescritura sistemática de tus selectores. Entender esa maquinaria explica de golpe la especificidad de tus reglas y la posición exacta de las fronteras del scope.

🎯 Al terminar esta lección sabrás
  • Identificar el atributo data-astro-cid que Astro inyecta en cada elemento de un componente.
  • Explicar cómo se reescribe cada selector del bloque para exigir esa marca de ámbito.
  • Medir el efecto sobre la especificidad y ajustarlo con scopedStyleStrategy.
  • Deducir, desde el mecanismo, por qué los componentes hijos y el HTML dinámico quedan fuera.

La marca de ámbito: data-astro-cid

El corazón del sistema es un identificador. Para cada componente .astro que declara estilos, Astro calcula un hash determinista a partir de su identidad —su ruta en el proyecto— y lo usa para construir un atributo con la forma data-astro-cid-<hash>. Durante el build, estampa ese atributo en cada elemento que la plantilla del componente renderiza. El sufijo cid no es casual: significa component id, el sello que dice a qué componente pertenece cada nodo.

<!-- lo que escribiste -->
<article class="tarjeta"><h2 class="titulo">Hola</h2></article>

<!-- lo que Astro emite -->
<article class="tarjeta" data-astro-cid-hbhonbnp>
  <h2 class="titulo" data-astro-cid-hbhonbnp>Hola</h2>
</article>

Que el hash sea determinista importa más de lo que parece: mientras la identidad del componente no cambie, el atributo es idéntico entre builds. Eso mantiene estables las hojas de estilo, favorece la caché del navegador y evita que un cambio trivial invalide artefactos que no se han tocado. El identificador es corto, opaco y reproducible: justo lo que necesita una marca que va a repetirse en cada nodo.

El mismo hash cumple una segunda función: es el pegamento entre el HTML y el CSS. El atributo que se estampa en los nodos y la condición que se añadirá a los selectores comparten exactamente ese identificador, de modo que ambos lados de la transformación —marcado y estilo— hablan del mismo componente sin ambigüedad posible. Esa es la razón de que la marca deba ser única y estable: si dos componentes compartieran hash, sus ámbitos se fundirían y el aislamiento se rompería.

📝
Un atributo, no una clase, y por una razón

Astro marca con un atributo de datos en lugar de con una clase para no contaminar el espacio de class que tú controlas. Tus clases siguen siendo tuyas, legibles y sin sufijos; la marca de ámbito viaja aparte, en un atributo reservado que nunca vas a escribir a mano. Es una separación limpia entre tu semántica y la fontanería del framework.

Selectores reescritos para exigir la marca

El atributo por sí solo no aísla nada; es la mitad de la ecuación. La otra mitad ocurre en el CSS: Astro reescribe cada selector de tu bloque scoped para que, además de lo que ya pedía, exija también la marca de ámbito. Una regla deja de aplicarse a cualquier .titulo del documento y pasa a aplicarse solo a los .titulo que lleven el atributo del componente.

/* lo que escribiste */
.titulo { color: #7c3aed; }
h2 { margin: 0; }

/* lo que Astro compila */
.titulo[data-astro-cid-hbhonbnp] { color: #7c3aed; }
h2[data-astro-cid-hbhonbnp] { margin: 0; }

La reescritura es uniforme: alcanza a los selectores de clase, a los de elemento, a los combinados. Como el atributo solo aparece en los nodos del componente, la conjunción de ambas condiciones —el selector original y la marca— confina la regla al ámbito. Aislamiento por construcción: no se prohíbe que la regla salga, simplemente se le añade una condición que fuera del componente nunca se cumple.

Este detalle explica también por qué el scope no depende del orden de los ficheros ni de la profundidad del árbol: la condición añadida es local a cada componente y no interactúa con las de los demás. Dos componentes con selectores idénticos generan reglas que, aun pareciéndose letra por letra, exigen marcas distintas y por tanto jamás colisionan, independientemente de cuál se cargue antes.

flowchart TD
CID[identidad del componente] --> HASH[hash determinista]
HASH --> ATTR[atributo data-astro-cid en cada elemento]
HASH --> SEL[selectores emparejados con la marca]
ATTR --> MATCH[la regla solo casa donde coinciden ambos]
SEL --> MATCH
MATCH --> OUT[estilo confinado al componente]
style CID fill:#89b4fa,color:#11111b
style OUT fill:#a6e3a1,color:#11111b

El peldaño de especificidad

Emparejar cada selector con un selector de atributo tiene una consecuencia mensurable: la especificidad sube. Un atributo pesa lo mismo que una clase, así que tu .titulo, que valía 0,0,1,0, pasa a valer 0,0,2,0 una vez compilado. En la práctica esto significa que una regla scoped gana, en igualdad de condiciones, a una regla global equivalente. Casi siempre es lo que quieres —que el estilo local del componente prevalezca—, pero conviene saberlo cuando intentes sobrescribir desde fuera y no lo consigas a la primera.

Astro te deja gobernar ese peldaño con la opción scopedStyleStrategy de la configuración, que elige cómo se ancla la marca en el selector:

⚖️

attribute

La estrategia por defecto. Ancla la marca como selector de atributo; añade un nivel de especificidad, como haría una clase extra.

🎯

class

Usa una clase de ámbito en vez de un atributo. También suma un nivel, pero deja el CSS algo más legible en las herramientas.

🪶

where

Envuelve la marca en :where(...), que aporta cero especificidad. Scoped y global empatan y decide el orden de aparición.

// astro.config.mjs
import { defineConfig } from 'astro/config';

export default defineConfig({
  scopedStyleStrategy: 'where', // por defecto 'attribute'; alternativa 'class'
});

Ver la diferencia en el CSS emitido despeja cualquier duda. Estas son las tres formas en que la misma regla puede quedar anclada según la estrategia elegida:

/* attribute (por defecto): suma un nivel, como una clase extra */
.titulo[data-astro-cid-hbhonbnp] { color: #7c3aed; }

/* class: tambien suma un nivel, pero se lee mejor en devtools */
.titulo.astro-hbhonbnp { color: #7c3aed; }

/* where: cero especificidad, el peso se queda en 0,0,1,0 */
.titulo:where([data-astro-cid-hbhonbnp]) { color: #7c3aed; }

Con 'where', un .titulo scoped y otro global quedan a la par y decide el orden de aparición: es la estrategia que elegir cuando necesitas que una hoja global pueda ganarle a un componente sin recurrir a trucos de especificidad.

⚠️
No pelees a golpe de !important con tu propio scope

Si un estilo global no logra sobrescribir a uno scoped, la causa suele ser este peldaño de especificidad, no un bug. La salida elegante no es !important, sino elegir scopedStyleStrategy: 'where' para nivelar el terreno, o subir la especificidad de tu regla global de forma deliberada. Recurrir a !important para ganarle a tu propio framework es tratar un síntoma y sembrar el siguiente conflicto.

Por qué los hijos y el HTML dinámico quedan fuera

Con el mecanismo a la vista, las fronteras de la lección anterior dejan de ser reglas que memorizar y pasan a ser consecuencias que se deducen. Un componente hijo renderiza sus elementos con su propio data-astro-cid, no con el del padre; por tanto, los selectores reescritos del padre —que exigen la marca del padre— jamás casan con los nodos del hijo. No es una prohibición añadida: es aritmética de atributos.

Lo mismo explica el HTML que aparece en runtime. El contenido que inyectas con set:html, o los nodos que crea tu JavaScript en el cliente, nacen después del build y sin la marca de ámbito estampada. Como no llevan el atributo, ningún selector reescrito los alcanza, y tus reglas scoped los ignoran por completo. Para estilar esos casos hará falta salir del ámbito con las herramientas globales que veremos en la siguiente lección.

Visto en el marcado, el contraste entre padre e hijo es inmediato:

<!-- el padre estampa SU marca en sus nodos -->
<section data-astro-cid-PADRE>
  <!-- el hijo estampa la SUYA en los suyos -->
  <h2 data-astro-cid-HIJO>Titulo del hijo</h2>
</section>

El h2 lleva la marca del hijo, no la del padre; por eso el selector reescrito del padre, que exige data-astro-cid-PADRE, nunca casa con él. La frontera entre componentes no es una regla especial que Astro imponga aparte: es la consecuencia aritmética de que cada componente estampa una marca distinta.

ℹ️
La prueba está en las herramientas del navegador

Nada de esto exige fe: es inspeccionable. Abre las herramientas del navegador, selecciona un elemento y verás el atributo data-astro-cid en el marcado y los selectores reescritos en el panel de estilos. Cuando una regla no aplica donde esperabas, ese panel te dice al instante si el elemento lleva la marca correcta o si pertenece a otro ámbito. La maquinaria del scoping es transparente por diseño.

El aislamiento no es una barrera, es una condición añadida

Vale la pena detenerse en la naturaleza del truco, porque revela una filosofía. Astro no construye un muro alrededor del componente ni monta un contexto de estilo separado como hace el Shadow DOM; no aísla prohibiendo, sino condicionando. A cada selector le añade una cláusula —debe existir esta marca— que dentro del componente siempre es verdadera y fuera siempre es falsa. El aislamiento emerge, así, de una tautología cuidadosamente colocada: la regla sigue siendo global en teoría, pero su condición de aplicación solo se satisface en un lugar. Esta elección tiene ecos profundos. Primero, mantiene el CSS resultante como CSS ordinario, legible e inspeccionable en cualquier navegador, sin runtime ni árbol sombra que mantener. Segundo, hace que las fronteras del scope sean deducibles en vez de arbitrarias: no tienes que recordar que los hijos quedan fuera, lo derivas del hecho de que llevan otra marca; no tienes que aprender que el HTML dinámico se escapa, lo entiendes al ver que nace sin marca. Y tercero, explica por qué la especificidad sube exactamente un peldaño y por qué :where() lo anula: la marca es un token de selector más, con su peso y su neutralización conocidos. Cuando comprendes que el aislamiento de Astro es una condición añadida y no una barrera erigida, dejas de tratar su comportamiento como un conjunto de reglas sueltas y empiezas a predecirlo desde un único principio. Esa es la diferencia entre usar una herramienta y entenderla.

⚔️ Radiografía del scope
  1. Compila un componente con estilos y busca en el HTML generado el atributo data-astro-cid; anota su hash y verifica que aparece en cada elemento del componente.
  2. Localiza en el CSS emitido tus selectores reescritos y confirma cómo se ha anclado la marca en cada uno.
  3. Cambia scopedStyleStrategy a 'where', recompila y observa en el CSS cómo desaparece el peldaño de especificidad.
  4. Inyecta un nodo con set:html, inspecciónalo y comprueba que carece de la marca; explica, con el mecanismo, por qué tus reglas scoped no lo tocan.