If you build interfaces long enough, you eventually need a place to put small bits of metadata that belong to the page but are not part of the visible content. That is what HTML data-* attributes are for. They let you attach custom, standards-compliant information to any element without inventing a new tag, overloading a class name, or hiding state in the DOM by accident.
Used well, data-* attributes are a clean bridge between HTML and JavaScript. They are useful for state, configuration, analytics hooks, and test selectors. Used poorly, they become a dumping ground for values that should live in state, props, or accessibility attributes instead.
The short version
data-* attributes are custom attributes that start with data-. The browser keeps them in the HTML, exposes them through the DOM, and makes them available in JavaScript through element.dataset.
That means the attribute is visible in markup, easy to query, and easy to read or update from script. It is also intentionally generic: the browser does not assign special meaning to data-*, so your application gets to define the meaning.
How the browser exposes them
Here is a simple example of an element with three custom attributes.
<button
data-state="open"
data-panel-id="checkout"
data-index-number="3"
>
Open cart
</button>
In JavaScript, those attributes show up on dataset using camelCase names.
const button = document.querySelector('[data-panel-id="checkout"]');
console.log(button.dataset.state); // "open"
console.log(button.dataset.panelId); // "checkout"
console.log(button.dataset.indexNumber); // "3"
The conversion rule is simple. Hyphenated names become camelCase property names, and the values are always strings.
Naming rules that matter
data-* attributes are flexible, but there are still a few rules worth remembering.
| HTML attribute | dataset property | Notes |
|---|---|---|
| data-user-id | dataset.userId | Good for stored IDs or references |
| data-long-name | dataset.longName | Hyphens become camelCase |
| data-api-version | dataset.apiVersion | Values are still strings |
If you need a number or boolean, parse it yourself. dataset.count is not a number just because it looks like one in HTML.
Where data-* is actually useful
The best use cases are the ones where you need metadata, not semantics.
1 Test selectors
Use stable hooks like data-testid or data-cy when the selector should survive visual redesigns.
2 UI state
Store lightweight state like data-state="open" or data-active="true" when the value belongs to the element.
3 Analytics hooks
Attach tracking metadata to buttons, links, or cards without hardcoding it into the visible label.
A practical event delegation example
One of the cleanest uses of data-* is event delegation. Instead of attaching separate handlers to every button, you can read the action from the clicked element.
document.addEventListener('click', (event) => {
const actionButton = event.target.closest('[data-action]');
if (!actionButton) {
return;
}
switch (actionButton.dataset.action) {
case 'save':
saveDraft();
break;
case 'delete':
deleteItem(actionButton.dataset.itemId);
break;
default:
break;
}
});
This pattern works well because the HTML describes the intent of the control, while JavaScript decides what to do with that intent.
React example
In React, data-* attributes are often used on buttons, rows, and cards that need a stable metadata channel.
type ArticleCardProps = {
id: string;
title: string;
onOpen: (id: string) => void;
};
export function ArticleCard({ id, title, onOpen }: ArticleCardProps) {
return (
<button
data-article-id={id}
data-card-role="featured"
onClick={(event) => onOpen(event.currentTarget.dataset.articleId ?? id)}
>
{title}
</button>
);
}
Notice the use of event.currentTarget. That is usually safer than event.target because it points at the element that owns the handler.
What data-* should not replace
data-* attributes are convenient, but they are not a replacement for the right HTML feature.
data-* vs class vs aria-*
The easiest way to choose is to ask what the attribute is for.
| Attribute type | Primary job | Typical example |
|---|---|---|
| data-* | Custom metadata for JavaScript | data-row-id="42" |
| class | Styling and visual state | class="is-open" |
| aria-* | Accessibility semantics | aria-expanded="true" |
That division keeps the code easier to reason about. Classes tell the browser and CSS what to render. ARIA tells assistive technology what the control means. data-* gives your scripts a safe place to store application-specific metadata.
Common mistakes
The most common mistakes are predictable.
1 Treating values as typed
dataset always returns strings, so parse numbers and booleans explicitly.
2 Using them for everything
Not every component needs custom DOM metadata. Keep the DOM small and intentional.
3 Ignoring naming clarity
Prefer names that explain the role of the value, not the implementation detail that produced it.
The real rule of thumb
Use data-* when the element needs custom metadata that JavaScript can read directly, especially for testing, event delegation, analytics, or lightweight UI state. Do not use it when a class, an ARIA attribute, or normal application state is a better fit.
That simple boundary keeps HTML readable, JavaScript predictable, and accessibility concerns separate from implementation details.