Limita el alcance de tus selectores con el permiso at-rule de CSS @scope.

Aprende a usar @scope para seleccionar elementos solo dentro de un subárbol limitado de tu DOM.

Fecha de publicación: 4 de octubre de 2023

Browser Support

  • Chrome: 118.
  • Edge: 118.
  • Firefox: 146.
  • Safari: 17.4.

Source

Cuando escribas selectores, es posible que te encuentres dividido entre dos mundos. Por un lado, debes ser bastante específico sobre los elementos que seleccionas. Por otro lado, quieres que tus selectores sigan siendo fáciles de anular y que no estén estrechamente vinculados a la estructura del DOM.

Por ejemplo, cuando deseas seleccionar "la imagen principal en el área de contenido del componente de tarjeta", que es una selección de elementos bastante específica, es muy probable que no desees escribir un selector como .card > .content > img.hero.

  • Este selector tiene una especificidad bastante alta de (0,3,1), lo que dificulta la anulación a medida que crece tu código.
  • Al depender del combinador de elementos secundarios directos, se vincula estrechamente a la estructura del DOM. Si alguna vez cambia el lenguaje de marcado, también deberás cambiar tu CSS.

Sin embargo, tampoco querrás escribir solo img como selector para ese elemento, ya que eso seleccionaría todos los elementos de imagen de tu página.

Encontrar el equilibrio adecuado en este sentido suele ser un gran desafío. Con el paso de los años, algunos desarrolladores crearon soluciones y alternativas para ayudarte en situaciones como estas. Por ejemplo:

  • Las metodologías como BEM dictan que le des a ese elemento una clase de card__img card__img--hero para mantener la especificidad baja y, al mismo tiempo, permitirte ser específico en lo que seleccionas.
  • Las soluciones basadas en JavaScript, como CSS con alcance o Componentes con diseño, reescriben todos tus selectores agregándoles cadenas generadas de forma aleatoria, como sc-596d7e0e-4, para evitar que segmenten elementos en el otro lado de tu página.
  • Algunas bibliotecas incluso eliminan los selectores por completo y requieren que coloques los activadores de diseño directamente en el propio lenguaje de marcado.

Pero ¿qué pasaría si no necesitaras ninguna de esas cosas? ¿Qué pasaría si CSS te permitiera ser bastante específico sobre qué elementos seleccionar, sin necesidad de escribir selectores de alta especificidad o que estén estrechamente vinculados a tu DOM? Ahí es donde entra en juego @scope, que te ofrece una forma de seleccionar elementos solo dentro de un subárbol de tu DOM.

Presentamos @scope

Con @scope, puedes limitar el alcance de tus selectores. Para ello, establece la raíz del alcance, que determina el límite superior del subárbol al que deseas segmentar. Con una raíz de alcance establecida, las reglas de diseño contenidas (denominadas reglas de diseño con alcance) solo pueden seleccionar desde ese subárbol limitado del DOM.

Por ejemplo, para segmentar solo los elementos <img> en el componente .card, debes establecer .card como la raíz del alcance de la regla @@scope.

@scope (.card) {
    img {
        border-color: green;
    }
}

La regla de estilo con alcance img { … } solo puede seleccionar de manera eficaz los elementos <img> que están dentro del alcance del elemento .card coincidente.

Para evitar que se seleccionen los elementos <img> dentro del área de contenido de la tarjeta (.card__content), puedes hacer que el selector img sea más específico. Otra forma de hacerlo es usar el hecho de que la regla @@scope también acepta un límite de alcance que determina el límite inferior.

@scope (.card) to (.card__content) {
    img {
        border-color: green;
    }
}

Esta regla de estilo con alcance solo se aplica a los elementos <img> que se colocan entre los elementos .card y .card__content en el árbol de elementos superiores. Este tipo de alcance, con un límite superior y uno inferior, suele denominarse alcance de donut.

El selector de :scope

De forma predeterminada, todas las reglas de diseño con alcance son relativas a la raíz del alcance. También es posible segmentar el elemento raíz de alcance en sí. Para ello, usa el selector :scope.

@scope (.card) {
    :scope {
        /* Selects the matched .card itself */
    }
    img {
       /* Selects img elements that are a child of .card */
    }
}

Los selectores dentro de las reglas de diseño con alcance obtienen :scope antepuesto de forma implícita. Si quieres, puedes ser explícito al respecto agregando :scope por tu cuenta. Como alternativa, puedes anteponer el selector &, desde CSS Nesting.

@scope (.card) {
    img {
       /* Selects img elements that are a child of .card */
    }
    :scope img {
        /* Also selects img elements that are a child of .card */
    }
    & img {
        /* Also selects img elements that are a child of .card */
    }
}

Un límite de alcance puede usar la seudoclase :scope para requerir una relación específica con la raíz del alcance:

/* .content is only a limit when it is a direct child of the :scope */
@scope (.media-object) to (:scope > .content) { ... }

Un límite de alcance también puede hacer referencia a elementos fuera de su raíz de alcance con :scope. Por ejemplo:

/* .content is only a limit when the :scope is inside .sidebar */
@scope (.media-object) to (.sidebar :scope .content) { ... }

Las reglas de estilo con alcance no pueden escapar del subárbol. Las selecciones como :scope + p no son válidas porque intentan seleccionar elementos que no están dentro del alcance.

@scope y especificidad

Los selectores que usas en el preámbulo de @scope no afectan la especificidad de los selectores incluidos. En nuestro ejemplo, la especificidad del selector img sigue siendo (0,0,1).

@scope (#sidebar) {
  img { /* Specificity = (0,0,1) */
    ...
  }
}

La especificidad de :scope es la de una seudoclase normal, es decir, (0,1,0).

@scope (#sidebar) {
  :scope img { /* Specificity = (0,1,0) + (0,0,1) = (0,1,1) */
    ...
  }
}

En el siguiente ejemplo, internamente, el & se reescribe en el selector que se usa para la raíz del alcance, incluido dentro de un selector :is(). Al final, el navegador usará :is(#sidebar, .card) img como selector para realizar la coincidencia. Este proceso se conoce como desazucarado.

@scope (#sidebar, .card) {
    & img { /* desugars to `:is(#sidebar, .card) img` */
        ...
    }
}

Dado que & se desazucara con :is(), la especificidad de & se calcula según las reglas de especificidad de :is(): la especificidad de & es la de su argumento más específico.

Aplicada a este ejemplo, la especificidad de :is(#sidebar, .card) es la de su argumento más específico, es decir, #sidebar, y, por lo tanto, se convierte en (1,0,0). Si combinas eso con la especificidad de img, que es (0,0,1), obtendrás (1,0,1) como la especificidad de todo el selector complejo.

@scope (#sidebar, .card) {
  & img { /* Specificity = (1,0,0) + (0,0,1) = (1,0,1) */
    ...
  }
}

La diferencia entre :scope y & dentro de @scope

Además de las diferencias en cómo se calcula la especificidad, otra diferencia entre :scope y & es que :scope representa la raíz de alcance coincidente, mientras que & representa el selector que se usó para hacer coincidir la raíz de alcance.

Por este motivo, es posible usar & varias veces. Esto contrasta con :scope, que solo puedes usar una vez, ya que no puedes hacer coincidir una raíz de alcance dentro de otra raíz de alcance.

@scope (.card) {
  & & { /* Selects a `.card` in the matched root .card */
  }
  :scope :scope { /* ❌ Does not work */
    
  }
}

Alcance sin Prelude

Cuando escribes estilos intercalados con el elemento <style>, puedes definir el alcance de las reglas de diseño para el elemento principal envolvente de <style> sin especificar ninguna raíz de alcance. Para ello, omite el preludio de @scope.

<div class="card">
  <div class="card__header">
    <style>
      @scope {
        img {
          border-color: green;
        }
      }
    </style>
    <h1>Card Title</h1>
    <img src="…" height="32" class="hero">
  </div>
  <div class="card__content">
    <p><img src="…" height="32"></p>
  </div>
</div>

En el ejemplo anterior, las reglas con alcance solo se dirigen a los elementos dentro del div con el nombre de clase card__header, porque ese div es el elemento principal del elemento <style>.

@scope en la cascada

Dentro de la cascada de CSS, @scope también agrega un criterio nuevo: la proximidad del alcance. Este paso se realiza después de la especificidad, pero antes del orden de aparición.

Visualización de la cascada de CSS.

Según las especificaciones:

Cuando se comparan declaraciones que aparecen en reglas de diseño con diferentes raíces de alcance, gana la declaración con la menor cantidad de saltos generacionales o de elementos hermanos entre la raíz de alcance y el sujeto de la regla de diseño con alcance.

Este nuevo paso es útil cuando se anidan varias variaciones de un componente. Veamos este ejemplo, que aún no usa @scope:

<style>
    .light { background: #ccc; }
    .dark  { background: #333; }
    .light a { color: black; }
    .dark a { color: white; }
</style>
<div class="light">
    <p><a href="#">What color am I?</a></p>
    <div class="dark">
        <p><a href="#">What about me?</a></p>
        <div class="light">
            <p><a href="#">Am I the same as the first?</a></p>
        </div>
    </div>
</div>

Cuando veas esa pequeña parte del lenguaje de marcado, el tercer vínculo será white en lugar de black, aunque sea un elemento secundario de un div con la clase .light aplicada. Esto se debe al criterio de orden de aparición que usa la cascada aquí para determinar el ganador. Ve que .dark a se declaró en último lugar, por lo que ganará la regla .light a.

Con el criterio de proximidad del alcance, esto ya no es un problema:

@scope (.light) {
    :scope { background: #ccc; }
    a { color: black;}
}

@scope (.dark) {
    :scope { background: #333; }
    a { color: white; }
}

Debido a que ambos selectores a con alcance tienen la misma especificidad, se activa el criterio de proximidad del alcance. Pondera ambos selectores según la proximidad a su raíz de alcance. Para ese tercer elemento a, solo hay un salto a la raíz de alcance .light, pero dos a la raíz de alcance .dark. Por lo tanto, ganará el selector a en .light.

Aislamiento del selector, no del estilo

Ten en cuenta que @scope limita el alcance de los selectores. No ofrece aislamiento de estilo. Las propiedades que se heredan hacia los elementos secundarios siguen heredándose más allá del límite inferior de @scope. Una de esas propiedades es la de color. Cuando se declara uno dentro de un alcance de donut, el color sigue heredándose hacia los elementos secundarios dentro del agujero del donut.

@scope (.card) to (.card__content) {
  :scope {
    color: hotpink;
  }
}

En el ejemplo, el elemento .card__content y sus elementos secundarios tienen un color hotpink porque heredan el valor de .card.