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.
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.
: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:
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:
: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%; }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.
: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); }.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:
$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:
npx sass --no-source-map painel.scss:painel.css
grep -o "#0f766e" painel.css | wc -l
grep -c "cor-marca" painel.cssA 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:
/* _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:
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.cssSã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):
: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:
: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:
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:
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:
: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:
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ó.
Quem declara a variável no filho e tenta ler no pai descobre isso do jeito difícil:
.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); }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:
: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; }<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>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 lê só o atributo style inline;
getComputedStyle lê o valor que a cascata resolveu.
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);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:
@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.
.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:
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:
: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; } }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.
Perguntas frequentes
Variável CSS funciona em todos os navegadores hoje?
Posso usar Sass e variável CSS no mesmo projeto?
Declarar em :root ou em html dá no mesmo?
Preciso escrever fallback em todo var()?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
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
- MDN — Using CSS custom properties (variables) — developer.mozilla.org
- CSS Custom Properties for Cascading Variables Module Level 1 — drafts.csswg.org
- MDN — @property — developer.mozilla.org


