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

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

Browser Support

  • Chrome: 118.
  • Edge: 118.
  • Firefox: behind a flag.
  • Safari: 17.4.

Source

A arte delicada de escrever seletores de CSS

Ao escrever seletores, você pode se sentir dividido entre dois mundos. Por um lado, você quer ser bastante específico sobre quais elementos selecionar. Por outro lado, você quer que os seletores continuem fáceis de substituir e não sejam acoplados à estrutura do DOM.

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

  • Esse seletor tem uma especificidade de (0,3,1) muito alta, o que dificulta a substituição à medida que o código cresce.
  • Ao depender do combinator filho direto, ele é acoplado à estrutura DOM. Se a marcação mudar, você também vai precisar mudar o CSS.

No entanto, você também não quer escrever apenas img como o seletor para esse elemento, porque isso selecionaria todos os elementos de imagem da página.

Encontrar o equilíbrio certo é um desafio. Ao longo dos anos, alguns desenvolvedores criaram soluções e alternativas para ajudar em situações como essa. 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, permitindo que você seja específico na seleção.
  • Soluções baseadas em JavaScript, como CSS com escopo ou Componentes estilizados, reescrevem todos os seletores adicionando strings geradas aleatoriamente, como sc-596d7e0e-4, para evitar que eles segmentem elementos do outro lado da página.
  • Algumas bibliotecas até mesmo eliminam os seletores e exigem que você coloque os acionadores de estilo diretamente na marcação.

Mas e se você não precisar de nenhuma delas? E se o CSS oferecesse uma maneira de ser bastante específico sobre quais elementos você seleciona, sem precisar escrever seletores de alta especificidade ou que estejam intimamente acoplados ao DOM? É aí que o @scope entra em ação, oferecendo uma maneira de selecionar elementos apenas em um subárvore do DOM.

Apresentação de @scope

Com @scope, você pode limitar o alcance dos seletores. Para fazer isso, defina a raiz de escopo, que determina o limite superior do subárvore que você quer segmentar. Com um conjunto de raiz de escopo, as regras de estilo contidas, chamadas de regras de estilo com escopo, só podem selecionar entre esse subárvore limitada do DOM.

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

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

A regra de estilo com escopo img { … } só pode selecionar elementos <img> que estão no escopo do elemento .card correspondente.

Para evitar que os elementos <img> dentro da á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 a regra @scope 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 só tem como alvo elementos <img> colocados entre elementos .card e .card__content na árvore ancestral. Esse tipo de escopo, com um limite máximo e mínimo, é frequentemente chamado de escopo de rosquinha.

O seletor :scope

Por padrão, todas as regras de estilo com escopo são relativas à raiz de escopo. Também é possível direcionar o próprio elemento raiz de 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 de regras de estilo com escopo recebem :scope na frente de forma implícita. Se quiser, você pode ser explícito, preenchendo :scope. Como alternativa, você pode adicionar o seletor &, usando o aninhamento 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 de 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 de escopo usando :scope. Exemplo:

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

As regras de estilo com escopo não podem escapar do 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 para @scope não afetam a especificidade dos seletores contidos. No exemplo abaixo, a especificidade do seletor img ainda é (0,0,1).

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

A especificidade de :scope é a de uma pseudoclasse regular, ou seja, (0,1,0).

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

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

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

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

Aplicado a este exemplo, a especificidade de :is(#sidebar, .card) é a do argumento mais específico, ou seja, #sidebar, e, portanto, se torna (1,0,0). Combine isso com a especificidade de img, que é (0,0,1), e você 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 de escopo correspondente, enquanto & representa o seletor usado para corresponder à raiz de escopo.

Por isso, é possível usar & várias vezes. Isso é diferente de :scope, que só pode ser usado uma vez, porque não é possível corresponder uma raiz de escopo dentro de outra.

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

Escopo sem prelúdio

Ao escrever estilos inline com o elemento <style>, é possível definir o escopo das regras de estilo para o elemento pai que contém o elemento <style> sem especificar nenhuma raiz de escopo. Para fazer isso, omita o prelúdio do @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 de escopo só segmentam elementos dentro do div com o nome de classe card__header, porque div é o elemento pai do <style>.

@scope na cascata

Dentro da cascata de CSS, @scope também adiciona um novo critério: proximidade de escopo. A etapa vem depois da especificidade, mas antes da ordem de aparição.

Visualização da cascata CSS.

De acordo com a especificação:

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

Essa nova etapa é útil ao aninhar várias 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 um filho de um div com a classe .light aplicada. Isso ocorre devido ao critério de ordem de aparição que a cascata usa para determinar o vencedor. Ele identifica que .dark a foi declarado por último, então ele vai vencer de acordo com a regra .light a

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

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

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

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

Observação final: isolamento do seletor, não do estilo

É importante observar que @scope limita o alcance dos seletores, mas não oferece isolamento de estilo. As propriedades que herdam para as crianças ainda vão herdar, além do limite inferior do @scope. Uma dessas propriedades é a color. Ao declarar esse dentro de um escopo de rosquinha, o color ainda vai herdar para os filhos dentro do buraco da rosquinha.

@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 de capa de rustam burkhanov no Unsplash)