Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Beginner+150 XP

HTML5 APIs Overview

data-* attributes, contenteditable, and the drag-and-drop API — the browser features beyond plain markup.

30 min August 2026
// Part 01 — data-* Custom Attributes

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.

Attaching custom data to elements
<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.

💡 Note
Why not just use a regular class or a made-up non-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.
// Part 02 — Reading data-* via dataset

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.

Reading data-* attributes with dataset
<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>
⚠️ Important
Every value read from dataset is always a string — even 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.
The truthy-string bug in practice, and the fix
<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.

Writing custom data back onto an element
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).

// Part 03 — data-* With CSS

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.

Styling based on a data-* attribute's value
<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.

// Part 04 — contenteditable

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.

A directly editable element
<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.

Capturing edits as they happen
<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>
⚠️ Important
innerHTML on a contenteditable element can contain messy, browser-inconsistent markup. Different browsers insert slightly different tags for the same user action — pressing Enter, for instance, might produce a new <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.
// Part 05 — contenteditable Gotchas

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.

What contenteditable does NOT give you for free
<!-- 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.

Bridging contenteditable content into a real form submission
<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>
🎯 Pro Tip
A useful rule of thumb: if the content you need to capture is genuinely simple plain text, a real <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.
// Part 06 — Drag-and-Drop Basics

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.

Making an element draggable
<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.

💡 Note
Most elements are 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.
// Part 07 — dragstart, dragover, drop

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.

The three essential drag-and-drop events
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 released

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

dragstart — recording what's being dragged
<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.

dragover — must call preventDefault or drop never fires
<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.

drop — completing the move
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
});
⚠️ Important
Forgetting preventDefault() in the dragover handler is, by a wide margin, the single most common drag-and-drop bug. Every other part of the implementation can be entirely correct, and the drop simply will not work — the item snaps back to its origin with no error in the console at all, since a rejected drop is the browser's intentional, silent default behavior, not a failure state.
// Part 08 — A Complete Example

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.

A minimal two-column drag-and-drop board
<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.

// Part 09 — Real World
💼 What This Looks Like at Work

A Task-Board Feature at a Portland Project-Management Startup

Scenario — Project-management SaaS, Portland · Feature bug report

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.

The buggy implementation
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."

The one-line fix
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.

// Part 10 — Misconceptions

Four Misconceptions About These APIs

✕ ""dataset values keep their original type — a data-count='5' reads back as the number 5""
Every value read through dataset is always a string, with no exceptions — data-count="5" reads back as the string "5", and data-active="true" reads back as the string "true", not a boolean. Any numeric or boolean comparison needs an explicit conversion first.
✕ ""contenteditable content is automatically included when its enclosing form submits""
It is not — contenteditable elements are not real form controls and are excluded from a form's natural submission entirely. Capturing the content requires JavaScript, typically copying it into a hidden input immediately before submission.
✕ ""draggable='true' alone is enough to make drag-and-drop actually work""
It only enables the browser's default pick-up/follow-cursor visual behavior. Making a drop actually do something requires JavaScript listening for dragstart, dragover (with preventDefault() called inside it), and drop.
✕ ""If the dragover highlight/visual feedback is working, the drop handler will fire too""
These are independent — dragover firing (and any CSS class toggling inside it) has nothing to do with whether drop will fire. Without an explicit preventDefault() call specifically inside the dragover handler, the browser rejects the drop and drop never fires, regardless of how correct the visual feedback looks.
// Part 11 — Interview Prep

5 Interview Questions — With Complete Answers

What naming convention translates a data-* attribute into a dataset property, and why does it matter?
A hyphenated attribute name has the data- prefix removed and is converted to camelCase — data-product-id becomes dataset.productId, data-in-stock becomes dataset.inStock. This matters because a data-* attribute must be written all-lowercase with hyphens at word boundaries in the HTML itself; writing data-productId directly in markup would not convert to dataset.productId the way you might expect.
Why is it important to know that every dataset value is always a string?
Because a truthy check like if (el.dataset.flag) is true for ANY non-empty string, including the string "false" — a very easy real bug. Numeric or boolean data-* values need explicit conversion (Number(...), or a strict === 'true' comparison) before being used in numeric or boolean logic.
Does contenteditable content get included when its enclosing form is submitted?
No — contenteditable elements are not real form controls and contribute nothing to a form's natural submission, unlike an <input> or <textarea>. Capturing the content requires JavaScript, most commonly copying the current innerHTML or innerText into a hidden input just before the form submits.
Walk through the minimum set of drag-and-drop events needed for a basic drag-and-drop feature to work, and what each does.
dragstart fires once on the dragged element, used to record what is being dragged (typically via event.dataTransfer.setData). dragover fires repeatedly on any element the drag passes over, and MUST call event.preventDefault() inside its handler or the drop will be rejected by the browser's default behavior. drop fires once on the target when the mouse is released, where the actual move/update logic happens, typically reading back whatever was stored in dataTransfer during dragstart.
A drag-and-drop drop zone shows the correct hover highlight, but dropping an item does nothing. What is the most likely cause?
The dragover event handler is very likely missing an explicit event.preventDefault() call. The browser's default response to dragover is to disallow a drop entirely, so without that call, the drop event silently never fires — regardless of whether the visual hover feedback (which is independent, usually just a CSS class toggle) is working correctly.
// Common Mistakes

HTML5 API Mistakes Beginners Make Constantly

Writing a data-* attribute in camelCase directly in the HTML
Broken: <div data-userId="4"></div> — this does not reliably map to dataset.userId the way a hyphenated one does. Fixed: <div data-user-id="4"></div>, which correctly reads back as element.dataset.userId.
Comparing a dataset boolean-like value without converting it first
Broken: if (card.dataset.completed) { ... } when data-completed="false" — this is always truthy since it is a non-empty string. Fixed: if (card.dataset.completed === 'true') { ... }.
Forgetting preventDefault() inside the dragover handler
Broken: a dragover listener with only visual-feedback logic and no event.preventDefault() call — the drop event never fires, with no console error at all. Fixed: call event.preventDefault() as the first line inside every dragover handler.
Expecting contenteditable content to appear in a form's submitted data automatically
Broken: relying on a <div contenteditable="true"> inside a <form> to be included on submit, the way a real input is. Fixed: manually copy its content (via innerText or innerHTML) into a hidden input immediately before the form submits.
Using data-* attributes for content that is actually part of the visible page structure
Broken: stashing genuinely visible, meaningful text — like a product's displayed price — only in a data-* attribute with nothing shown in the actual markup, making it invisible to search engines, screen readers, and users with JavaScript disabled or failing. Fixed: keep genuinely visible content in real markup; reserve data-* strictly for values JavaScript and CSS selectors need but that are not meant to be independently meaningful content on their own.
// Error Library

Errors and Bugs You Will Hit With These APIs

Drop silently does nothing — card snaps back to its origin column
Cause: The dragover event handler on the drop target does not call event.preventDefault(). The browser's default action for dragover is to disallow dropping entirely, so drop never fires — and there is no console error, since this is intentional default browser behavior, not a failure.
Fix: Add event.preventDefault() as the first line inside the dragover event handler.
TypeError: Cannot read properties of null (reading 'appendChild') — inside a drop handler
Cause: The card id retrieved from event.dataTransfer.getData('text/plain') does not match any element's data-card-id, usually because setData and getData used mismatched keys/format strings, or the id was never set at all inside dragstart.
Fix: Confirm the exact same string key ("text/plain" or a custom one) is used in both setData (dragstart) and getData (drop), and log the retrieved id to confirm it matches a real element before calling appendChild on the result.
if (el.dataset.someFlag) always evaluates true, even when the attribute is "false"
Cause: Every dataset value is a string. A non-empty string, including the literal text "false", is truthy in JavaScript — the mistake is treating a data-* boolean-like attribute as an actual boolean without converting it.
Fix: Use an explicit comparison: el.dataset.someFlag === 'true', or convert with a helper before using it in conditional logic.
A data-* attribute set with dataset in JavaScript does not appear to work in a CSS selector
Cause: A mismatch between the CSS attribute selector's expected hyphenated name and the camelCase name used when setting it via dataset in JavaScript — e.g. writing element.dataset.itemCount but the CSS selector expects [data-itemcount] instead of the correct [data-item-count].
Fix: Remember the conversion is always hyphen-case in HTML/CSS and camelCase in dataset — double check the CSS selector uses the correctly hyphenated attribute name.
contenteditable content is missing entirely from the data sent when a form is submitted
Cause: contenteditable elements are not real form controls and are never included in a form's natural GET/POST submission, regardless of where they are nested in the DOM.
Fix: Manually copy the contenteditable element's current content into a hidden <input> right before submission, using a submit event listener.

🎯 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, object
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...