Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

Variáveis CSS: custom properties na prática

Como declarar uma variável no :root, ler com var(), trocar o tema inteiro num seletor só e por que ela herda em vez de sumir no build.

Rodolfo Mori11 min de leitura

Variável CSS — o nome oficial é custom property — é um valor que você declara uma vez, com dois hífens na frente, e lê em qualquer regra com var(). Ela não some quando o CSS é gerado: fica viva no navegador, é herdada pela árvore de elementos e pode mudar depois que a página carregou.

Todos os exemplos aqui são o painel da recepção de uma clínica veterinária: cartão de consulta, etiqueta de estado e um modo plantão para o turno da noite. Cada saída marcada como resultado saiu do Chromium 151 rodando por Playwright, e o CSS compilado saiu do Dart Sass 1.103.1.

Declarar em :root e ler com var()

A declaração é uma propriedade comum, só que o nome começa com --. O lugar padrão é :root, que é o elemento <html> visto de dentro do CSS.

css
:root {
  --cor-marca: #0f766e;
  --raio: 10px;
  --espaco: 16px;
}

.consulta {
  border-left: 4px solid var(--cor-marca);
  border-radius: var(--raio);
  padding: var(--espaco);
}

.consulta h3 {
  color: var(--cor-marca);
  margin: 0;
}

Lendo o estilo computado do cartão no navegador:

borda do card: rgb(15, 118, 110) raio do card: 10px padding do card: 16px cor do titulo: rgb(15, 118, 110)

O nome é sensível a maiúscula: --cor-marca e --Cor-Marca são duas variáveis diferentes. E o valor não precisa ser um valor completo de propriedade — ele pode ser um pedaço solto, que só faz sentido quando encaixado em outro lugar:

css
:root {
  --marca-h: 175;
  --marca-s: 77%;
  --marca-l: 26%;
  --sombra: 0 1px 3px;
}

.botao {
  background: hsl(var(--marca-h) var(--marca-s) var(--marca-l));
  box-shadow: var(--sombra) hsl(var(--marca-h) var(--marca-s) var(--marca-l) / 0.4);
  color: #fff;
}

.botao:hover { --marca-l: 18%; }
normal background rgb(15, 117, 109) box-shadow rgba(15, 117, 109, 0.4) 0px 1px 3px 0px hover background rgb(11, 81, 75) box-shadow rgba(11, 81, 75, 0.4) 0px 1px 3px 0px

Repare no que aconteceu: o :hover mexeu em uma variável, a luminosidade, e o fundo e a sombra escureceram juntos e na mesma proporção. Guardar as peças da cor separadas é um truque barato para manter a paleta coerente — o mesmo raciocínio aparece em cores no CSS, quando você escolhe entre hex, hsl e oklch.

O var() que falha não volta para o valor anterior

var() aceita um segundo argumento, que é usado quando a variável não existe. Até aí, previsível. O que pega gente é o que acontece sem fallback.

css
:root { --cor-agendada: #0f766e; }

/* os quatro parágrafos .a a .d estão dentro de .painel */
.painel { color: #1d4ed8; }

.a { color: var(--cor-agendada); }
.b { color: var(--cor-urgente, #b91c1c); }
.c { color: var(--cor-agendad); }
.d { color: #b45309; color: var(--cor-agendad); }
.a rgb(15, 118, 110) Mel — vacina V10 .b rgb(185, 28, 28) Thor — retorno pós-cirúrgico .c rgb(29, 78, 216) Nina — banho e tosa .d rgb(29, 78, 216) Bidu — exame de sangue

.b usou o fallback, como esperado. .c tem um erro de digitação — falta o a final em --cor-agendada — e ficou azul: herdou a cor de .painel.

O caso importante é .d. Ela declara #b45309 e, na linha seguinte, um var() quebrado. Você poderia esperar que o navegador descartasse a linha ruim e mantivesse o laranja. Não é o que acontece: a declaração com var() vence a cascata primeiro e só depois falha, num estágio chamado invalid at computed-value time. Aí a propriedade inteira vira unset: como color é uma propriedade herdada, ela cai para o valor herdado — o mesmo azul de .c. Numa propriedade que não herda, a queda é para o valor inicial, e mais adiante você vê o estrago que isso faz numa borda.

A diferença que o Sass não cobre: a variável sobrevive ao build

Quem já usou variáveis no Sass pode achar que --cor é só uma sintaxe nova para $cor. São coisas diferentes, e dá para provar olhando o arquivo que cada uma gera. Este é o SCSS do painel:

css
$cor-marca: #0f766e;

.painel { border-top: 3px solid $cor-marca; }
.painel__titulo { color: $cor-marca; }
.consulta { border-left: 4px solid $cor-marca; }
.consulta__hora { color: $cor-marca; }
.botao { background: $cor-marca; border: 1px solid $cor-marca; }
.botao:hover { box-shadow: 0 0 0 3px $cor-marca; }
.aba--ativa { border-bottom: 2px solid $cor-marca; color: $cor-marca; }

Compilando e contando o que sobrou:

bash
npx sass --no-source-map painel.scss:painel.css
grep -o "#0f766e" painel.css | wc -l
grep -c "cor-marca" painel.css
9 0

A variável do Sass não existe mais no arquivo entregue ao navegador: virou nove cópias literais de #0f766e. Ela é uma abreviação para quem escreve o código, e o compilador a resolve antes de o CSS sair.

Isso tem uma consequência prática. Para ter dois temas com Sass, o painel vira um partial com as cores abertas por !default — e ganha o fundo do cartão, que no plantão também muda. Cada tema passa a ser um arquivo de entrada que sobrescreve o que precisa:

css
/* _tema.scss */
$cor-marca: #0f766e !default;
$cor-cartao: #ffffff !default;

.painel { border-top: 3px solid $cor-marca; }
.painel__titulo { color: $cor-marca; }
.consulta { background: $cor-cartao; border-left: 4px solid $cor-marca; }
.consulta__hora { color: $cor-marca; }
.botao { background: $cor-marca; border: 1px solid $cor-marca; }
.botao:hover { box-shadow: 0 0 0 3px $cor-marca; }
.aba--ativa { border-bottom: 2px solid $cor-marca; color: $cor-marca; }

/* tema-dia.scss */
@use 'tema';

/* tema-plantao.scss */
@use 'tema' with ($cor-marca: #2dd4bf, $cor-cartao: #111c33);

São dois arquivos de entrada, então são dois CSS compilados. Ao lado deles, painel-var.css é o mesmo painel escrito uma vez com custom property, no mesmo estilo comprimido:

bash
npx sass --no-source-map --style=compressed tema-dia.scss:tema-dia.css tema-plantao.scss:tema-plantao.css
wc -c tema-dia.css tema-plantao.css painel-var.css
305 tema-dia.css 308 tema-plantao.css 453 painel-var.css 1066 total

São 305 + 308 = 613 bytes divididos em dois arquivos, e trocar de tema significa trocar qual <link> está na página. A versão com custom property faz o trabalho dos dois em 453 bytes num arquivo só (ele é uma linha só de verdade; está quebrado abaixo só para caber na leitura):

css
:root{--marca:#0f766e;--cartao:#fff}
:root[data-tema=plantao]{--marca:#2dd4bf;--cartao:#111c33}
.painel{border-top:3px solid var(--marca)}
.painel__titulo{color:var(--marca)}
.consulta{background:var(--cartao);border-left:4px solid var(--marca)}
.consulta__hora{color:var(--marca)}
.botao{background:var(--marca);border:1px solid var(--marca)}
.botao:hover{box-shadow:0 0 0 3px var(--marca)}
.aba--ativa{border-bottom:2px solid var(--marca);color:var(--marca)}

O ganho de bytes é o detalhe menor. O grande é que a segunda versão continua sendo um arquivo por mais temas que o painel tenha, e o navegador troca entre eles sem baixar nada.

Um atributo no html troca o painel inteiro

A recepção da clínica pede um modo plantão para o turno da noite. Com as cores declaradas em :root, o tema inteiro é um segundo seletor:

css
:root {
  --fundo: #f8fafc;
  --texto: #0f172a;
  --cartao: #ffffff;
  --marca: #0f766e;
}

:root[data-tema="plantao"] {
  --fundo: #0b1120;
  --texto: #e2e8f0;
  --cartao: #111c33;
  --marca: #2dd4bf;
}

body { background: var(--fundo); color: var(--texto); }

.consulta {
  background: var(--cartao);
  border-left: 4px solid var(--marca);
  padding: 12px 16px;
}

O botão só precisa mexer no atributo do elemento raiz:

js
const raiz = document.documentElement;

document.getElementById('trocar').addEventListener('click', () => {
  raiz.dataset.tema = raiz.dataset.tema === 'plantao' ? 'dia' : 'plantao';
});

Medindo os estilos computados antes e depois do clique:

antes do clique (data-tema="dia") body background rgb(248, 250, 252) color rgb(15, 23, 42) .consulta background rgb(255, 255, 255) borda rgb(15, 118, 110) depois do clique (data-tema="plantao") body background rgb(11, 17, 32) color rgb(226, 232, 240) .consulta background rgb(17, 28, 51) borda rgb(45, 212, 191)

Nenhuma regra de .consulta foi reescrita. Quatro variáveis mudaram no topo do documento e o resto da folha de estilo se ajustou sozinho.

Dá para combinar com a preferência do sistema operacional, deixando o data-tema como decisão explícita da pessoa e o @media como padrão:

css
:root { --fundo: #f8fafc; --marca: #0f766e; }

@media (prefers-color-scheme: dark) {
  :root { --fundo: #0b1120; --marca: #2dd4bf; }
}

:root[data-tema="dia"]     { --fundo: #f8fafc; --marca: #0f766e; }
:root[data-tema="plantao"] { --fundo: #0b1120; --marca: #2dd4bf; }

body { background: var(--fundo); }
.consulta { border-left: 4px solid var(--marca); }

Lendo o fundo do body e a borda do cartão em três situações — o Playwright finge o sistema claro e o escuro com emulateMedia:

sistema claro, sem data-tema fundo rgb(248, 250, 252) marca rgb(15, 118, 110) sistema escuro, sem data-tema fundo rgb(11, 17, 32) marca rgb(45, 212, 191) sistema escuro, data-tema="dia" fundo rgb(248, 250, 252) marca rgb(15, 118, 110)

A terceira linha é a que importa: mesmo com o sistema no escuro, quem escolheu “dia” continua no claro, porque a regra com [data-tema] tem especificidade maior. Se você não entende por que uma regra vence a outra, seletores e cascata no CSS explica a conta.

A variável desce na árvore — e só desce

Custom property é herdada como color e font-family: o valor declarado num elemento vale para ele e para todos os descendentes. O caminho é de cima para baixo, e só.

:root --estado: verde .agenda herda: verde .consulta--urgente --estado: vermelho .tag herda: vermelho declara o valor padrão do painel não declara nada, usa o de cima redefine só para este ramo recebe o vermelho do pai

Quem declara a variável no filho e tenta ler no pai descobre isso do jeito difícil:

css
.agenda { border: 2px solid var(--destaque); padding: 8px; }

.consulta {
  --destaque: #b91c1c;
  border-left: 4px solid var(--destaque);
  padding: 6px 10px;
}

.consulta .tag { outline: 2px solid var(--destaque); }
.agenda --destaque "" .agenda border-style none .agenda border-width 0px .agenda border-color rgb(0, 0, 0) .consulta border-left rgb(185, 28, 28) .tag --destaque "#b91c1c" .tag outline-color rgb(185, 28, 28)

Na .agenda a variável chega vazia — ela nasceu no filho, e variável não sobe. Repare no tamanho do estrago: não é só a cor que se perde. O border é um atalho, ele falha inteiro, e como border-style e border-width não são propriedades herdadas, cada uma cai para o próprio valor inicial: none e zero. Ou seja, a .agenda não ficou com uma borda preta — ficou sem borda nenhuma. O rgb(0, 0, 0) que aparece no border-color é o currentColor inicial, uma cor que não tem traço para pintar.

O .tag, que está abaixo da .consulta, recebeu o vermelho sem precisar de nenhuma regra própria.

Redefinir a mesma variável dentro do componente

Essa herança é o que torna a custom property boa para variação de componente. Você escreve a regra visual uma vez, com var() no meio, e cada modificador só troca o valor:

css
:root { --estado: #0f766e; }

.consulta {
  border-left: 4px solid var(--estado);
  padding: 8px 12px;
}
.consulta .tag { color: var(--estado); }

.consulta--urgente { --estado: #b91c1c; }
.consulta--espera  { --estado: #b45309; }
html
<article class="consulta"><span class="tag">agendada</span> Mel — vacina V10</article>
<article class="consulta consulta--urgente"><span class="tag">urgente</span> Thor — pós-cirúrgico</article>
<article class="consulta consulta--espera"><span class="tag">na espera</span> Nina — banho e tosa</article>
agendada | borda rgb(15, 118, 110) | tag rgb(15, 118, 110) urgente | borda rgb(185, 28, 28) | tag rgb(185, 28, 28) na espera | borda rgb(180, 83, 9) | tag rgb(180, 83, 9)

Sem variável, cada modificador precisaria repetir border-left e .tag { color }. Com ela, o modificador tem uma linha e o próximo estado da clínica (“em atendimento”, “faltou”) custa outra linha só.

Ler e escrever a variável pelo JavaScript

Do lado do JavaScript existem dois caminhos, e confundir os dois é a segunda armadilha mais comum. elemento.style o atributo style inline; getComputedStyle lê o valor que a cascata resolveu.

js
const card = document.querySelector('.consulta--urgente');

console.log('card.style       ', JSON.stringify(card.style.getPropertyValue('--estado')));
console.log('getComputedStyle ', JSON.stringify(getComputedStyle(card).getPropertyValue('--estado')));
console.log('borda antes      ', getComputedStyle(card).borderLeftColor);

card.style.setProperty('--estado', '#7c3aed');
console.log('borda depois     ', getComputedStyle(card).borderLeftColor);
console.log('atributo style   ', JSON.stringify(card.getAttribute('style')));

card.style.removeProperty('--estado');
console.log('borda removida   ', getComputedStyle(card).borderLeftColor);
card.style "" getComputedStyle "#b91c1c" borda antes rgb(185, 28, 28) borda depois rgb(124, 58, 237) atributo style "--estado: #7c3aed;" borda removida rgb(185, 28, 28)

Três coisas para guardar. Para ler, use getComputedStyle. Para escrever, use setProperty com o nome completo, incluindo os dois hífens — style['--x'] = ... não funciona. E removeProperty devolve o elemento ao valor da folha de estilo, que é como você desfaz uma personalização sem guardar o valor antigo.

Comparado com trocar classe na mão, muda o alcance: setProperty num elemento afeta o ramo inteiro abaixo dele. Para as outras formas de mexer no estilo pelo script, veja classList e style.

@property: dar tipo para poder animar

Por padrão o navegador não sabe o que a sua variável guarda. Para ele, --nivel é um texto qualquer — e texto não interpola. Por isso transition: --nivel não faz nada.

A regra @property registra a variável, dizendo o tipo, se ela é herdada e qual é o valor inicial. Aí o navegador passa a saber animar. A barra abaixo mostra quantos consultórios da clínica estão ocupados:

css
@property --nivel {
  syntax: '<percentage>';
  inherits: false;
  initial-value: 0%;
}

.ocupacao {
  --nivel: 0%;
  height: 14px;
  background: linear-gradient(90deg, #0f766e var(--nivel), #e2e8f0 0);
  transition: --nivel 600ms linear;
}

.ocupacao.cheia { --nivel: 80%; }

Ao lado dela, uma barra idêntica em tudo, menos no registro: --nivel-cru não tem @property nenhum.

css
.ocupacao-cru {
  --nivel-cru: 0%;
  height: 14px;
  background: linear-gradient(90deg, #0f766e var(--nivel-cru), #e2e8f0 0);
  transition: --nivel-cru 600ms linear;
}

.ocupacao-cru.cheia { --nivel-cru: 80%; }

As duas recebem a classe cheia no mesmo instante, e o valor computado de cada uma é lido a cada 150 ms:

1ms --nivel (com @property): 0% --nivel-cru (sem @property): 80% 154ms --nivel (com @property): 14.3067% --nivel-cru (sem @property): 80% 302ms --nivel (com @property): 33.1333% --nivel-cru (sem @property): 80% 452ms --nivel (com @property): 53.1867% --nivel-cru (sem @property): 80% 601ms --nivel (com @property): 73.2133% --nivel-cru (sem @property): 80% 754ms --nivel (com @property): 80% --nivel-cru (sem @property): 80%

A coluna da direita pula de 0% para 80% no primeiro milissegundo: sem tipo, não há interpolação, e a transition é ignorada. A da esquerda passa por todos os valores intermediários. É esse registro que permite animar gradiente, sombra e ângulo de rotação sem JavaScript nenhum.

Onde o var() simplesmente não funciona

Custom property é um valor, e valor só entra onde o CSS espera valor. Três dos cinco casos abaixo falham em silêncio; os outros dois são a forma que funciona, logo ao lado:

css
:root { --largura-tablet: 600px; --prop: color; --altura-linha: 20; }

p { font-size: 16px; color: #0f172a; }

.a { var(--prop): #b91c1c; }
.b { font-size: var(--altura-linha)px; }
.c { font-size: calc(var(--altura-linha) * 1px); }

@media (min-width: var(--largura-tablet)) { .d { color: #b91c1c; } }
@media (min-width: 600px)                 { .e { color: #b91c1c; } }
largura da janela: 1280px .a font-size 16px color rgb(15, 23, 42) .b font-size 16px color rgb(15, 23, 42) .c font-size 20px color rgb(15, 23, 42) .d font-size 16px color rgb(15, 23, 42) .e font-size 16px color rgb(185, 28, 28)

Numa janela de 1280px, só .c e .e fizeram efeito:

caso o que foi tentado resultado por quê
.a var() no nome da propriedade ignorado var() só substitui valor, nunca nome de propriedade nem seletor
.b var(--altura-linha)px ignorado a substituição é de token: o px fica solto e a declaração inteira morre
.c calc(var(--altura-linha) * 1px) 20px calc() é a forma certa de dar unidade a um número guardado
.d var() dentro de @media nunca aplica a media query é avaliada antes da cascata, quando a variável ainda não existe
.e @media (min-width: 600px) vermelho com o valor escrito na regra, a media query casa e a cor entra

O caso .b merece atenção porque parece razoável. Guardar 20 sem unidade é útil quando o mesmo número vira 20px num lugar e 20% em outro — mas a conversão sempre passa por calc(). Se você não precisa dessa flexibilidade, guarde a unidade junto na variável e evite o problema; a escolha entre px, rem e as outras está em unidades no CSS.

O próximo passo

Comece pequeno no seu projeto: declare em :root as três ou quatro cores que mais se repetem, troque as ocorrências por var() e confira no DevTools que nada mudou de aparência. Só depois disso crie o segundo tema — ele vai custar um seletor.

Se o próximo passo é montar o design system inteiro em cima disso, o @theme do Tailwind mostra a mesma ideia industrializada: as classes utilitárias da versão 4 leem custom properties. E se você quer o mapa completo de onde esta lição entra, o guia de CSS tem a ordem de estudo da trilha de CSS do começo ao fim.

  • css
  • variáveis
  • custom properties
  • tema
  • dark mode

Perguntas frequentes

Variável CSS funciona em todos os navegadores hoje?
Sim. O Firefox tem desde 2014, Chrome e Safari desde 2016, o Edge desde 2017 — não precisa de fallback nem de polyfill. O que é recente é a regra @property, que só ficou disponível em todos os navegadores modernos em 2024, quando o Firefox entrou; para ela, vale checar o suporte do seu público.
Posso usar Sass e variável CSS no mesmo projeto?
Pode, e é o arranjo mais comum. Deixe no Sass o que só o build precisa saber — breakpoints usados em media query, mapas que geram utilitários, cálculos de cor — e declare como custom property tudo que precisa mudar depois que a página carregou.
Declarar em :root ou em html dá no mesmo?
Os dois selecionam o mesmo elemento, mas a especificidade é diferente — :root é uma pseudo-classe (0,1,0) e html é um seletor de tipo (0,0,1). Na prática use :root, porque é a convenção que todo mundo lê como "o topo do documento".
Preciso escrever fallback em todo var()?
Não, e escrever em todos costuma esconder erro de digitação. Use fallback onde o valor é mesmo opcional, como um componente que aceita ser personalizado de fora. Nos tokens do seu design system, deixe sem — assim um nome errado aparece em vez de virar um cinza silencioso.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Chromium 151 (Playwright 1.62.1) e Dart Sass 1.103.1, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — Using CSS custom properties (variables) — developer.mozilla.org
  2. CSS Custom Properties for Cascading Variables Module Level 1 — drafts.csswg.org
  3. MDN — @property — developer.mozilla.org

Continue por aqui