data attributes no HTML: guardar dado no próprio elemento
Como usar data-* para levar informação no markup, ler com dataset no JavaScript e selecionar por atributo no CSS, sem inventar atributo inválido.
Um atributo global data-* guarda uma informação personalizada no próprio
elemento HTML. O valor não aparece como conteúdo visual, pode ser lido e alterado
pelo JavaScript por meio de dataset e também pode participar de seletores CSS.
Pense numa etiqueta presa a uma caixa da estante. A caixa é o elemento; campos
como código e seção são os data-*; dataset é a forma de ler a etiqueta pelo
JavaScript. A etiqueta acompanha a caixa mesmo que a lista seja reordenada. No
DOM, todos esses valores continuam sendo strings e são públicos e editáveis —
servem para identificar e controlar a interface, não para guardar segredo ou
autorizar uma compra.
Todos os exemplos são o catálogo de uma livraria: cada li é um livro na
estante, com id, preço e estoque pendurados no próprio elemento.
Onde guardar o id do livro que o botão vai mandar para o carrinho
O botão “Adicionar ao carrinho” precisa saber qual livro. A informação
existe no servidor, mas quem clica é o navegador, e o clique só tem acesso ao
que está no DOM. As saídas ruins aparecem sozinhas: escrever id="livro-8571"
e fatiar a string depois, esconder um span com o número dentro, ou manter um
array em JavaScript paralelo à lista — que desencontra na primeira vez que
alguém filtra a estante.
O data- resolve pendurando o dado no elemento que já existe:
<ul class="catalogo">
<li class="livro"
data-livro-id="8571"
data-preco-centavos="4990"
data-estoque="7"
data-secao="ficcao-brasileira">
<h2>Grande Sertão: Veredas</h2>
<button type="button" class="add">Adicionar ao carrinho</button>
</li>
<li class="livro"
data-livro-id="9214"
data-preco-centavos="12900"
data-estoque="0"
data-secao="tecnico">
<h2>Estruturas de Dados em C</h2>
<button type="button" class="add">Adicionar ao carrinho</button>
</li>
</ul>Nada disso aparece na página. Para ler, o JavaScript usa a propriedade
dataset, que todo elemento tem:
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
const html = readFileSync(new URL('./catalogo.html', import.meta.url), 'utf8');
const { document } = new JSDOM(html).window;
const livro = document.querySelector('.livro');
console.log({ ...livro.dataset });
console.log(livro.dataset.livroId);
console.log(livro.dataset.precoCentavos);
console.log(livro.getAttribute('data-preco-centavos'));O dataset é um objeto só com os data-* daquele elemento — os outros
atributos ficam de fora. E repare que getAttribute('data-preco-centavos')
devolve exatamente a mesma coisa: dataset é conveniência, não mágica.
A regra do nome: data-, um traço e nada de maiúscula
A especificação pede quatro coisas do nome: começar com data-, ter pelo menos
um caractere depois do traço, ser um nome de atributo válido e não conter letra
maiúscula do alfabeto ASCII. Fora isso, vale quase tudo — e “quase” é onde as
pessoas se machucam.
Testei dez nomes de canto no mesmo elemento, escritos direto no HTML:
<li id="teste"
data-livro-id="1"
data-ISBN-13="2"
data-URL="3"
data-2fa="4"
data--x="5"
data-x-="6"
data-Ação="7"
data-meu_campo="8"
data-="9"
dado-preco="10">livro</li>const el = document.getElementById('teste');
for (const attr of el.attributes) console.log(' ', attr.name, '=', JSON.stringify(attr.value));
console.log({ ...el.dataset });Dez nomes, dez lições — e a última é a única que some do dataset:
| você escreveu | virou a chave | por quê |
|---|---|---|
data-livro-id |
livroId |
o traço antes de letra minúscula some e a letra sobe |
data-ISBN-13 |
'isbn-13' |
o parser rebaixou o nome; -1 não é traço + letra, então sobrevive |
data-URL |
url |
maiúscula no HTML nunca chega ao DOM |
data-2fa |
'2fa' |
chave válida, mas dataset.2fa é erro de sintaxe em JS |
data--x |
X |
o segundo traço é que foi consumido |
data-x- |
'x-' |
traço no fim não tem letra depois, então fica |
data-Ação |
'ação' |
o A cai para minúscula, mas ç e ã passam intactos |
data-meu_campo |
meu_campo |
underscore não é traço: nada some, nada sobe |
data- |
'' |
a spec exige um caractere depois do traço; o dataset expõe assim mesmo |
dado-preco |
— | não começa com data-, não entra no dataset |
O caso do data-ISBN-13 é o que mais pega gente boa. Você escreve ISBN-13
pensando em isbn13 e recebe isbn-13, que nem dá para ler com ponto: só
el.dataset['isbn-13']. A regra é mecânica — traço seguido de letra
minúscula — e 1 não é letra. Numa base de código de livraria, data-isbn
com o traço fora do nome evita o problema inteiro.
O data- sozinho merece uma nota: ele é a única linha da tabela que a
especificação reprova, porque não tem caractere nenhum depois do traço. Mas
reprovar não é bloquear — o algoritmo do dataset recorta os cinco primeiros
caracteres e aceita “o que sobrar, se sobrar”. Sobra nada, e nasce a chave
vazia. Um validador de HTML acusa; o navegador segue em frente.
O crachá vira chave: data-preco-centavos → precoCentavos
A conversão é a mesma sempre, e cabe em uma frase: tira o data-, e cada traço
seguido de letra minúscula desaparece deixando a letra em maiúscula. Todo o
resto passa intacto.
Existem dois jeitos de um atributo com cara de data- estar no DOM e mesmo
assim não aparecer no dataset. O primeiro é o dado-preco de cima, que
erra o prefixo e por isso nem chega a ser um data-. O segundo é mais raro e
mais confuso: um nome com maiúscula de verdade. Escrevendo HTML você nunca
consegue criar um, porque o parser rebaixa tudo; mas setAttributeNS não
rebaixa:
// um li novo, só com id="teste"
const el = document.getElementById('teste');
el.setAttribute('data-LIVRO-ID', '8571'); // rebaixa o nome
el.setAttributeNS(null, 'data-Editora', 'Nova Fronteira'); // não rebaixa
for (const attr of el.attributes) console.log(attr.name, '=', JSON.stringify(attr.value));
console.log('dataset:', { ...el.dataset });
console.log('outerHTML:', el.outerHTML);O data-Editora está no elemento, sai no outerHTML, aparece no inspetor — e o
dataset finge que ele não existe. Se algum dia um valor sumir sem explicação,
procure a maiúscula.
Escrever, trocar e apagar pelo dataset
O dataset não é só leitura. Atribuir cria ou sobrescreve o atributo, e
delete remove:
// o li desta seção entra assim, com dois atributos só:
// <li class="livro" data-livro-id="8571" data-estoque="7"></li>
const livro = document.querySelector('.livro');
livro.dataset.secao = 'ficcao-brasileira'; // cria data-secao
livro.dataset.estoque = 6; // sobrescreve, e vira string
livro.dataset.precoCentavos = 4990; // cria data-preco-centavos
delete livro.dataset.livroId; // remove data-livro-id
console.log(livro.outerHTML);
console.log(typeof livro.dataset.estoque, JSON.stringify(livro.dataset.estoque));
console.log('tem data-livro-id?', livro.hasAttribute('data-livro-id'));Três coisas para guardar: precoCentavos virou data-preco-centavos (a
conversão funciona nos dois sentidos), o número 6 virou a string "6" na
entrada, e o atributo escrito aparece no HTML de verdade — quem inspecionar a
página vê.
Na volta, a regra do traço vira uma armadilha. Se você tentar usar o nome do atributo como chave, o navegador recusa:
livro.dataset['preco-centavos'] = '4990';Node.js v24.16.0
A mensagem é literal: preco-centavos não é nome de propriedade válido, porque
traço-mais-minúscula é justamente o que a conversão usaria. Do lado do JS a
chave é sempre camelCase; do lado do HTML, sempre com traço.
Todo valor é string — inclusive o "0"
Atributo de HTML é texto. Não existe data- de número, de booleano ou de
array. Isso produz dois bugs clássicos, e o segundo é silencioso:
const [primeiro, segundo] = document.querySelectorAll('.livro');
console.log(primeiro.dataset.precoCentavos + 100);
console.log(Number(primeiro.dataset.precoCentavos) + 100);
console.log('estoque do segundo:', JSON.stringify(segundo.dataset.estoque));
if (segundo.dataset.estoque) {
console.log('o if disse: tem estoque');
} else {
console.log('o if disse: esgotado');
}
console.log('Number(...) resolve:', Number(segundo.dataset.estoque) > 0);O + concatenou em vez de somar: 4990100 é o preço com um 100 colado
atrás. E o livro esgotado passou pelo if, porque toda string que não é vazia
é verdadeira — o zero falso é o número 0, e não o texto "0" que veio do
atributo. Vale para "false" também. A correção é converter na leitura, com
Number e parseInt, nunca
confiar no valor cru dentro de uma condição.
O CSS enxerga o atributo: [data-estoque="0"]
Aqui é onde o data- deixa de ser um truque de JavaScript. O seletor de
atributo funciona com qualquer data-*, e o navegador reestiliza sozinho
quando o valor muda:
.livro[data-estoque="0"] .add { opacity: 0.4; }
.livro[data-secao~="tecnico"] { border-left: 4px solid teal; }
.livro[data-secao^="ficcao"] { background: seashell; }console.log('esgotados:', document.querySelectorAll('.livro[data-estoque="0"]').length);
console.log('tecnicos:', document.querySelectorAll('[data-secao~="tecnico"]').length);
console.log('ficcao:', document.querySelectorAll('[data-secao^="ficcao"]').length);
const [botaoA, botaoB] = document.querySelectorAll('.add');
console.log('opacidade do botao disponivel:', JSON.stringify(getComputedStyle(botaoA).opacity));
console.log('opacidade do botao esgotado: ', JSON.stringify(getComputedStyle(botaoB).opacity));O botão do livro esgotado ficou com opacity: 0.4 sem uma linha de JavaScript
de estilo. ~= casa uma palavra dentro de uma lista separada por espaço — no
teste os valores eram ficcao-brasileira classico e tecnico programacao — e
^= casa o começo do valor.
Uma pegadinha de caixa alta que vale conhecer: no nome do atributo o seletor não diferencia maiúscula de minúscula, mas no valor ele diferencia.
console.log('[data-livro-id] ->', document.querySelectorAll('[data-livro-id]').length);
console.log('[data-LIVRO-ID] ->', document.querySelectorAll('[data-LIVRO-ID]').length);
console.log('[data-secao="Ficcao"] ->', document.querySelectorAll('[data-secao="Ficcao"]').length);
console.log('[data-secao="ficcao"] ->', document.querySelectorAll('[data-secao="ficcao"]').length);
console.log('[data-secao="ficcao" i] ->', document.querySelectorAll('[data-secao="ficcao" i]').length);O elemento tinha data-secao="Ficcao". Buscar por "ficcao" não achou nada; a
bandeira i antes do colchete de fechamento liga a comparação sem caixa. Melhor
ainda: padronize os valores em minúscula na origem e nunca precise do i.
data-estado: um interruptor que o CSS e o JS compartilham
Esse é o padrão que mais rende no dia a dia. Em vez de o JavaScript adicionar e remover classe, ele muda um atributo de estado, e o CSS decide o resto:
const ficha = document.querySelector('.ficha');
const sinopse = document.querySelector('.sinopse');
console.log('antes: ', ficha.dataset.estado, '->', getComputedStyle(sinopse).display);
ficha.dataset.estado = ficha.dataset.estado === 'aberto' ? 'fechado' : 'aberto';
console.log('depois:', ficha.dataset.estado, '->', getComputedStyle(sinopse).display);
console.log(ficha.outerHTML.split('\n')[0]);O CSS por trás disso são duas linhas — .ficha[data-estado="fechado"] .sinopse { display: none; } e a irmã com aberto. A vantagem sobre classList é que
estado vira um valor por vez: um elemento não consegue estar
data-estado="aberto" e data-estado="fechado" ao mesmo tempo, enquanto duas
classes contraditórias convivem sem reclamar.
Um listener para o catálogo inteiro
Com o id pendurado no li, um único
addEventListener no ul atende a estante
toda, inclusive os livros que ainda vão ser carregados por uma busca:
const carrinho = [];
document.querySelector('.catalogo').addEventListener('click', (evento) => {
const botao = evento.target.closest('button.add');
if (!botao) return;
const livro = botao.closest('[data-livro-id]');
const { livroId, precoCentavos, estoque } = livro.dataset;
if (Number(estoque) === 0) {
console.log(`livro ${livroId}: esgotado, nao entra no carrinho`);
return;
}
carrinho.push({ id: livroId, precoCentavos: Number(precoCentavos) });
console.log(`livro ${livroId} adicionado por ${Number(precoCentavos) / 100}`);
});
// no teste, o clique foi disparado nos dois botões da estante
for (const botao of document.querySelectorAll('button.add')) {
botao.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
console.log('carrinho:', carrinho);O closest('[data-livro-id]') sobe do botão clicado até o ancestral que carrega
o dado — por isso o atributo mora no li, e não no botão. Esse casamento entre
data-* e
delegação de eventos é o motivo
número um de os data- existirem no HTML de aplicação.
O erro mais comum: dataset devolvendo undefined
Quase sempre a mesma história: o HTML tem data-livroid (sem traço) e o
JavaScript pede dataset.livroId. A chave não existe, e o undefined só
aparece na linha seguinte:
import { JSDOM } from 'jsdom';
const { document } = new JSDOM(
'<li class="livro" data-livroid="8571" data-precocentavos="4990"></li>'
).window;
const livro = document.querySelector('.livro');
console.log('etiqueta:', livro.dataset.livroId.padStart(8, '0'));TypeError: Cannot read properties of undefined (reading ‘padStart’) at file:///private/tmp/livraria-beco/etiqueta.mjs:9:48 at ModuleJob.run (node:internal/modules/esm/module_job:439:25) at async node:internal/modules/esm/loader:633:26 at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)
Node.js v24.16.0
Esse é o clássico
Cannot read properties of undefined,
e a versão de data-* dele tem três causas, nessa ordem de frequência: traço
faltando no HTML, camelCase errado no JS (dataset.livroID), ou o
querySelector pegando o elemento errado — o botão, e não o li que tem o
atributo.
Quando o valor entra num cálculo, o erro nem explode: fica NaN circulando
pela página.
// mesmo li de cima, que tem data-precocentavos e não data-preco-centavos
const centavos = livro.dataset.precoCentavos;
console.log('R$ ' + (centavos / 100).toFixed(2));Diagnóstico de dez segundos: imprima console.log({ ...elemento.dataset }) e
compare as chaves com o que você está pedindo. As duas listas lado a lado
mostram o traço faltando na hora.
O limite: data-* não é banco de dados
Como o valor é texto e não tem tamanho máximo na especificação, a tentação é serializar o registro inteiro num atributo só. Duas medições explicam por que isso sai caro.
A primeira é de sintaxe. Aspas duplas dentro de um atributo delimitado por aspas duplas quebram o markup — e o parser não avisa, ele improvisa:
<li class="livro" data-livro="{"titulo":"Grande Sertao","preco":4990}">x</li>const quebrado = `<li class="livro" data-livro="{"titulo":"Grande Sertao","preco":4990}">x</li>`;
const livro = new JSDOM(quebrado).window.document.querySelector('.livro');
for (const attr of livro.attributes) console.log(attr.name, '=', JSON.stringify(attr.value));
console.log('dataset:', { ...livro.dataset });O data-livro ficou valendo {, e o resto do JSON virou dois atributos
inventados com nomes impronunciáveis. Para funcionar, cada " precisa virar
" (ou o atributo usar aspas simples):
const certo = `<li class="livro" data-livro="{"titulo":"Grande Sertao","preco":4990}"></li>`;
const livro = new JSDOM(certo).window.document.querySelector('.livro');
console.log('valor cru: ', livro.dataset.livro);
console.log('parseado: ', JSON.parse(livro.dataset.livro));A segunda medição é de peso. Gerei a mesma estante de 200 livros de dois
jeitos: com os dez campos do registro em data-* e só com data-livro-id.
const registro = {
id: 8571,
titulo: 'Grande Sertao: Veredas',
autor: 'Joao Guimaraes Rosa',
editora: 'Nova Fronteira',
isbn: '9788520925539',
paginas: 624,
precoCentavos: 4990,
estoque: 7,
secao: 'ficcao-brasileira',
sinopse: 'Riobaldo conta a travessia do sertao a um interlocutor que nunca fala.',
};
const tudoNoDataset = Array.from({ length: 200 }, (_, i) =>
`<li class="livro" ${Object.entries({ ...registro, id: registro.id + i })
.map(([campo, valor]) => `data-${campo.toLowerCase()}="${valor}"`)
.join(' ')}></li>`
).join('\n');
const soOId = Array.from({ length: 200 }, (_, i) =>
`<li class="livro" data-livro-id="${registro.id + i}"></li>`
).join('\n');
const bytes = (s) => Buffer.byteLength(s, 'utf8');
console.log('200 livros com o registro inteiro em data-*:', bytes(tudoNoDataset), 'bytes');
console.log('200 livros com data-livro-id apenas: ', bytes(soOId), 'bytes');
console.log('diferenca:', bytes(tudoNoDataset) - bytes(soOId), 'bytes');
console.log('razao:', (bytes(tudoNoDataset) / bytes(soOId)).toFixed(1) + 'x');São 58 KB a mais de HTML trafegando em toda visita, para dados que a página em
geral só precisa depois de um clique. Some a isso que tudo ali é público e
editável pelo inspetor: data-preco-centavos é uma dica de renderização, nunca
o preço em que você confia para fechar o pedido, e data-papel="admin" não é
controle de acesso — é um convite. A conferência é sempre do lado do servidor.
A régua que uso: data-* guarda identificador e estado de interface. O
registro fica onde ele já mora — no servidor, ou num objeto em JavaScript
indexado por esse mesmo id.
Antes de inventar um data-, veja se já existe atributo para isso
Metade dos data- que vejo em revisão duplica um atributo que o HTML já tem, e
que faz mais do que guardar texto:
| em vez de | use | porque |
|---|---|---|
data-valor num option ou input |
value |
é o que o formulário envia sozinho |
data-aberto="true" num botão |
aria-expanded |
o leitor de tela anuncia; o CSS seleciona igual |
data-invisivel="true" |
hidden |
esconde de verdade, inclusive da leitura assistiva |
data-desativado |
disabled |
bloqueia clique e foco sem JavaScript |
data-link numa div clicável |
href num a |
ganha teclado, foco e abrir em nova aba |
data-livro com o registro inteiro |
id + objeto em JS | atributo é texto; objeto tem tipo |
O caso do aria-expanded é o mais direto: ele serve de estado para o CSS
exatamente como um data-estado serviria, e ainda informa quem usa leitor de
tela.
const botao = document.querySelector('.alternar');
const sinopse = document.querySelector('.sinopse');
console.log('antes: ', botao.getAttribute('aria-expanded'), '->', getComputedStyle(sinopse).display);
botao.setAttribute('aria-expanded', String(botao.getAttribute('aria-expanded') !== 'true'));
console.log('depois:', botao.getAttribute('aria-expanded'), '->', getComputedStyle(sinopse).display);Mesmo resultado visual do data-estado, com uma informação a mais para quem
não enxerga a tela — o assunto da lição de
acessibilidade em HTML. O data-* continua
sendo a resposta certa quando não existe atributo padrão para o que você
precisa dizer: o id do livro, a seção do catálogo, o passo do formulário, o
alvo de um teste automatizado.
O que vem depois
Escolha uma convenção de nomes agora e repita nela o projeto inteiro: tudo em
minúscula, traço só entre palavras, data- + o nome do recurso + o campo
(data-livro-id, data-livro-secao). Metade dos bugs desta lição some com
essa disciplina.
O próximo passo natural é o
guia de HTML, que mostra onde os atributos de dados entram
no mapa maior, e a trilha de HTML para seguir na ordem —
com formulários e acessibilidade, que são justamente onde os data-* mais
convivem com atributos padrão.
Monte três itens com data-produto-id e data-estoque, selecione apenas os que
têm estoque zero com CSS e leia o id clicado por event.target.closest no
JavaScript. Confirme no console que dataset.estoque tem tipo string e faça a
conversão antes da comparação numérica. Depois altere o atributo no DevTools:
se isso mudaria uma regra de negócio, a regra está no lugar errado e deve ir para
o servidor.
Perguntas frequentes
Posso usar data-* em qualquer tag?
O Google lê o conteúdo de um data attribute?
data-testid é boa prática em teste automatizado?
Em React e Vue eu escrevo data-* do mesmo jeito?
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 Node 24.16.0 com jsdom 30.0.1, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- HTML Standard — Embedding custom non-visible data with the data-* attributes — html.spec.whatwg.org
- HTML Standard — HTMLElement.dataset e DOMStringMap — html.spec.whatwg.org
- MDN — Atributos globais data-* — developer.mozilla.org


