HTML5 APIs Overview
data-* attributes, contenteditable, and the drag-and-drop API — the browser features beyond plain markup.
data-* Attributes — Attaching Your Own Data to an Element
Everything covered so far in this track has used HTML's built-in attributes — href, src, type, and the rest, each with meaning the browser itself understands. HTML5 also standardized a way to attach your own attributes, with names you invent, specifically so JavaScript can read them later. Any attribute prefixed with data- is guaranteed valid HTML, ignored entirely by the browser's own rendering, and reserved exclusively for this purpose — attaching arbitrary data to an element that only your own code cares about.
<button data-product-id="4471" data-in-stock="true">
Add to Cart
</button>
<li data-user-id="882" data-role="admin" class="user-row">
Maria Chen
</li>
<div data-tooltip="Click to expand" data-expanded="false">
Section Header
</div>A data-* attribute name can be almost anything you choose, following one specific naming rule worth knowing up front: it must be all lowercase, and any word boundary is written with a hyphen — data-product-id, not data-productId. This rule exists because of exactly how the attribute gets translated into a JavaScript property name, covered in the next part.
data- attribute instead? A plain made-up attribute like product-id="4471" (without the data- prefix) technically renders fine in every modern browser, but it is not valid HTML and will fail HTML validation, may collide with a genuine future HTML attribute of the same name, and is not guaranteed stable across browser versions. data-* is the only officially reserved, permanently safe namespace for exactly this purpose.The dataset Property — Reading Custom Attributes in JavaScript
Every DOM element exposes its data-* attributes through a single JavaScript property called dataset — an object where each key is the attribute name with its data- prefix stripped and converted from hyphen-case to camelCase automatically by the browser.
<button id="add-btn" data-product-id="4471" data-in-stock="true">
Add to Cart
</button>
<script>
const btn = document.getElementById('add-btn');
console.log(btn.dataset.productId); // "4471"
console.log(btn.dataset.inStock); // "true"
// Note: data-product-id → dataset.productId
// data-in-stock → dataset.inStock
// The hyphen is removed and the following letter is capitalized —
// exactly the same convention JavaScript already uses for
// multi-word property names.
</script>data-in-stock="true" comes back as the string "true", not the boolean true, and data-product-id="4471" comes back as the string "4471", not the number 4471. Comparing a dataset value directly against a boolean or number without converting it first (Number(...), or a strict string comparison) is a common source of bugs — if (btn.dataset.inStock) is always truthy, even when the value is literally the string "false", because any non-empty string is truthy in JavaScript.<div data-visible="false"></div>
<script>
const el = document.querySelector('div');
if (el.dataset.visible) {
console.log('This runs — even though the data says "false"!');
// "false" is a non-empty string, so it is truthy.
}
// The fix — an explicit string comparison
if (el.dataset.visible === 'true') {
console.log('This correctly does NOT run.');
}
</script>Writing to dataset — the same mechanism, in reverse
dataset is not read-only — assigning to it writes a new data-* attribute back onto the actual DOM element, visible if you inspect the element in DevTools, and the same camelCase-to-hyphen conversion happens automatically in reverse.
el.dataset.expanded = 'true';
// Sets the actual HTML attribute to: data-expanded="true"
el.dataset.itemCount = '12';
// Sets: data-item-count="12"This read/write pair — data-* attributes in the markup, dataset in JavaScript — is genuinely one of the most common patterns in real front-end code for tracking per-element state directly in the DOM itself, without a separate JavaScript data structure kept in sync with what is on screen, and it also plays a starring role in CSS selectors (Part 03) and in the drag-and-drop pattern covered later in this module (Part 08).
Using data-* Attributes as CSS Hooks
data-* attributes are not only readable from JavaScript — CSS attribute selectors can target them directly, which makes them a genuinely common way to drive visual state (an expanded panel, a selected tab, an active step in a wizard) without adding or removing CSS classes at all.
<div class="panel" data-state="collapsed">...</div>
<style>
.panel[data-state="collapsed"] {
max-height: 0;
overflow: hidden;
}
.panel[data-state="expanded"] {
max-height: 500px;
}
</style>
<script>
document.querySelector('.panel').dataset.state = 'expanded';
// Toggling ONE attribute value drives the entire visual state change —
// no separate CSS classes to add and remove in sync with each other.
</script>This pattern scales cleanly to more than two states — a data-step attribute holding "1", "2", or "3" on a multi-step form wizard, for instance, can drive entirely different CSS per step through [data-step="2"] .field-b { display: block; } style selectors, keeping the "what state is this component in" logic in exactly one place rather than scattered across several toggled class names.
contenteditable — Turning Any Element Into an Editable Field
The contenteditable attribute, set on essentially any element, makes its content directly editable by the user in the browser itself — no <input> or <textarea> involved. The browser handles the cursor, text selection, typing, and even basic rich-text behavior (Enter creating new paragraphs, for example) entirely on its own.
<div contenteditable="true">
This text can be clicked into and edited directly, right in the page.
</div>
<h2 contenteditable="true">Click this heading to rename it</h2>This is genuinely how a large share of real "inline editing" interfaces work — a document title you click to rename in place, a rich-text comment box, or a simplified content editor embedded in an admin dashboard, all commonly built directly on contenteditable rather than a heavier third-party rich-text library, at least for simpler cases.
Reading the edited content back out
contenteditable only makes the content editable in the browser's UI — it does nothing on its own to save that content anywhere. Reading the current state back out for saving requires JavaScript, most commonly via innerText or innerHTML, listening for the input event as the user types.
<div contenteditable="true" id="title">Untitled Document</div>
<script>
const title = document.getElementById('title');
title.addEventListener('input', () => {
console.log('Current content:', title.innerText);
// In a real app, this is where you'd debounce and send an
// autosave request to the server.
});
</script><div> in one browser and a <p> or a <br> in another. Production rich-text editors (like ones built on top of ContentEditable, e.g. many WYSIWYG libraries) do substantial normalization work specifically to paper over this inconsistency — it is one of the real reasons teams reach for an established library rather than hand-rolling a full rich-text editor directly on raw contenteditable.contenteditable Gotchas — And When to Reach for Something Else
contenteditable is genuinely useful for small, contained pieces of editable content, but it has real limitations worth knowing before reaching for it as a default choice over a plain form control.
<!-- No built-in form submission — contenteditable content is not
automatically included when a <form> submits, unlike a real input -->
<form>
<div contenteditable="true">This will NOT be sent on submit</div>
<button type="submit">Submit</button>
</form>
<!-- No built-in validation — required, maxlength, and pattern
simply do not apply to a contenteditable div at all -->
<div contenteditable="true" required></div>
<!-- "required" here has no effect whatsoever -->Because contenteditable content is not a real form field, it is entirely excluded from a form's natural submission — none of the built-in validation attributes covered in the two Forms modules apply to it, since those are input-element-specific. Any real persistence has to be handled manually with JavaScript, typically by copying the current content into a hidden <input> right before submission, or sending it directly with a fetch request.
<form id="post-form">
<div contenteditable="true" id="post-body"></div>
<input type="hidden" name="body" id="hidden-body">
<button type="submit">Publish</button>
</form>
<script>
document.getElementById('post-form').addEventListener('submit', () => {
// Copy the editable content into the hidden input just before
// submission, so it actually gets included in the form data.
document.getElementById('hidden-body').value =
document.getElementById('post-body').innerHTML;
});
</script><input> or <textarea> is almost always the better choice — you get built-in form submission, validation, and correct mobile keyboard behavior for free. Reach for contenteditable specifically when you need rich formatting (bold, links, headings) inline in the page itself, which a plain textarea cannot express at all.Native Drag-and-Drop — The draggable Attribute
HTML5 standardized a native drag-and-drop API, built into the browser itself, requiring no external library for basic use cases. The starting point is a single attribute: draggable="true", placed on any element you want a user to be able to pick up and drag with the mouse.
<div class="card" draggable="true" id="card-1">
Task: Write Q3 report
</div>On its own, draggable="true" lets the browser visually pick the element up on mouse-down and follow the cursor — but it does nothing beyond that visual behavior by itself. Making drag-and-drop actually do something (reorder a list, move a card between columns, accept a file) requires listening for a specific sequence of events, covered next.
draggable="false" by default — with one notable exception: images and links are draggable by default in most browsers (you may have noticed you can drag an image out of a web page onto your desktop without any code at all). Explicit draggable="true" is what enables the behavior for everything else, and draggable="false" can be used to explicitly turn it off where the default draggability of an image or link is unwanted.The Drag-and-Drop Event Sequence
A complete drag-and-drop interaction fires a specific sequence of events across two different elements: the item being dragged, and the area it can be dropped onto. Three events matter most for a basic implementation.
dragstart — fires ONCE, on the element being dragged, the instant the drag begins
dragover — fires REPEATEDLY, on the drop target, continuously while something
is being dragged over it (many times per second)
drop — fires ONCE, on the drop target, the instant the item is releaseddragstart is where you typically record what is being dragged, using the drag event's built-in dataTransfer object — a small data-passing mechanism purpose-built for exactly this handoff between the drag source and the eventual drop target.
<div class="card" draggable="true" id="card-1">Task: Write Q3 report</div>
<script>
const card = document.getElementById('card-1');
card.addEventListener('dragstart', (event) => {
event.dataTransfer.setData('text/plain', card.id);
// Storing the dragged element's id lets the drop handler
// later identify exactly which element to move.
});
</script>dragover fires continuously on any element the drag passes over, and — this is the single most commonly missed step — the browser's default behavior is to reject a drop entirely unless event.preventDefault() is called inside the dragover handler itself. Without it, the drop event never fires at all, no matter how correctly everything else is written.
<div class="column" id="in-progress-column"></div>
<script>
const column = document.getElementById('in-progress-column');
column.addEventListener('dragover', (event) => {
event.preventDefault(); // REQUIRED — without this, "drop" never fires
});
</script>Finally, drop fires on the target the instant the mouse button is released, and is where the actual move happens — reading back whatever was stored in dataTransfer during dragstart and using it to relocate the real DOM element.
column.addEventListener('drop', (event) => {
event.preventDefault();
const draggedId = event.dataTransfer.getData('text/plain');
const draggedElement = document.getElementById(draggedId);
column.appendChild(draggedElement); // moves the real element in the DOM
});Putting It Together — A Minimal Kanban-Style Drag-and-Drop Board
Combining everything from Parts 01–07 — data-* attributes, dataset, and the three-event drag sequence — produces a small but genuinely complete card-moving interaction, the same fundamental mechanism behind real task-board tools.
<div class="board">
<div class="column" data-status="todo" id="todo-column">
<h3>To Do</h3>
<div class="card" draggable="true" data-card-id="1">Write Q3 report</div>
<div class="card" draggable="true" data-card-id="2">Review PR #482</div>
</div>
<div class="column" data-status="done" id="done-column">
<h3>Done</h3>
</div>
</div>
<script>
// dragstart on every card — record which card is being dragged
document.querySelectorAll('.card').forEach((card) => {
card.addEventListener('dragstart', (event) => {
event.dataTransfer.setData('text/plain', card.dataset.cardId);
});
});
// dragover + drop on every column
document.querySelectorAll('.column').forEach((column) => {
column.addEventListener('dragover', (event) => {
event.preventDefault(); // required, or drop never fires
column.classList.add('drag-over'); // visual feedback while dragging over
});
column.addEventListener('dragleave', () => {
column.classList.remove('drag-over');
});
column.addEventListener('drop', (event) => {
event.preventDefault();
column.classList.remove('drag-over');
const cardId = event.dataTransfer.getData('text/plain');
const card = document.querySelector(`[data-card-id="${cardId}"]`);
column.appendChild(card);
console.log(`Card ${cardId} moved to status: ${column.dataset.status}`);
});
});
</script>Notice the data-card-id attribute doing double duty exactly as described earlier in this module — it identifies each card for the drag-and-drop logic in Part 07, and it is also the attribute a CSS selector or a query like document.querySelector('[data-card-id="1"]') can target directly, without any additional class or id needed purely for this purpose.
A Task-Board Feature at a Portland Project-Management Startup
A Portland-based project-management startup ships a Kanban board feature, letting users drag task cards between "To Do," "In Progress," and "Done" columns. During internal QA, a report comes in: dragging a card over the "Done" column highlights it correctly with a visual border, but releasing the mouse does nothing at all — the card snaps right back to its original column, with no error anywhere in the browser console.
document.querySelectorAll('.column').forEach((column) => {
column.addEventListener('dragover', () => {
column.classList.add('drag-over'); // visual highlight works fine
});
column.addEventListener('drop', (event) => {
const cardId = event.dataTransfer.getData('text/plain');
const card = document.querySelector(`[data-card-id="${cardId}"]`);
column.appendChild(card);
});
});What the engineer finds
Exactly the bug flagged in Part 07: the dragover handler never calls event.preventDefault(). Because the browser's default response to a dragover is to reject the drop outright, the visual highlight applied by the handler works perfectly fine — CSS classes have nothing to do with the drag-and-drop protocol itself — while the actual drop event silently never fires at all. The missing call is a single line, but its absence is completely invisible from the visual behavior alone, since the highlight gives every impression that the interaction is "almost working."
column.addEventListener('dragover', (event) => {
event.preventDefault(); // ← the missing line
column.classList.add('drag-over');
});The team adds a short comment directly above every dragover listener in the codebase afterward — // preventDefault() required here or drop() never fires — specifically because this exact bug had already cost an afternoon of debugging once, and the fix is trivial to miss again on the next drag-and-drop feature built by someone unfamiliar with this particular quirk of the API.
Four Misconceptions About These APIs
5 Interview Questions — With Complete Answers
HTML5 API Mistakes Beginners Make Constantly
Errors and Bugs You Will Hit With These APIs
🎯 Key Takeaways
- ✓data-* attributes let you attach arbitrary custom data to any element, in a namespace guaranteed valid and reserved by HTML5 specifically for this purpose.
- ✓The dataset property reads and writes data-* attributes from JavaScript, automatically converting between hyphen-case in HTML (data-item-id) and camelCase in JS (dataset.itemId).
- ✓Every value read through dataset is always a string — even "true" and "5" — and must be explicitly converted before being used as a real boolean or number.
- ✓data-* attributes work directly as CSS attribute selectors too, a common way to drive visual state changes by toggling one attribute value rather than several CSS classes.
- ✓contenteditable makes any element directly editable in the browser, but its content is excluded from a form's natural submission — capturing it requires manual JavaScript, typically via a hidden input.
- ✓draggable="true" alone only enables the visual pick-up behavior. A working drag-and-drop feature needs dragstart, dragover, and drop event listeners.
- ✓The single most common drag-and-drop bug: forgetting event.preventDefault() inside the dragover handler, which causes the browser to silently reject every drop with no console error.
- ✓dataTransfer.setData() (in dragstart) and dataTransfer.getData() (in drop) are the built-in mechanism for passing information about what is being dragged from the drag source to the eventual drop target.
What comes next
Module 12 covers embedding external content safely — iframe, the legacy embed and object elements, the sandbox attribute, cross-origin restrictions, and the clickjacking risk every embedded page introduces.
Module 12 → Embedding Content — iframe, embed, objectDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.