There's a pattern I've seen in codebases everywhere: developers need to attach custom data to a DOM element, so they encode it into class names (class="item item-42 status-active") or store it in hidden inputs, or add a JavaScript object keyed by some ID. All of these are workarounds. data-* attributes are the right tool for the job, and they're built right into the HTML Living Standard.

What data-* Attributes Are

The data-* attribute specification lets you attach any custom key-value data to any HTML element. The name must start with data-, followed by at least one character. The value is always a string.

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>

They don't affect rendering at all. They don't add any classes, styles, or layout behaviour. They're purely metadata, readable by JavaScript and CSS, ignored by the browser's rendering engine.

Naming Conventions

Data attribute names are case-insensitive in HTML (the spec lowercases them), and they map to camelCase in JavaScript's dataset API. The conversion is automatic:

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)

The rule: hyphens in HTML become camelCase in JavaScript. data-item-countdataset.itemCount. This conversion is bidirectional — writing to dataset.itemCount updates data-item-count in the DOM.

Reading and Writing with dataset

The dataset property is a DOMStringMap — it behaves like a regular JavaScript object for reading and writing, and the DOM updates live:

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');
}
Remember: All data-* values are strings in HTML. Always parse numbers with parseInt() or parseFloat(), and booleans by comparing to the string 'true'. Treating them as their original type without parsing is a common source of bugs.

Event Delegation with data-* Attributes

One of the most practical uses of data attributes: event delegation. Instead of attaching individual event listeners to dozens of buttons, attach one listener to a parent and read the action from the element's dataset:

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`);
}

This pattern scales to any number of items. event.target.closest() walks up the DOM from the clicked element to find the nearest matching ancestor — so clicking the button text (a child text node) still finds the button. It's cleaner than comparing event.target.tagName and more reliable than checking exact element matches.

Using data-* with CSS attr()

CSS can read data attribute values using the attr() function in content declarations. This is useful for tooltips, badges, and labels that come from data rather than markup:

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

Note that attr() currently only works in content properties — you can't use it for width, color, or other CSS properties yet (though the CSS Values Level 5 spec is working on expanding this). For now, attribute selectors ([data-status="complete"]) are the practical way to vary styles based on data attribute values.

A Complete Interactive Example — Accordion with data-*

Here's a real-world accordion component driven entirely by data attributes for state. No class toggling for behaviour, just data attributes — classes stay for styling only:

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

The state lives in data-open on the container. CSS reads that attribute to rotate the arrow indicator. JavaScript toggles the attribute and manages aria-expanded and hidden. Each layer does exactly one job, and they compose cleanly.

Performance and Accessibility Notes

  • Performance. data-* attributes are part of the DOM and reading them is very fast — essentially the same as reading any other attribute. For extremely hot code paths (thousands of reads per frame), a JavaScript Map keyed by element reference is faster. But for typical UI code, dataset is fine.
  • Data-* is for scripts, not semantics. These attributes are invisible to screen readers unless you explicitly bind them to ARIA states. Don't use data-role or data-label to try to convey semantics — use proper HTML elements and WAI-ARIA attributes for that.
  • Security. Never put sensitive data (tokens, passwords, PII) in data attributes. They're visible in the browser's element inspector and readable by any JavaScript on the page, including third-party scripts.
  • Querying. You can select elements by data attribute with CSS attribute selectors: document.querySelectorAll('[data-action="delete"]') — this works exactly as you'd expect and is very useful for bulk operations.

Tools for HTML Development

When working with HTML, HTML Formatter keeps your attribute-heavy markup readable, and HTML Validator catches errors like duplicate IDs that can break aria-controls associations. For live editing and previewing component patterns like the accordion above, the HTML Editor lets you see changes in real time without a build step.

Wrapping Up

Data attributes are the clean, official way to attach custom metadata to DOM elements. They beat class-name hacks, hidden inputs, and external JavaScript maps for most use cases. The dataset API makes reading and writing them straightforward, the camelCase conversion is automatic, and they compose naturally with CSS attribute selectors and event delegation patterns. Just remember: data attributes are for script-readable metadata, not for accessibility semantics — that's what ARIA is for.