Hay un patrón que he visto en bases de código por todas partes: los desarrolladores necesitan adjuntar datos personalizados a un elemento DOM, así que los codifican en nombres de clase (class="item item-42 status-active"), los almacenan en entradas ocultas, o agregan un objeto JavaScript indexado por algún ID. Todas estas son soluciones alternativas. Los atributos data-* son la herramienta adecuada para el trabajo, y están integrados directamente en el Estándar HTML Living Standard.

Qué son los atributos data-*

La especificación del atributo data-* te permite adjuntar cualquier dato clave-valor personalizado a cualquier elemento HTML. El nombre debe comenzar con data-, seguido de al menos un carácter. El valor siempre es una cadena de texto.

html
<!-- data-* attributes: custom data right on the element -->
<button
  data-action="delete"
  data-item-id="284"
  data-item-name="Summer Newsletter"
  data-confirm="true"
>
  Delete
</button>

<li
  data-product-id="SKU-9921"
  data-price="29.99"
  data-in-stock="true"
  data-category="electronics"
>
  Wireless Keyboard
</li>

No afectan el renderizado en absoluto. No agregan clases, estilos ni comportamiento de diseño. Son puramente metadatos, legibles por JavaScript y CSS, ignorados por el motor de renderizado del navegador.

Convenciones de nomenclatura

Los nombres de atributos de datos no distinguen entre mayúsculas y minúsculas en HTML (la especificación los convierte a minúsculas), y se mapean a camelCase en la API dataset de JavaScript. La conversión es automática:

html
<!-- HTML attribute names use kebab-case -->
<div
  data-user-id="42"
  data-first-name="Alice"
  data-account-type="premium"
  data-last-login-at="2026-04-16T09:00:00Z"
>
</div>
js
// JavaScript dataset uses camelCase (automatic conversion)
const el = document.querySelector('div');

el.dataset.userId;       // "42"       (from data-user-id)
el.dataset.firstName;    // "Alice"    (from data-first-name)
el.dataset.accountType;  // "premium"  (from data-account-type)
el.dataset.lastLoginAt;  // "2026-04-16T09:00:00Z" (from data-last-login-at)

La regla: los guiones en HTML se convierten en camelCase en JavaScript. data-item-countdataset.itemCount. Esta conversión es bidireccional — escribir en dataset.itemCount actualiza data-item-count en el DOM.

Lectura y escritura con dataset

La propiedad dataset es un DOMStringMap — se comporta como un objeto JavaScript normal para lectura y escritura, y el DOM se actualiza en tiempo real:

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

// Reading
const productId  = card.dataset.productId;  // "SKU-9921"
const price      = parseFloat(card.dataset.price); // 29.99 (remember: always a string, parse as needed)
const inStock    = card.dataset.inStock === 'true'; // convert string to boolean

// Writing (updates the DOM attribute in real time)
card.dataset.price   = '24.99';       // sets data-price="24.99"
card.dataset.inStock = 'false';       // sets data-in-stock="false"
card.dataset.badgeText = 'Sale';      // adds data-badge-text="Sale" (new attribute)

// Deleting
delete card.dataset.badgeText;        // removes data-badge-text from the element

// Checking existence
if ('productId' in card.dataset) {
  console.log('This is a product card');
}
Recuerda: Todos los valores data-* son cadenas de texto en HTML. Siempre analiza los números con parseInt() o parseFloat(), y los booleanos comparándolos con la cadena 'true'. Tratarlos como su tipo original sin análisis es una fuente común de errores.

Delegación de eventos con atributos data-*

Uno de los usos más prácticos de los atributos de datos: la delegación de eventos. En lugar de adjuntar escuchadores de eventos individuales a decenas de botones, adjunta uno al padre y lee la acción desde el dataset del elemento:

html
<ul id="task-list">
  <li data-task-id="101" data-status="pending">
    Write article draft
    <button data-action="complete" data-task-id="101">Complete</button>
    <button data-action="delete"   data-task-id="101">Delete</button>
  </li>
  <li data-task-id="102" data-status="pending">
    Review pull request
    <button data-action="complete" data-task-id="102">Complete</button>
    <button data-action="delete"   data-task-id="102">Delete</button>
  </li>
</ul>
js
const taskList = document.getElementById('task-list');

// One listener handles all buttons — even ones added dynamically later
taskList.addEventListener('click', (event) => {
  const button = event.target.closest('button[data-action]');
  if (!button) return; // click was somewhere else in the list

  const action = button.dataset.action;
  const taskId = button.dataset.taskId;

  switch (action) {
    case 'complete':
      markTaskComplete(taskId);
      break;
    case 'delete':
      deleteTask(taskId);
      break;
  }
});

function markTaskComplete(id) {
  const item = taskList.querySelector(`li[data-task-id="${id}"]`);
  item.dataset.status = 'complete'; // CSS can react to this change
  console.log(`Task ${id} marked complete`);
}

function deleteTask(id) {
  const item = taskList.querySelector(`li[data-task-id="${id}"]`);
  item.remove();
  console.log(`Task ${id} deleted`);
}

Este patrón escala a cualquier número de elementos. event.target.closest() sube por el DOM desde el elemento clicado para encontrar el ancestro coincidente más cercano — así que hacer clic en el texto del botón (un nodo de texto hijo) aún encuentra el botón. Es más limpio que comparar event.target.tagName y más confiable que verificar coincidencias exactas de elementos.

Uso de data-* con CSS attr()

CSS puede leer valores de atributos de datos usando la función attr() en declaraciones content. Esto es útil para tooltips, insignias y etiquetas que provienen de datos en lugar del marcado:

html
<!-- Tooltip text from data attribute -->
<span class="tooltip" data-tip="Saves automatically every 30 seconds">
  Auto-save enabled
</span>

<!-- Status badge text from data attribute -->
<li class="task" data-status="in-progress">Review PR #412</li>
<li class="task" data-status="complete">Write unit tests</li>
css
/* Tooltip using attr() in CSS */
.tooltip {
  position: relative;
  cursor: help;
  text-decoration: underline dotted;
}

.tooltip::after {
  content: attr(data-tip);
  position: absolute;
  bottom: 100%;
  left: 0;
  background: #1a1a2e;
  color: white;
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 0.85rem;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s;
}

.tooltip:hover::after,
.tooltip:focus::after {
  opacity: 1;
}

/* Status badge via attr() */
.task::before {
  content: '[' attr(data-status) '] ';
  font-size: 0.75rem;
  font-weight: bold;
  text-transform: uppercase;
}

.task[data-status="complete"] { text-decoration: line-through; color: #6b7280; }
.task[data-status="in-progress"] { color: #d97706; }

Ten en cuenta que attr() actualmente solo funciona en propiedades content — no puedes usarlo para width, color u otras propiedades CSS todavía (aunque la especificación CSS Values Level 5 está trabajando en expandir esto). Por ahora, los selectores de atributos ([data-status="complete"]) son la forma práctica de variar estilos basados en valores de atributos de datos.

Un ejemplo interactivo completo — Acordeón con data-*

Aquí hay un componente de acordeón del mundo real impulsado completamente por atributos de datos para el estado. Sin alternancia de clases para el comportamiento, solo atributos de datos — las clases permanecen solo para el estilo:

html
<div id="faq-accordion" role="list">
  <div class="accordion-item" role="listitem" data-open="false">
    <button
      class="accordion-trigger"
      data-target="faq-1"
      aria-expanded="false"
      aria-controls="faq-1"
    >
      What is the difference between article and section?
    </button>
    <div id="faq-1" class="accordion-panel" data-panel hidden>
      <p>An <code>article</code> is self-contained content that could stand alone.
      A <code>section</code> is a thematic grouping within a larger context.</p>
    </div>
  </div>

  <div class="accordion-item" role="listitem" data-open="false">
    <button
      class="accordion-trigger"
      data-target="faq-2"
      aria-expanded="false"
      aria-controls="faq-2"
    >
      When should I use ARIA roles?
    </button>
    <div id="faq-2" class="accordion-panel" data-panel hidden>
      <p>Only when no native HTML element conveys the same semantics.
      Native elements should always be preferred.</p>
    </div>
  </div>
</div>
js
const accordion = document.getElementById('faq-accordion');

accordion.addEventListener('click', (event) => {
  const trigger = event.target.closest('.accordion-trigger');
  if (!trigger) return;

  const targetId = trigger.dataset.target;
  const panel    = document.getElementById(targetId);
  const item     = trigger.closest('.accordion-item');
  const isOpen   = item.dataset.open === 'true';

  // Toggle this item
  item.dataset.open       = isOpen ? 'false' : 'true';
  trigger.ariaExpanded    = isOpen ? 'false' : 'true';
  panel.hidden            = isOpen;
});
css
.accordion-trigger {
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  padding: 1rem;
  font-size: 1rem;
  cursor: pointer;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

/* Arrow rotates based on data-open state */
.accordion-item[data-open="false"] .accordion-trigger::after {
  content: '▼';
  transition: transform 0.2s;
}

.accordion-item[data-open="true"] .accordion-trigger::after {
  content: '▲';
}

.accordion-panel {
  padding: 0 1rem 1rem;
}

.accordion-panel[hidden] {
  display: none;
}

El estado vive en data-open en el contenedor. CSS lee ese atributo para rotar el indicador de flecha. JavaScript alterna el atributo y gestiona aria-expanded y hidden. Cada capa hace exactamente un trabajo, y se componen limpiamente.

Notas sobre rendimiento y accesibilidad

  • Rendimiento. Los atributos data-* son parte del DOM y leerlos es muy rápido — esencialmente igual que leer cualquier otro atributo. Para rutas de código extremadamente críticas (miles de lecturas por fotograma), un Map de JavaScript indexado por referencia de elemento es más rápido. Pero para código de UI típico, dataset está bien.
  • Data-* es para scripts, no para semántica. Estos atributos son invisibles para los lectores de pantalla a menos que los vincules explícitamente a estados ARIA. No uses data-role o data-label para intentar transmitir semántica — usa elementos HTML adecuados y los atributos WAI-ARIA para eso.
  • Seguridad. Nunca pongas datos sensibles (tokens, contraseñas, información personal) en atributos de datos. Son visibles en el inspector de elementos del navegador y legibles por cualquier JavaScript en la página, incluidos scripts de terceros.
  • Consultas. Puedes seleccionar elementos por atributo de datos con selectores de atributos CSS: document.querySelectorAll('[data-action="delete"]') — esto funciona exactamente como esperarías y es muy útil para operaciones masivas.

Herramientas para el desarrollo HTML

Al trabajar con HTML, el Formateador HTML mantiene tu marcado con muchos atributos legible, y el Validador HTML detecta errores como IDs duplicados que pueden romper las asociaciones aria-controls. Para edición en vivo y vista previa de patrones de componentes como el acordeón anterior, el Editor HTML te permite ver los cambios en tiempo real sin un paso de compilación.

Conclusión

Los atributos de datos son la forma limpia y oficial de adjuntar metadatos personalizados a elementos DOM. Superan los trucos de nombres de clase, las entradas ocultas y los mapas JavaScript externos para la mayoría de los casos de uso. La API dataset hace que leerlos y escribirlos sea sencillo, la conversión camelCase es automática, y se componen naturalmente con selectores de atributos CSS y patrones de delegación de eventos. Solo recuerda: los atributos de datos son para metadatos legibles por scripts, no para semántica de accesibilidad — para eso es ARIA.