Novo Livro!
Categorias
Ferramentas
Projetos
CSS

A melhor forma de usar ícones em interfaces web

A melhor forma de usar ícones em interfaces web

Dá para usar CSS para inserir e trocar ícones em interfaces web sem tocar no HTML, e o resultado é mais flexível e mais fácil de manter do que o SVG inline que a maioria dos projetos usa hoje.

A seguir, o sistema completo: ícones como design tokens, inseridos por pseudo-elementos e mask, ativados por style queries e com contornos para três bugs do Safari. Com pressa? Pule para a demonstração.

A autora, Lea Verou, sempre teve um fraco por ícones1. Além de dar acabamento à interface, eles melhoram a usabilidade de forma concreta. Ícones conhecidos ajudam as pessoas a achar o que procuram mais rápido, porque reconhecemos imagens muito mais depressa do que lemos palavras.

E, para quem pensa como arquiteta da web, as formas dominantes de incluir ícones hoje são desanimadoras.

Há muitas maneiras de colocar ícones numa interface web, cada uma com características de desempenho diferentes, mas as mais comuns, de longe, envolvem colocar SVG inline no HTML. Um ícone de lixeira, por exemplo, poderia entrar assim:

<button>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash preview-icon"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Excluir
</button>

Demonstração: botão Excluir com o ícone de lixeira em SVG inline

Há quem use um arquivo SVG separado, por sanidade, e inclua os ícones com <use>, mas a marcação continua bem desajeitada (sem falar que é lenta sem bundler).

Todo esse desajeito leva as pessoas a esconder o ícone sob camadas de abstração, como custom elements dedicados do tipo <x-icon name="trash"></x-icon> ou componentes React como <TrashIcon />.

Só que a feiura continua sendo enviada por baixo dessas camadas todas, e o navegador continua tendo de lidar com ela.

As fontes de ícones colocaram os ícones no CSS (::before { content: "\f1f8" } ainda é como funciona o modo pseudo-elemento do Font Awesome), e boa parte do apelo delas era exatamente isso. Mas elas vinham com uma longa lista de problemas:

  • downloads grandes (mesmo com subsetting);
  • glifos de fallback quando a fonte não carrega;
  • leitores de tela anunciando codepoints de uso privado;
  • fontes escolhidas pela pessoa (por exemplo, para dislexia) apagando os ícones;
  • hinting de texto, que nunca foi pensado para figuras.

Jogaram fora o bebê junto com a água do banho: o problema era a fonte, não o CSS.

Muitas das tentativas de levar isso para o CSS ainda dependem de marcação. O truque da máscara em que a maioria se apoia também não é novo: Chen Hui Jing tratou dele em 2021, e o Iconify gera CSS baseado em máscara para o catálogo inteiro.

Mas, em todos esses casos, qual ícone usar (e se haverá ícone) ainda é decidido na marcação, com uma classe por ícone; o CSS só cuida da cor.

O mais próximo disso é o post de 2024 de David Bushell sobre incluir ícones só com CSS, usando conteúdo gerado e máscaras. Ali o ícone é escolhido no CSS, mas cada seletor que quiser ter ícone precisa do próprio bloco de CSS, e não há como trocar o ícone caso a caso.

Ainda assim, é uma solução superior ao SVG inline no HTML, e é difícil entender por que ela não é mais popular.

Um sistema de ícones centrado em quem usa

Segundo os princípios do artigo da autora sobre esforço do usuário, um bom sistema de ícones deve:

  1. manter a sintaxe perto do mínimo necessário para declarar a intenção, e
  2. tornar as coisas simples fáceis e as complexas possíveis, com uma transição suave de uma para a outra (sem precipícios de usabilidade)

Como isso se traduz num sistema de ícones?

  • Alta relação sinal-ruído: a intenção de adicionar ou trocar um ícone é basicamente “ícone” mais o nome dele e, talvez, a posição (início, fim, marcador etc.). Todo o resto é boilerplate. Um pouco de boilerplate é inevitável, mas a maioria das soluções com <svg> traz uma quantidade inaceitável (e é por isso que acabamos abstraindo tudo isso).
  • Coisas simples devem ser fáceis:
    • Deve ser possível definir ícones padrão para certos casos sem incluí-los explicitamente em cada ponto de uso. Um <button class="delete">, por exemplo, deveria ganhar o ícone de lixeira automaticamente, sem precisar declará-lo na marcação de forma alguma, a menos que se queira trocá-lo.
    • A cor padrão do ícone deve seguir a cor do texto local (ou a cor definida para ícones, se for outra) sem nenhum malabarismo extra.
  • Coisas complexas devem ser possíveis:
    • Deve ser fácil trocar o ícone padrão de um componente, de vez ou em função de condições (mudanças de estado, interação etc.).
    • Estilizar o ícone deve ser tão simples quanto aplicar uma propriedade CSS. O mínimo é a cor, mas quanto mais estilização for possível, melhor.
  • Sem precipícios de usabilidade: trocar um ícone, temporariamente ou de vez, não deve exigir muito mais do que a informação necessária para declarar a intenção.

Pelo princípio de quem consome acima de quem produz, tornar o uso dos ícones ergonômico importa mais do que tornar a definição ergonômica. Mas, idealmente, acrescentar um ícone novo ao sistema não deveria exigir muito mais do que:

  • declarar o nome do ícone, e
  • fornecer os dados do path SVG ou a URL de um arquivo SVG

E, claro, ainda precisa ter um desempenho razoável. Os critérios de sucesso que já existiam não desaparecem!

As formas dominantes de incluir ícones hoje falham em pelo menos um desses critérios. Dá para fazer melhor?

Por que CSS?

O CSS é a única linguagem da plataforma web que permite agregação reativa: você declara o que se aplica em quais condições (tipo de elemento, estado de interação, media query etc.), e o navegador aplica os estilos certos quando essas condições valem e os remove de forma limpa quando deixam de valer.

Isso acontece sem afetar nenhuma outra condição que se aplique ao elemento ao mesmo tempo, e sem obrigar quem desenvolve a gerenciar esses efeitos colaterais na mão.

Na opinião da autora, essa linguagem maravilhosa é subutilizada, porque fica artificialmente restrita a estilização, mas isso é assunto para outro artigo.

Se os ícones funcionarem bem pelo CSS, isso dá um poder expressivo enorme, praticamente sem perda de flexibilidade.

Imagine poder ajustar ícones com base em…

  • Qualquer seletor, incluindo pseudo-classes de interação (:hover, :focus etc.), por exemplo para comunicar mais informação sobre a ação ou se adaptar a outra cor de fundo
  • Media queries (print, dark mode, alto contraste etc.), por exemplo para usar outros ícones no dark mode ou acrescentá-los e removê-los na impressão
  • Container queries
  • Modo de escrita, por exemplo para usar outro ícone em texto RTL

Além disso, se os ícones podem ser inseridos ou ajustados pelo CSS, dá para defini-los em pseudo-elementos como details::marker, select::picker-icon ou option::checkmark, algo que nenhuma solução de marcação acomoda.

Ou, ainda, imagine aplicar conjuntos de ícones totalmente diferentes à página inteira (ou a uma parte dela) sem tocar na marcação! Uma seção com estética desenhada à mão, por exemplo, poderia ganhar um conjunto de ícones desenhados à mão sem afetar o resto da interface.

As possibilidades são infinitas. Depois que você se permite considerar que isso é possível, passa a ver casos de uso em todo lugar.

Ícones devem ser design tokens

Incluir ícones como <svg> diretos (principalmente sem uma abstração em volta que gerencie um registro compartilhado) significa que não há como garantir consistência: vale tudo. Uma página pode usar a lixeira do Font Awesome, e outra, a lixeira do Lucide.

A proposta é que os próprios ícones sejam um tipo de design token, gerenciado pelo design system, que os componentes de interface simplesmente usam, como fazem com cores e gradientes.

Gerenciar ícones pelo CSS também permite defini-los junto com os outros design tokens (por exemplo, como variáveis CSS no :root), em vez de num lugar separado.

Como isso fica?

Suponha que os ícones fossem variáveis CSS e que desse para acrescentar um ícone a um elemento só definindo uma custom property --icon, com algo como

<button style="--icon: var(--icon-trash)">Excluir</button>

Isso também deixaria as coisas simples fáceis:

button.delete {
--icon: var(--icon-trash);
}

mantendo a possibilidade de trocar o ícone caso a caso, simplesmente sobrescrevendo a propriedade --icon.

Dá até para reagir a estados:

button.delete {
--icon: var(--icon-trash-outlined);
@media (prefers-color-scheme: dark) {
--icon: var(--icon-trash-filled);
}
&:hover {
background: var(--color-danger);
/* Veja https://lea.verou.me/blog/2024/contrast-color/
para fazer a cor se adaptar automaticamente */
color: var(--color-danger-text);
}
}
a[href^="https://"] {
@media print {
--icon-end: var(--icon-link-external);
}
}

ou à interação, por exemplo para comunicar tanto para onde um link leva (GitHub) quanto o que ele é (um link externo):

a.github {
--icon: var(--icon-github);
&:hover {
--icon: var(--icon-link-external);
}
}

E, para ícones avulsos e totalmente pontuais, em que escrever CSS daria mais trabalho do que resolver inline no HTML, pode haver uma classe .icon:

<i class="icon" style="--icon: var(--icon-trash)"></i>

Isso torna a abordagem estritamente mais poderosa do que as baseadas em marcação.

A única exceção é que o SVG totalmente inline permite, em tese, alterar o próprio SVG de formas arbitrárias (por exemplo, transformá-lo em outro ícone ou mudá-lo de acordo com o que a pessoa digita). Para esses casos, o SVG inline continua necessário. Mas quantos dos seus ícones caem nessa categoria?

Como funciona

A ideia central pode ser resumida assim:

  • Os ícones são design tokens de primeira classe, guardados em propriedades --icon-[nome] que contêm valores <image> (embutidos ou remotos)
  • Os ícones são inseridos por pseudo-elementos.
  • O ícone é aplicado como máscara a um quadrado com background igual à cor do ícone.
  • O sistema usa custom properties genéricas como --icon, --icon-start e --icon-end, por flexibilidade e reuso.
  • Style queries e @property fazem a inserção condicional do ícone com base nessas custom properties.

Renderizando e referenciando ícones

Os ícones são inseridos por pseudo-elementos, que usam o ícone para mascarar um quadrado com background igual à cor do ícone.

A sintaxe de referência adotada é esta:

  • Propriedades de baixo nível --icon-[posição]:
    • --icon-start para pôr um ícone antes do conteúdo do elemento (via ::before)
    • --icon-end para pôr um ícone depois do conteúdo do elemento (via ::after)
    • Opcionalmente, outras propriedades podem redirecionar para outros pseudo-elementos, como --icon-marker para ::marker
  • --icon, uma abstração de nível mais alto que significa apenas “ponha um ícone neste componente”: os componentes2 podem redirecioná-la para qualquer uma das propriedades --icon-[posição] de baixo nível, no próprio elemento ou num descendente, conforme fizer sentido para cada componente. Por exemplo, --icon pode definir --icon-start num <button>, mas --icon-marker num <summary>

Dá para ir além e definir também propriedades --icon-[estado], como --icon-hover, --icon-focus etc., se forem úteis! A beleza dessa abordagem é a flexibilidade imensa.

Os ícones disponíveis são definidos no :root como variáveis CSS, por exemplo:

:root {
/* Embutido, por desempenho */
--icon-trash: url("data:image/svg+xml,<svg viewBox='0 0 448 512' xmlns='http://www.w3.org/2000/svg'><path d="…"></svg>");
/* SVGs remotos também funcionam! */
--icon-link-external: url("icons/link-external.svg");
/* mais ícones aqui */
}

Depois, para exibir esses valores como ícones, o essencial é:

  • Um pseudo-elemento vazio, como ::before ou ::after, é usado para exibir o ícone
  • O pseudo-elemento é pintado como um quadrado de 1em com background: currentColor
  • Uma mask do CSS recorta o quadrado no formato do ícone (Mas, mas, mas… e o suporte dos navegadores?!? Calma! Máscaras já são Baseline amplamente disponível hoje em dia)
  • Uma propriedade CSS (--icon-start ou --icon-end) indica o ícone a exibir, em vez de uma propriedade improvisada como --arrow. Isso permite definir o ícone de qualquer elemento a partir de qualquer lugar que aceite declarações, inclusive estilos inline
::before {
content: "";
display: inline-block;
width: 1em;
height: 1em;
background-color: currentColor;
mask: var(--icon-start) no-repeat center / contain;
}

Infelizmente, é preciso repetir o conteúdo do pseudo-elemento para cada tipo de pseudo-elemento que deve aceitar ícones. Os mixins vão resolver isso um dia.

Restringindo aos elementos que realmente têm ícone

Do jeito descrito acima, isso valeria para todos os elementos, o que está longe do ideal!

Dá para usar uma style query (finalmente Baseline! 🎉; o blog tem uma introdução às style queries) para restringir a regra aos elementos com a propriedade --icon-start definida:

@container style(--icon-start) {
::before {
/* igual ao anterior */
}
}

Também é preciso registrar --icon-start como não herdável via @property. Do contrário, todo container com ícone faria brotar um ícone em cada descendente (inclusive na formatação inline, como <strong>, <em> etc.!), o que definitivamente não é o que se quer.

@property --icon-start {
syntax: '*';
inherits: false;
}

E então basta herdá-las explicitamente no pseudo-elemento:

@container style(--icon-start) {
::before {
--icon-start: inherit;
/* igual ao anterior */
}
}

Mas dá mesmo para depender de style queries?

As style queries são Baseline 2026, o que soa bem recente. Mas, olhando a participação de mercado, elas funcionam em ~93% dos navegadores do mundo, segundo os dados do Can I Use.

Esses dados sub-representam o Chrome Android antigo (todas as versões entram como a mais recente). Mas o Chrome lançou as style queries em 2023 (na versão 111), e quem usa Chrome no celular parece atualizar bem rápido, então o número real não deve ser muito menor.

Como isso é possível? Ordenando por data, dá para ver que todos os grandes navegadores, exceto o Firefox, já tinham lançado as style queries em 2024. O Firefox foi o último, em maio de 2026.

Tabela do Can I Use com o suporte a style queries ordenado por data: Chrome, Edge, Safari e Opera com suporte desde 2024, e o Firefox só a partir da versão 151.

Agora, veja o gráfico relativo ao uso:

Tabela do Can I Use com o suporte a style queries proporcional à participação de mercado de cada versão de navegador.

O Firefox como um todo responde por 2,28% da participação de mercado mundial.

Isso não quer dizer ignorar o Firefox por causa da baixa participação! Seria uma atitude canalha, comparável a fazer interfaces inacessíveis porque quem usa leitor de tela é minoria.

Algumas coisas importam mais do que os números brutos sugerem, e não excluir o Firefox é essencial para que a web continue aberta, interoperável e fora do controle exclusivo de grandes empresas.

Feita a ressalva, para recursos que já chegaram a todos os grandes navegadores, é defensável levar a participação de mercado em conta. Os níveis do Baseline são uma ótima… base, mas nenhuma métrica compacta consegue transmitir esse nível de detalhe.

Especialmente num caso como este, em que as coisas em geral continuam funcionando sem os ícones, é uma estratégia plausível tratar os ícones como progressive enhancement e simplesmente aceitar que eles não apareçam nos 7% de navegadores sem suporte.

Para os casos em que o ícone é crucial (por exemplo, controles só com ícone), dá para usar uma classe (como .icon, .icon-start, .icon-end etc.), para que navegadores antigos sem style queries também os mostrem.

A partir daí, o quanto apostar no progressive enhancement depende do quanto o sistema se apoia na classe ou na style query.

Dá até para acrescentar outros seletores conhecidos, como estilos inline ou seletores que já se sabe que têm ícone:

.icon-start,
[style*="--icon-start:"],
button.delete {
&::before {
/* ... */
}
}

Um problema da abordagem com classe é que ela fixa a presença do ícone, mesmo que o ícone em si ainda possa ser ajustado por custom properties.

Uma forma de lidar com isso é definir um valor de content que se torna IACVT (inválido no momento do valor computado) se a custom property não tiver valor, o que faz o pseudo-elemento inteiro sumir:

.icon-start::before {
content: image-set(
linear-gradient(transparent 0 100%),
var(--icon-start)
);
/* ... */
}

Assim, content resolve para uma imagem transparente se --icon-start estiver definida, e para unset → none se não estiver, o que efetivamente remove o pseudo-elemento. E sim, o image-set() é Baseline amplamente disponível.

Isso não remove nenhuma das outras propriedades, então continua não se combinando com outros estilos de ::before. Não aplique de forma tão ampla que possa entrar em conflito com outros elementos que tenham estilos em ::before.

Acabamento

Os estilos acima são um começo, mas dá para melhorar:

  • Cor e tamanho personalizáveis: em vez de fixar currentColor e 1em, leia os valores de custom properties, com esses dois como fallback: background-color: var(--icon-style-color, currentColor) e width: var(--icon-style-size, 1em) (o mesmo para height). Como elas são herdadas, dá para estilizar todos os ícones de uma subárvore definindo-as num ancestral, sem tocar no pseudo-elemento. O prefixo --icon-style- evita colisão com os próprios ícones, que também são --icon-*.
  • Preservar no modo de cores forçadas: forced-color-adjust: none; mantém o preenchimento da máscara no modo forced-colors. O currentColor continua seguindo a cor de texto forçada.
  • Não atrapalhar a interação com o ponteiro: pointer-events: none, para o ícone não capturar cliques que não deveria.
  • Não encolher em containers flex: flex: none. Um pseudo-elemento vazio tem tamanho mínimo zero, então num botão flex ele é a primeira coisa a encolher quando o rótulo é longo, e pode encolher até sumir. O tamanho de um ícone é uma decisão de design, não algo que a pressão do layout deva negociar.
  • Centralização vertical: a autora usa vertical-align: calc(0.5cap - size / 2 - 0.02em); para centralizar os ícones na altura das maiúsculas e subi-los um pouquinho. Nossos olhos dão mais peso ao espaço acima do ícone do que ao de baixo, então algo centralizado matematicamente parece um pouco baixo. Com tamanho e deslocamento personalizáveis, fica calc(0.5cap - var(--icon-style-size, 1em) / 2 + var(--icon-style-voffset, -0.02em)), para o centro do ícone não sair do lugar quando ele cresce.
  • Espaço em relação ao texto: margin-inline-end: var(--icon-style-spacing, .3em); cria um respiro entre o ícone e o conteúdo (margin-inline-start no ::after). Lembre de removê-lo nos ícones avulsos.
  • Acessibilidade: o pseudo-elemento não tem texto, então as tecnologias assistivas o ignoram por completo, que é exatamente o que se quer para um ícone ao lado de um rótulo. Controles só com ícone ainda precisam de um nome, e o ícone pode fornecê-lo via texto alternativo para conteúdo gerado: content: "" / var(--icon-style-alt, ""). Assim, <button style="--icon: var(--icon-trash); --icon-style-alt: 'Excluir'"> é anunciado como “Excluir”. O aria-label no próprio controle continua funcionando, claro.

Contornando bugs do Safari

Juntando tudo até aqui, chega-se a algo assim:

<link rel="stylesheet" href="icon-tokens.css">
<link rel="stylesheet" href="styles.css">
<style>
/* Sistema de ícones */
/* Definições dos ícones no :root */
@import url("icon-tokens.css");
@property --icon-start {
syntax: '*';
inherits: false;
}
@container style(--icon-start) {
::before {
--icon-start: inherit;
content: "" / var(--icon-style-alt, "");
display: inline-block;
overflow: hidden;
/* Centraliza o ícone na altura das maiúsculas: https://blog.kizu.dev/cap-height-align/
overflow: hidden faz a baseline da caixa ser a borda inferior (clip não faz). */
vertical-align: calc(
0.5cap - var(--icon-style-size, 1em) / 2 + var(--icon-style-voffset, -0.02em)
);
width: var(--icon-style-size, 1em);
height: var(--icon-style-size, 1em);
margin-inline-end: var(--icon-style-spacing, .3em);
background-color: var(--icon-style-color, currentColor);
mask: var(--icon-start) no-repeat center / contain;
pointer-events: none;
flex: none;
forced-color-adjust: none;
}
}
</style>
<style>
/* Estilos dos botões (só a parte relevante) */
button {
:where(&) {
/* Redireciona --icon para algo mais específico */
--icon-start: var(--icon);
}
&:hover {
--icon-start: var(--icon-hover, var(--icon));
}
&.danger {
--icon: var(--icon-trash-outlined);
&:hover {
--icon: var(--icon-trash);
}
}
}
</style>
<button>Sem ícone</button>
<button style="--icon: var(--icon-github)">Ícone avulso</button>
<button class="danger">Ícone padrão</button>
<button class="danger">Com <strong>estilo <em>inline</em></strong></button>
<button style="--icon: var(--icon-github); --icon-hover: var(--icon-link-external);">GitHub para link</button>

Demonstração: cinco botões com o sistema de ícones, ainda sem os contornos para o Safari

No Chrome ou no Firefox, a demonstração deve funcionar como esperado. No Safari, já deu para perceber que ela está bem quebrada.

A mesma demonstração no Safari: os ícones só aparecem depois que o ponteiro passa sobre os botões.

A investigação disso rendeu não um, mas dois relatórios de bug no Safari. A boa notícia é que todos têm contorno. A má é que esses contornos obrigam a escrever um código pior e, a menos que se aceite enviar uma versão diferente para o Safari, todo mundo sai perdendo.

Contornando os bugs #301609 e #320220 do WebKit

O bug #301609 do WebKit foi o que realmente mais atrapalhou esta técnica. Resumindo: com o CSS acima, os ícones não aparecem no Safari até que a pessoa interaja com eles de algum jeito (hover, foco etc.).

Felizmente, existe um contorno em CSS:

/* Contorno para https://bugs.webkit.org/show_bug.cgi?id=301609
Uma animação de duração zero nos pseudo-elementos força o WebKit
a resolver o estilo de novo, desta vez contra o container correto.
Precisa ficar fora do bloco @container (que é falso na primeira passada).
*/
@keyframes webkit-301609 {}
/* Liste aqui todos os pseudo-elementos relevantes */
::before, ::after {
animation: webkit-301609 0s;
}

Mas, como isso aplica uma animação a todos os pseudo-elementos ::before e ::after, o ideal é restringir um pouco e facilitar a sobrescrita, envolvendo a regra numa layer.

/* Contorno para https://bugs.webkit.org/show_bug.cgi?id=301609
Uma animação de duração zero nos pseudo-elementos força o WebKit
a resolver o estilo de novo, desta vez contra o container correto.
Precisa ficar fora do bloco @container (que é falso na primeira passada). */
@keyframes webkit-301609 {}
/* Safari 18+. Propriedades exclusivas do WebKit combinadas com OR, por robustez */
@supports ((-webkit-nbsp-mode: normal) or (-webkit-touch-callout: none) or (font: -apple-system-body)) and (content-visibility: auto) {
@layer webkit-301609-fix {
::before, ::after {
animation: webkit-301609 0s;
}
}
}

Pelo jeito, isso também corrige, como mágica, o bug #320220 do WebKit, que também é relevante aqui.

Neste ponto, a demonstração funciona quase perfeitamente!

<link rel="stylesheet" href="icon-tokens.css">
<link rel="stylesheet" href="styles.css">
<style>
/* Sistema de ícones */
/* Definições dos ícones no :root */
@import url("icon-tokens.css");
@property --icon-start {
syntax: '*';
inherits: false;
}
@container style(--icon-start) {
::before {
--icon-start: inherit;
content: "" / var(--icon-style-alt, "");
display: inline-block;
overflow: hidden;
/* Centraliza o ícone na altura das maiúsculas: https://blog.kizu.dev/cap-height-align/
overflow: hidden faz a baseline da caixa ser a borda inferior (clip não faz). */
vertical-align: calc(
0.5cap - var(--icon-style-size, 1em) / 2 + var(--icon-style-voffset, -0.02em)
);
width: var(--icon-style-size, 1em);
height: var(--icon-style-size, 1em);
margin-inline-end: var(--icon-style-spacing, .3em);
background-color: var(--icon-style-color, currentColor);
mask: var(--icon-start) no-repeat center / contain;
pointer-events: none;
flex: none;
forced-color-adjust: none;
}
}
/* Contorno para https://bugs.webkit.org/show_bug.cgi?id=301609
Uma animação de duração zero nos pseudo-elementos força o WebKit
a resolver o estilo de novo, desta vez contra o container correto.
Precisa ficar fora do bloco @container (que é falso na primeira passada). */
@keyframes webkit-301609 {}
/* Só Safari 18+ */
@supports ((-webkit-nbsp-mode: normal) or (-webkit-touch-callout: none) or (font: -apple-system-body)) and (content-visibility: auto) {
@layer webkit-301609-fix {
::before, ::after {
animation: webkit-301609 0s;
}
}
}
</style>
<style>
/* Estilos dos botões (só a parte relevante) */
button {
:where(&) {
/* Redireciona --icon para algo mais específico */
--icon-start: var(--icon);
}
&:hover {
--icon-start: var(--icon-hover, var(--icon));
}
&.danger {
--icon: var(--icon-trash-outlined);
&:hover {
--icon: var(--icon-trash);
}
}
}
</style>
<button>Sem ícone</button>
<button style="--icon: var(--icon-github)">Ícone avulso</button>
<button class="danger">Ícone padrão</button>
<button class="danger">Com <strong>estilo <em>inline</em></strong></button>
<button style="--icon: var(--icon-github); --icon-hover: var(--icon-link-external);">GitHub para link</button>

Demonstração: os mesmos cinco botões com o contorno para os bugs #301609 e #320220 do WebKit

Consegue achar o bug que sobrou?

Contornando o bug #325900 do WebKit

Dois botões trocam de ícone no hover: Ícone padrão e GitHub para link. O primeiro funciona, o segundo não. Como assim?!

No Safari, o botão GitHub para link não troca de ícone no hover.

Pois há uma diferença entre eles: .danger define --icon no hover, enquanto GitHub para link define --icon-start diretamente, via --icon-hover. E --icon-start está registrada como não herdável, o que é bem relevante aqui.

Acontece que --non-inherited: inherit simplesmente não funcionava no Safari até 2023, e ainda há casos em que o problema se reproduz hoje. A investigação resultou na abertura do bug #325900 do WebKit.

Há algumas soluções, e a única viável parece ser registrar a propriedade como herdável, afinal, e acrescentar uma style query que a redefine nos descendentes:

@property --icon-start {
syntax: '*';
inherits: true;
}
@container style(--icon-start) {
* {
--icon-start: initial;
}
}
/* ... */

Isso é um pouco mais frágil e provavelmente mais lento, então o ideal seria enviar só para o Safari.

O @supports só detecta recursos, não bugs, então mais uma vez é preciso farejar o WebKit. Por sorte, @property é permitida dentro de regras condicionais, e, quando uma propriedade é registrada várias vezes, o último registro vence:

@property --icon-start {
syntax: '*';
inherits: false;
}
/* Contorno para https://bugs.webkit.org/show_bug.cgi?id=325900
`inherit` em propriedades registradas não herdáveis não é confiável,
então elas são registradas como herdáveis e redefinidas nos descendentes. */
@supports ((-webkit-nbsp-mode: normal) or (-webkit-touch-callout: none) or (font: -apple-system-body)) and (content-visibility: auto) {
@property --icon-start {
syntax: '*';
inherits: true;
}
@layer webkit-325900-fix {
@container style(--icon-start) {
* {
--icon-start: initial;
}
}
}
}
/* ... */

A layer garante que qualquer declaração fora de layer vença a redefinição, mesmo as de especificidade zero, como :where(button) { --icon-start: var(--icon) }.

Demonstração

Juntando tudo, esta é a demonstração final, com os dois contornos aplicados:

<link rel="stylesheet" href="icon-tokens.css">
<link rel="stylesheet" href="styles.css">
<style>
/* Sistema de ícones */
/* Definições dos ícones no :root */
@import url("icon-tokens.css");
@property --icon-start {
syntax: '*';
inherits: false;
}
@container style(--icon-start) {
::before {
--icon-start: inherit;
content: "" / var(--icon-style-alt, "");
display: inline-block;
overflow: hidden;
/* Centraliza o ícone na altura das maiúsculas: https://blog.kizu.dev/cap-height-align/
overflow: hidden faz a baseline da caixa ser a borda inferior (clip não faz). */
vertical-align: calc(
0.5cap - var(--icon-style-size, 1em) / 2 + var(--icon-style-voffset, -0.02em)
);
width: var(--icon-style-size, 1em);
height: var(--icon-style-size, 1em);
margin-inline-end: var(--icon-style-spacing, .3em);
background-color: var(--icon-style-color, currentColor);
mask: var(--icon-start) no-repeat center / contain;
pointer-events: none;
flex: none;
forced-color-adjust: none;
}
}
/* Contorno para https://bugs.webkit.org/show_bug.cgi?id=301609
Uma animação de duração zero nos pseudo-elementos força o WebKit
a resolver o estilo de novo, desta vez contra o container correto.
Precisa ficar fora do bloco @container (que é falso na primeira passada). */
@keyframes webkit-301609 {}
/* Só Safari 18+ */
@supports ((-webkit-nbsp-mode: normal) or (-webkit-touch-callout: none) or (font: -apple-system-body)) and (content-visibility: auto) {
@layer webkit-301609-fix {
::before, ::after {
animation: webkit-301609 0s;
}
}
/* Contorno para https://bugs.webkit.org/show_bug.cgi?id=325900
`inherit` em propriedades registradas não herdáveis não é confiável,
então elas são registradas como herdáveis e redefinidas nos descendentes. */
@property --icon-start {
syntax: '*';
inherits: true;
}
@layer webkit-325900-fix {
@container style(--icon-start) {
* {
--icon-start: initial;
}
}
}
}
</style>
<style>
/* Estilos dos botões (só a parte relevante) */
button {
:where(&) {
/* Redireciona --icon para algo mais específico */
--icon-start: var(--icon);
}
&:hover {
--icon-start: var(--icon-hover, var(--icon));
}
&.danger {
--icon: var(--icon-trash-outlined);
&:hover {
--icon: var(--icon-trash);
}
}
}
</style>
<button>Sem ícone</button>
<button style="--icon: var(--icon-github)">Ícone avulso</button>
<button class="danger">Ícone padrão</button>
<button class="danger">Com <strong>estilo <em>inline</em></strong></button>
<button style="--icon: var(--icon-github); --icon-hover: var(--icon-link-external);">GitHub para link</button>

Demonstração: demonstração final, com os dois contornos para o Safari: passe o ponteiro sobre os botões para trocar os ícones

Usando ícones remotos

Ao usar ícones em arquivos SVG, vale ter em mente que qualquer URL relativa é resolvida em relação ao arquivo CSS que usa o ícone, não ao que o define.

Na prática, isso obriga a usar URLs relativas à raiz (como url('/assets/icons/trash.svg')); do contrário, qualquer estrutura de projeto não trivial vai quebrar de forma aleatória.

As propriedades registradas deveriam resolver isso: o valor computado de um <url> ou <image> registrado é uma URL absoluta, resolvida em relação à folha de estilo que a declarou.

Então, em tese, daria para registrar as propriedades dos ícones como <image> via @property e definir os valores no initial-value:

@property --icon-caret {
syntax: '<image>';
inherits: true;
initial-value: url("icons/caret.svg");
}

Isso também seria melhor por outros motivos:

  • Não haveria centenas de variáveis --icon-… no :root nas ferramentas de desenvolvedor
  • Daria até para sobrescrever temporariamente com outro ícone num contexto específico, e redefinir onde quiser com --icon-caret: initial;

Infelizmente, na prática isso só funciona no Firefox. A autora montou uma página de teste que experimenta quatro sintaxes diferentes, com a URL no initial-value ou declarada no :root, e os resultados são um caos:

  • O Firefox acerta tudo.
  • O Safari resolve as URLs do initial-value em relação ao documento, qualquer que seja a sintaxe. Os valores declarados no :root funcionam com todas as sintaxes.
  • O Chrome tem o mesmo bug do initial-value e, além disso, os valores declarados no :root só funcionam quando a sintaxe começa com <url>: <url> e <url> | <image> funcionam, mas <image> e <image> | <url> resolvem em relação ao documento.

Então, hoje, a única coisa que funciona em todo lugar é registrar as propriedades dos ícones com uma sintaxe que comece com <url> e, ainda assim, declarar os ícones de fato no :root:

@property --icon-caret {
syntax: "<url> | <image> | none";
inherits: true;
initial-value: none;
}
:root {
--icon-caret: url("icons/caret.svg");
}

Isso perde a maior parte das vantagens acima, mas faz as URLs relativas funcionarem. A desvantagem óbvia é a experiência de desenvolvimento: cada ícone passa a ficar em dois lugares, e há muito mais boilerplate.

As declarações simples no :root dá para manter na mão, mesmo com muitos ícones, mas isso aqui começa a exigir uma etapa de build depois de uma dúzia de ícones, mais ou menos.

Além disso, as propriedades registradas hoje são globais e não funcionam em shadow roots, então podem não servir para todos os casos.

Outra opção seria percorrer todas as propriedades --icon-* do :root e registrá-las via JS:

const cs = getComputedStyle(document.documentElement);
const iconTokens = [...cs].filter(p => p.startsWith("--icon-") && !p.startsWith("--icon-style"));
for (let name of iconTokens) {
CSS.registerProperty({ name, inherits: true, syntax: "<url> | <image> | none", initialValue: "none" });
}

Outras ressalvas

Ícones multicoloridos

Como o ícone é uma máscara, esta técnica só funciona com ícones monocromáticos (variar a opacidade não tem problema), mas a maioria dos ícones hoje é monocromática mesmo.

Se for realmente preciso mais, os engenheiros da Allegro empilham máscaras no elemento e nos dois pseudo-elementos dele, o que dá até três cores com um único elemento.

E os ícones coloridos, como emojis? Ou os ícones textuais, como… emojis?

Em tese, a técnica funciona bem com eles, e esses casos são até mais fáceis! Ícones coloridos basta definir como imagem de fundo, sem máscara. E ícones textuais dá para pôr direto na propriedade content.

O único desafio é escolher entre esses três caminhos. Mas isso pode ficar para um próximo post, já que este já está bem longo.

Ícones com fundo e afins

Como o pseudo-elemento inteiro é mascarado, não dá para aplicar a ele um fundo ou uma borda à parte. Para isso, é preciso um elemento de verdade (como <i class="icon">), e as bordas, fundos etc. vão nele.

E a animação?

Animações totalmente arbitrárias, obviamente, continuam exigindo elementos <svg> inline.

Mas e as animações predefinidas? Um check ou um X sendo desenhado, um menu hambúrguer virando um botão de fechar etc.?

A autora achou que tinha encontrado um jeito, e chegou muito perto! Perto o bastante para a saga toda merecer um post próprio, então aguarde 😉

A estrela-guia

O sistema acima já oferece uma experiência de desenvolvimento muito boa (principalmente sem os bugs do Safari!), mas algumas coisas ainda não são ideais.

Primeiro, a abordagem com máscara traz algumas limitações das quais seria bom se livrar.

Depois, a relação sinal-ruído poderia ser ainda melhor:

  • E se --icon: trash funcionasse, em vez de --icon: var(--icon-trash)?
  • E se <button icon="check"> funcionasse, em vez de <button style="--icon: var(--icon-check)">? (deixando de lado, por um instante, as restrições do HTML sobre nomes de atributo)

E, embora quem consome venha antes de quem produz, seria muito bom se dar suporte a novos tipos de pseudo-elemento não exigisse tanto malabarismo.

Vários recursos de CSS que estão a caminho aproximam o sistema dessa estrela-guia.

Linked parameters = personalização arbitrária!

Se você ainda não ouviu falar da nova especificação CSS Linked Parameters, ela é a melhor coisa que aconteceu aos ícones em CSS em muito tempo.

Ela permite passar dados arbitrários para qualquer recurso que aceite isso (e sim, o SVG é um deles). Assim, dá para personalizar ícones de formas antes impossíveis, como mudar a espessura do traço, a cor ou fazer personalizações totalmente arbitrárias, já que o SVG também aceita style queries.

E, desde algumas semanas atrás, ela também permite passar color e accent-color sem nenhuma adesão dupla por parte do ícone, o que significa que dá para usar qualquer biblioteca de ícones existente sem que ela precise ser atualizada para isso!

--icon: url("icons/link.svg");
link-parameters: param(color)
param(--stroke-width, 2px);

Ainda há limitações, mas a especificação está sendo desenhada ativamente, o Firefox já lançou uma versão anterior dela e ela é uma das propostas mais votadas do Interop 2027. Então há boas razões para esperar que ela facilite muito o uso de ícones em CSS. Fique de olho nessa!

Em breve, não vai ser preciso usar backgrounds

Hoje, por um detalhe técnico de como a propriedade content é tratada, é preciso recorrer a imagens de fundo para incluir ícones, porque do contrário width e height não funcionam. Nem os Linked Parameters sozinhos mudam isso; eles só permitem abandonar o truque da máscara.

Felizmente, o CSS WG (do qual a autora faz parte) decidiu recentemente por uma mudança que vai resolver isso! (dependendo de uma análise de compatibilidade)

ident() permitiria usar identificadores em vez de variáveis CSS

O ident() é o recurso de CSS de que você não sabia que precisava desesperadamente, e merece um artigo próprio, pela infinidade de usos em design systems. Enquanto esse artigo não sai, Bramus tem um ótimo texto sobre ele.

Mas, no que interessa aos ícones, quando o ident() tiver amplo suporte, dará para construir nomes de variáveis dinamicamente.

Isso permite usar identificadores simples em vez de variáveis CSS, o que deixa a sintaxe ainda mais limpa: daria para traduzir --icon-start-trash em var(--icon-start-trash) ao custo de um pouco de complexidade do lado de quem produz:

--_icon-start: var(ident(--icon- var(--icon-start)));

Sim, é feio, mas também é um detalhe de implementação que não fica exposto a quem consome os ícones, então a troca compensa.

Combinado com o attr() expandido, que está quase lá (só falta o Safari, e o Safari TP já tem suporte), isso também libera ícones avulsos com um atributo simples:

<i data-icon="trash"></i>
[data-icon] {
--_icon-start: var(ident(--icon- attr(data-icon)));
}

Registro de propriedades em massa mais ergonômico

O CSS WG decidiu recentemente permitir o registro de várias propriedades com os mesmos descritores de uma vez, via @property --prop1, --prop2, --prop3 { … }, mas isso não ajuda muito aqui, já que cada ícone precisa de um valor inicial diferente.

Daria, claro, para registrar várias propriedades com a sintaxe <image> e também definir os ícones de fato no :root, mas aí cada ícone teria de ser acrescentado em dois lugares, o que seria pior do que a sintaxe expandida atual do @property.

Porém, outro caminho que o CSS WG está explorando é uma forma abreviada do @property, que permitiria um registro mais compacto. Para o ícone acima, ficaria assim:

@property --icon-caret <image> = url("icons/caret.svg");

@mixin vai permitir centralizar os estilos dos pseudo-elementos

Hoje é preciso repetir muitos estilos entre os diferentes pseudo-elementos. Se também se quiser uma classe de fallback que não dependa de style queries, é preciso repeti-los quatro vezes!

Com @mixin, basta fazer:

@mixin --icon {
content: "";
display: inline-block;
overflow: hidden;
vertical-align: calc(0.5cap - 0.5em - 0.02em);
width: 1em;
height: 1em;
background-color: currentColor;
mask: no-repeat center / contain;
pointer-events: none;
flex: none;
}
@mixin --icon-start {
@apply --icon;
--icon-start: inherit;
margin-inline-end: .3em;
mask-image: var(--icon-start);
}
@mixin --icon-end {
@apply --icon;
--icon-end: inherit;
margin-inline-start: .3em;
mask-image: var(--icon-end);
}

E então usá-los em todo lugar:

.icon {
--icon-start: var(--icon);
}
.icon::before,
.icon-start::before {
@apply --icon-start;
}
.icon-end::after {
@apply --icon-end;
}
@container style(--icon-start) {
::before {
@apply --icon-start;
}
}
@container style(--icon-end) {
::after {
@apply --icon-end;
}
}
@container style(--icon-marker) {
::marker {
@apply --icon-marker;
}
}
/* e assim por diante… */

Use hoje com @nudeui/icons!

A autora publicou um pacote que reúne tudo deste post, com o acabamento e também as imperfeições. Ele não traz ícones, só o sistema de ícones. Basta incluí-lo para poder usar --icon-start e --icon-end nos seus estilos. Ele também inclui as classes .icon, .icon-start e .icon-end.

O pacote não envolve o conteúdo numa layer, para deixar a escolha com você, mas a recomendação é importá-lo numa layer mesmo assim.

@nudeui/icons

Notas de rodapé

  1. Há uma pequena chance de ela ter sido a primeira a usar uma web font para ícones de interface, em 2010. Ela também trabalhou por um tempo como Product Lead no Font Awesome. ↩

  2. “Componentes” aqui tem o sentido geral de elemento de interface reutilizável, não o de componente de framework ou de custom element. Um <button> estilizado ou um <article class="callout">, por exemplo, contam. ↩