Limitar o alcance dos seletores com o CSS @scope at-rule

Aprenda a usar @scope para selecionar elementos somente em uma subárvore limitada do DOM.

Compatibilidade com navegadores

  • Chrome: 118
  • Borda: 118.
  • Firefox: atrás de uma sinalização.
  • Safari: 17.4.

Origem

A delicada arte de escrever seletores de CSS

Ao escrever seletores, você pode ficar confuso entre dois mundos. Por um lado, convém ser bastante específico sobre quais elementos você seleciona. Por outro lado, você quer que os seletores permaneçam fáceis de modificar e não estejam rigidamente associados à estrutura do DOM.

Por exemplo, quando você quiser selecionar "a imagem principal na área de conteúdo do componente do card", que é uma seleção de elementos bastante específica, provavelmente não vai querer usar um seletor como .card > .content > img.hero.

  • Esse seletor tem uma especificidade bastante alta de (0,3,1), o que dificulta a substituição à medida que o código aumenta.
  • Ao contar com o combinador filho direto, ele está rigidamente acoplado à estrutura DOM. Caso a marcação mude, você também precisará alterar o CSS.

No entanto, também não é recomendável escrever apenas img como o seletor desse elemento, já que isso selecionaria todos os elementos de imagem na página.

Encontrar o equilíbrio certo nisso é geralmente o desafio. Ao longo dos anos, alguns desenvolvedores criaram soluções e soluções alternativas para ajudar você em situações como essas. Exemplo:

  • Metodologias como o BEM determinam que você atribua a esse elemento uma classe de card__img card__img--hero para manter a especificidade baixa e, ao mesmo tempo, permitir que você escolha algo específico.
  • As soluções baseadas em JavaScript, como CSS com escopo ou Componentes estilizados, reescrevem todos os seletores adicionando strings geradas aleatoriamente, como sc-596d7e0e-4, a eles para evitar que eles segmentem elementos no outro lado da página.
  • Algumas bibliotecas até mesmo eliminam completamente os seletores e exigem que você coloque os acionadores de estilo diretamente na própria marcação.

Mas e se você não precisar de nada disso? E se o CSS oferecesse uma maneira de ser bastante específico sobre quais elementos você seleciona, sem a necessidade de criar seletores de alta especificidade ou que estejam fortemente acoplados ao seu DOM? É aí que o @scope entra em jogo, oferecendo uma maneira de selecionar elementos somente dentro de uma subárvore do DOM.

Conheça o @scope

Com @scope, você pode limitar o alcance dos seus seletores. Para isso, configure a raiz do escopo, que determina o limite superior da subárvore que você quer segmentar. Com um conjunto raiz de escopo, as regras de estilo contidas nele, chamadas de regras de estilo com escopo, só podem fazer a seleção nessa subárvore limitada do DOM.

Por exemplo, para segmentar apenas os elementos <img> no componente .card, defina .card como a raiz do escopo da regra @scope.

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

A regra de estilo com escopo img { … } seleciona apenas elementos <img> que estejam no escopo do elemento .card correspondente.

Para evitar que os elementos <img> na área de conteúdo do card (.card__content) sejam selecionados, você pode tornar o seletor img mais específico. Outra maneira de fazer isso é usar o fato de que @scope em regra também aceita um limite de escopo, que determina o limite inferior.

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

Essa regra de estilo com escopo segmenta apenas elementos <img> colocados entre elementos .card e .card__content na árvore ancestral. Esse tipo de escopo, com limites superior e inferior, é geralmente chamado de escopo de donut.

(link em inglês)

O seletor :scope

Por padrão, todas as regras de estilo com escopo são relativas à raiz do escopo. Também é possível direcionar para o próprio elemento raiz do escopo. Para isso, use o seletor :scope.

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

Os seletores dentro das regras de estilo com escopo recebem implicitamente :scope como prefixo. Se quiser, inclua :scope no início e seja explícito. Como alternativa, você pode anexar o seletor & no início de Transição de CSS.

@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 */
    }
}

Um limite de escopo pode usar a pseudoclasse :scope para exigir uma relação específica com a raiz do escopo:

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

Um limite de escopo também pode referenciar elementos fora da raiz do escopo usando :scope. Exemplo:

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

Observe que as regras de estilo com escopo não podem escapar da subárvore. Seleções como :scope + p são inválidas porque tentam selecionar elementos que não estão no escopo.

@scope e especificidade

Os seletores usados no prelúdio de @scope não afetam a especificidade deles. No exemplo abaixo, a especificidade do seletor img ainda é (0,0,1).

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

A especificidade de :scope é uma pseudoclasse regular, mais especificamente (0,1,0).

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

No exemplo a seguir, internamente, o & é reescrito no seletor usado para a raiz do escopo, agrupado em um seletor :is(). No final, o navegador vai usar :is(#sidebar, .card) img como seletor para fazer a correspondência. Esse processo é conhecido como simplificação.

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

Como a & é simplificada usando :is(), a especificidade de & é calculada seguindo as regras de especificidade de :is(): a especificidade de & é a do argumento mais específico.

Aplicada a esse exemplo, a especificidade de :is(#sidebar, .card) é a do argumento mais específico, ou seja, #sidebar. Portanto, se torna (1,0,0). Combine isso com a especificidade de img, que é (0,0,1), e você vai ter (1,0,1) como a especificidade de todo o seletor complexo.

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

A diferença entre :scope e & dentro de @scope

Além das diferenças na forma como a especificidade é calculada, outra diferença entre :scope e & é que :scope representa a raiz do escopo correspondente, e & representa o seletor usado para corresponder à raiz do escopo.

Por isso, é possível usar & várias vezes. Isso é diferente de :scope, que você pode usar apenas uma vez, já que não é possível corresponder uma raiz de escopo dentro de uma raiz de escopo.

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

Escopo sem início

Ao escrever estilos in-line com o elemento <style>, é possível definir o escopo das regras de estilo para o elemento pai associado ao elemento <style> sem especificar nenhuma raiz de escopo. Para fazer isso, omita o prelúdio 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>

No exemplo acima, as regras com escopo segmentam apenas elementos dentro da div com o nome de classe card__header, porque essa div é o elemento pai do elemento <style>.

@scope na cascata

Dentro da Cascade CSS, @scope também adiciona um novo critério: proximidade do escopo. A etapa vem após a especificidade, mas antes da ordem de aparecimento.

Visualização da Cascade do CSS.

De acordo com a especificação:

Ao comparar declarações que aparecem em regras de estilo com diferentes raízes de escopo, vence a declaração com o menor número de saltos entre a raiz do escopo e o assunto da regra de estilo com escopo.

Essa nova etapa é útil ao aninhar diversas variações de um componente. Confira este exemplo, que ainda não 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>

Ao visualizar essa pequena marcação, o terceiro link será white em vez de black, mesmo que seja filho de um div com a classe .light aplicada a ele. Isso se deve à ordem de exibição do critério que a cascata usa aqui para determinar o vencedor. Ele vê que .dark a foi declarado por último, então vencerá da regra .light a.

(link em inglês)

Com o critério de proximidade do escopo, isso é resolvido:

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

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

Como os dois seletores a no escopo têm a mesma especificidade, o critério de proximidade do escopo entra em ação. Ele pesa os dois seletores por proximidade com a raiz de escopo. Para esse terceiro elemento a, é apenas um salto para a raiz do escopo .light, mas dois para o .dark. Portanto, o seletor a no .light vence.

Nota final: isolamento do seletor, não do isolamento de estilo

Uma observação importante a ser feita é que @scope limita o alcance dos seletores, não oferece isolamento de estilo. As propriedades herdadas para filhos ainda serão herdadas, além do limite inferior de @scope. Uma dessas propriedades é a color. Ao declarar isso dentro de um escopo de rosca, o color ainda vai herdar para filhos dentro do buraco do donut.

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

No exemplo acima, o elemento .card__content e os filhos dele têm uma cor hotpink porque herdam o valor de .card.

(Foto da capa de rustam burkhanov no Unsplash)