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

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.

Rodolfo Mori12 min de leitura

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:

html
<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:

js
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'));
{ livroId: '8571', precoCentavos: '4990', estoque: '7', secao: 'ficcao-brasileira' } 8571 4990 4990

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:

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>
js
const el = document.getElementById('teste');

for (const attr of el.attributes) console.log(' ', attr.name, '=', JSON.stringify(attr.value));
console.log({ ...el.dataset });
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" { livroId: '1', 'isbn-13': '2', url: '3', '2fa': '4', X: '5', 'x-': '6', 'ação': '7', meu_campo: '8', '': '9' }

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-centavosprecoCentavos

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.

data- preco -c entavos preco C entavos atributo no HTML: data-preco-centavos chave no dataset: precoCentavos prefixo descartado traço some, letra minúscula sobe

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:

js
// 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);
id = "teste" data-livro-id = "8571" data-Editora = "Nova Fronteira" dataset: { livroId: '8571' } outerHTML: <li id="teste" data-livro-id="8571" data-Editora="Nova Fronteira">livro</li>

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:

js
// 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'));
<li class="livro" data-estoque="6" data-secao="ficcao-brasileira" data-preco-centavos="4990"></li> string "6" tem data-livro-id? false

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:

js
livro.dataset['preco-centavos'] = '4990';
node:internal/modules/run_main:107 triggerUncaughtException( ^ [DOMException [SyntaxError]: 'preco-centavos' is not a valid property name]

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:

js
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);
4990100 5090 estoque do segundo: "0" o if disse: tem estoque Number(...) resolve: false

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:

css
.livro[data-estoque="0"] .add { opacity: 0.4; }
.livro[data-secao~="tecnico"] { border-left: 4px solid teal; }
.livro[data-secao^="ficcao"] { background: seashell; }
js
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));
esgotados: 1 tecnicos: 1 ficcao: 1 opacidade do botao disponivel: "1" opacidade do botao esgotado: "0.4"

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.

js
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);
[data-livro-id] -> 1 [data-LIVRO-ID] -> 1 [data-secao="Ficcao"] -> 1 [data-secao="ficcao"] -> 0 [data-secao="ficcao" i] -> 1

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:

js
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]);
antes: fechado -> none depois: aberto -> block <article class="ficha" data-estado="aberto">

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:

js
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);
livro 8571 adicionado por 49.9 livro 9214: esgotado, nao entra no carrinho carrinho: [ { id: '8571', precoCentavos: 4990 } ]

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:

js
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'));
file:///private/tmp/livraria-beco/etiqueta.mjs:9 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.

js
// 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));
R$ NaN

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:

html
<li class="livro" data-livro="{"titulo":"Grande Sertao","preco":4990}">x</li>
js
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 });
class = "livro" data-livro = "{" titulo":"grande = "" sertao","preco":4990}" = "" dataset: { livro: '{' }

O data-livro ficou valendo {, e o resto do JSON virou dois atributos inventados com nomes impronunciáveis. Para funcionar, cada " precisa virar &quot; (ou o atributo usar aspas simples):

js
const certo = `<li class="livro" data-livro="{&quot;titulo&quot;:&quot;Grande Sertao&quot;,&quot;preco&quot;: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));
valor cru: {"titulo":"Grande Sertao","preco":4990} parseado: { titulo: 'Grande Sertao', preco: 4990 }

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.

js
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');
200 livros com o registro inteiro em data-*: 68799 bytes 200 livros com data-livro-id apenas: 8999 bytes diferenca: 59800 bytes razao: 7.6x

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.

js
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);
antes: false -> none depois: true -> block

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.

  • html
  • data attributes
  • dataset
  • javascript
  • css

Perguntas frequentes

Posso usar data-* em qualquer tag?
Sim. É um atributo global, igual a class e id, então vale em qualquer elemento HTML — inclusive nos vazios, como img, input e br. A única exigência é o nome seguir a regra do prefixo.
O Google lê o conteúdo de um data attribute?
Ele lê o HTML inteiro, então o valor está lá e é visível no código-fonte. Mas data-* não é conteúdo indexável nem dado estruturado: para o buscador entender preço, avaliação ou estoque, o formato é JSON-LD em script type="application/ld+json".
data-testid é boa prática em teste automatizado?
É a forma mais estável de apontar para um elemento em teste de interface. Classe muda quando o designer mexe no CSS e texto muda quando o produto troca a palavra; um data-testid só muda quando alguém decide mudá-lo. Escolha um prefixo (data-teste, data-testid) e use sempre o mesmo.
Em React e Vue eu escrevo data-* do mesmo jeito?
Escreve. Diferente de class, que no JSX vira className, os atributos data-* são passados com o nome literal e chegam no DOM como você escreveu. Na hora de ler, o elemento continua expondo dataset.

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 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

  1. HTML Standard — Embedding custom non-visible data with the data-* attributes — html.spec.whatwg.org
  2. HTML Standard — HTMLElement.dataset e DOMStringMap — html.spec.whatwg.org
  3. MDN — Atributos globais data-* — developer.mozilla.org

Continue por aqui