JavaScript DOM 100 Handbook

Created by Laurence Svekis · Open the interactive lab

Chapter 1 · Lesson 1

Your first living page

HTML describes the initial document. The browser builds a tree of objects from it. JavaScript can change those objects after the page loads, so the visible page need not match its original source. In this studio the HTML fixture is created before your code runs. Select the heading, then assign a new textContent value; the change is visible immediately. This is a DOM update, not a change to the HTML file on disk.

Your mission

Change #welcome to exactly Hello, DOM!

Starting HTML

<h1 id="welcome">Hello, explorer</h1>

Worked JavaScript

document.querySelector('#welcome').textContent = 'Hello, DOM!';

Hint: querySelector returns an element; textContent controls its text.

Explain: What did your script change?

The live document in the browser

Try next: Change the greeting again, run it and compare the DOM inspector with the original HTML.

Open interactive lesson →

Chapter 1 · Lesson 2

Elements and text nodes

A DOM tree contains more than elements. Text inside an element is represented by a text node. children lists element children; childNodes includes text and comments too. Whitespace between tags may therefore change a childNodes count. Use the collection that matches your intent rather than assuming every node is an element. This fixture deliberately includes a text node followed by a strong element.

Your mission

Show the nodeType of the first child of #line in #result.

Starting HTML

<p id="line">Hello <strong>world</strong></p><output id="result"></output>

Worked JavaScript

document.querySelector('#result').textContent = document.querySelector('#line').childNodes[0].nodeType;

Hint: A text node has nodeType 3.

Explain: Which collection includes text nodes?

childNodes

Try next: Inspect children.length and childNodes.length with console.log.

Open interactive lesson →

Chapter 1 · Lesson 3

Select by an identifier

An id identifies a particular element in a document and should be unique. getElementById accepts the identifier without a # prefix; querySelector accepts a CSS selector such as #status. Both can return null when nothing matches. Store the element in a descriptive variable so later changes are easy to understand. Avoid relying on automatic window properties created from ids.

Your mission

Use getElementById to change #status to Ready.

Starting HTML

<p id="status">Waiting</p>

Worked JavaScript

const status = document.getElementById('status');
status.textContent = 'Ready';

Hint: getElementById("status") selects this paragraph.

Explain: What argument does getElementById expect?

The id without a # prefix

Try next: Try an absent id and log the result without accessing textContent on it.

Open interactive lesson →

Chapter 1 · Lesson 4

Wait for the document

A script cannot select markup that the browser has not parsed yet. On a normal site, defer on an external script lets parsing finish before execution; a script at the end of body also follows earlier markup. DOMContentLoaded announces parsing completion. If your script may load later, check readyState before adding a listener, because an event that already happened will not replay. Here your code always runs after the fixture exists.

Your mission

Initialize #status to Initialized whether DOMContentLoaded has fired or not.

Starting HTML

<p id="status">Not initialized</p>

Worked JavaScript

function init() { document.querySelector('#status').textContent = 'Initialized'; }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();

Hint: Use a readyState branch and an init function.

Explain: Why check readyState in late-loading code?

DOMContentLoaded may already have happened

Try next: Explain why a listener alone could miss initialization on a real site.

Open interactive lesson →

Chapter 1 · Lesson 5

Handle a missing match

querySelector returns null when there is no match. Reading a property on null throws, stopping the current synchronous execution. Guard optional UI before using it and provide a meaningful fallback. Do not hide every error in an empty catch block: a missing optional element differs from a broken required selector. This lesson contains no #optional element, so the fallback must run.

Your mission

Show No optional panel when #optional is absent.

Starting HTML

<output id="result"></output>

Worked JavaScript

const optional = document.querySelector('#optional');
document.querySelector('#result').textContent = optional ? optional.textContent : 'No optional panel';

Hint: Check the selection before reading its properties.

Explain: What does querySelector return for no match?

null

Try next: Add a guard around a second optional feature and log a helpful message.

Open interactive lesson →

Chapter 2 · Lesson 6

Find elements with CSS selectors

querySelector uses the same selector language as CSS. A descendant selector scopes a search to matching descendants, while a class selector matches a reusable class. querySelector returns only the first match. Prefer selectors that describe stable structure or explicit hooks; styling changes should not silently break important behavior. Here two cards exist, but only the featured card should change.

Your mission

Change only .card.featured to Featured pick.

Starting HTML

<article class="card">Basic</article><article class="card featured">Special</article>

Worked JavaScript

document.querySelector('.card.featured').textContent = 'Featured pick';

Hint: Combine class selectors without a space to require both.

Explain: What does .card.featured mean?

One element with both classes

Try next: Compare .card.featured with .card .featured in the inspector.

Open interactive lesson →

Chapter 2 · Lesson 7

Update a collection

querySelectorAll returns a static NodeList of matches. It is not one element, so setting textContent on the collection does not update its members. Iterate over each element with forEach or a loop. Keep the selection scoped to the part of the UI you intend to change. This avoids changing similar elements in another feature.

Your mission

Add the ready class to all three .item elements.

Starting HTML

<ul><li class="item">One</li><li class="item">Two</li><li class="item">Three</li></ul>

Worked JavaScript

document.querySelectorAll('.item').forEach(item => item.classList.add('ready'));

Hint: Use querySelectorAll(...).forEach(...).

Explain: How do you update every match?

Iterate over the returned elements

Try next: Use the callback index to number each item.

Open interactive lesson →

Chapter 2 · Lesson 8

Scope a search to a component

An element also supports querySelector. Searching inside a known container makes reusable components safer: the same class can appear elsewhere without being changed. Start with the container, then find a descendant. Scope is especially useful for cards, forms and repeated UI regions. Remember that the container itself is not a descendant match.

Your mission

Set only the right panel’s .count to 5.

Starting HTML

<section id="left"><span class="count">0</span></section><section id="right"><span class="count">0</span></section>

Worked JavaScript

const panel = document.querySelector('#right');
panel.querySelector('.count').textContent = '5';

Hint: Call querySelector on #right.

Explain: Why query within a container?

It limits changes to that component

Try next: Create a helper accepting a panel element and a number.

Open interactive lesson →

Chapter 2 · Lesson 9

Move through the tree

parentElement, children and nextElementSibling let you navigate relationships already present in the document. Element-oriented properties skip whitespace text nodes. Traversal is useful when a control acts on its own card, but overreliance on a fixed number of parents becomes brittle when markup changes. Use closest for a named ancestor when that expresses your intent better.

Your mission

Change the next element sibling of #first to Next.

Starting HTML

<ul id="list"><li id="first">A</li><li>B</li></ul>

Worked JavaScript

document.querySelector('#first').nextElementSibling.textContent = 'Next';

Hint: Get #first, then its nextElementSibling.

Explain: Which property skips whitespace text siblings?

nextElementSibling

Try next: Log the parent element’s tagName and number of children.

Open interactive lesson →

Chapter 2 · Lesson 10

Understand static selections

querySelectorAll gives a snapshot of matching elements at selection time. Adding another matching element later does not expand that NodeList. getElementsByClassName is a live collection and changes as the DOM changes. A live collection can surprise you if you remove classes during iteration. When you need a stable batch, a static selection or Array.from copy is often clearer.

Your mission

Capture .item matches, append a second .item, and display old/new counts as 1/2.

Starting HTML

<div id="items"><span class="item">A</span></div><output id="result"></output>

Worked JavaScript

const before = document.querySelectorAll('.item');
const item = document.createElement('span'); item.className = 'item'; item.textContent = 'B';
document.querySelector('#items').append(item);
document.querySelector('#result').textContent = before.length + '/' + document.querySelectorAll('.item').length;

Hint: Compare a saved NodeList length with a fresh selection.

Explain: Does a querySelectorAll result automatically gain later matches?

No, it is static

Try next: Repeat mentally with getElementsByClassName and predict the counts.

Open interactive lesson →

Chapter 3 · Lesson 11

Render text safely

textContent treats a value as text, even if it contains angle brackets. innerHTML parses a string as markup, so untrusted input can create elements or dangerous behavior. Prefer textContent for names, comments and search results. It also replaces existing descendants, so use a dedicated text element if a component contains controls you need to preserve.

Your mission

Show the literal text <strong>Hello</strong> without creating a strong element.

Starting HTML

<p id="message"></p>

Worked JavaScript

document.querySelector('#message').textContent = '<strong>Hello</strong>';

Hint: Assign the exact string to textContent.

Explain: Which API renders an untrusted name as text?

textContent

Try next: Try a string that looks like an image tag; it should remain text.

Open interactive lesson →

Chapter 3 · Lesson 12

Read and write attributes

Attributes hold values in markup. setAttribute writes a string value, and getAttribute reads one or returns null. Some DOM properties reflect attributes, but not all behave identically. Links, images and accessibility state often need explicit attribute updates. When building real links from external data, validate allowed destinations as well as safely rendering the label.

Your mission

Change #guide to Start learning and its href attribute to #learn.

Starting HTML

<a id="guide" href="#old">Old guide</a>

Worked JavaScript

const link = document.querySelector('#guide');
link.setAttribute('href', '#learn');
link.textContent = 'Start learning';

Hint: Use setAttribute for href and textContent for the label.

Explain: What does getAttribute return for an absent attribute?

null

Try next: Explain why a label and destination must both be checked.

Open interactive lesson →

Chapter 3 · Lesson 13

Keep styles in classes

classList provides add, remove, contains and toggle without replacing unrelated classes. This keeps behavior in JavaScript and presentation in CSS. Setting className to a new string replaces the full class list, which may remove styling or hooks you wanted to keep. Use toggle with its second argument when a Boolean state should determine the class rather than invert it blindly.

Your mission

Remove muted and add highlight while keeping card.

Starting HTML

<article id="card" class="card muted">Status card</article>

Worked JavaScript

const card = document.querySelector('#card');
card.classList.remove('muted');
card.classList.add('highlight');

Hint: Call remove and add on the same classList.

Explain: Which operation preserves unrelated classes?

classList.add

Try next: Use toggle("highlight", false) and predict the result.

Open interactive lesson →

Chapter 3 · Lesson 14

Use data attributes

Custom data-* attributes store small pieces of element metadata. dataset exposes them as strings with camel-cased names: data-product-id becomes dataset.productId. Convert numbers deliberately before arithmetic. These values are visible to visitors and editable through developer tools, so never treat them as secrets or authorization evidence.

Your mission

Read data-price and data-quantity and show their product in #total.

Starting HTML

<article id="product" data-price="12" data-quantity="3">Notebook</article><output id="total"></output>

Worked JavaScript

const product = document.querySelector('#product');
document.querySelector('#total').textContent = Number(product.dataset.price) * Number(product.dataset.quantity);

Hint: Use Number to make the conversion explicit.

Explain: What type is a dataset value?

String

Try next: Try addition instead of multiplication and observe why conversion matters.

Open interactive lesson →

Chapter 3 · Lesson 15

Properties describe current control state

A checkbox’s checked property represents its current state. Its checked attribute describes the default state in markup; it is not a reliable way to read later user interaction. Likewise input.value represents the current text. Boolean properties such as disabled should be assigned true or false. Writing disabled="false" still creates a disabled attribute.

Your mission

Check #agree and enable #continue using properties.

Starting HTML

<label><input id="agree" type="checkbox"> Agree</label><button id="continue" disabled>Continue</button>

Worked JavaScript

document.querySelector('#agree').checked = true;
document.querySelector('#continue').disabled = false;

Hint: Set checked = true and disabled = false.

Explain: Does disabled="false" enable a button?

No, the Boolean attribute is still present

Try next: Compare getAttribute("checked") with the checked property.

Open interactive lesson →

Chapter 4 · Lesson 16

Create and append an element

createElement makes a detached element. Set its text and attributes, then insert it with append. Creating an element alone does not make it visible. append accepts nodes and strings, while appendChild expects a node. This approach makes data flow explicit and lets you avoid constructing HTML strings for ordinary user-facing text.

Your mission

Create one li containing Learn DOM and append it to #list.

Starting HTML

<ul id="list"></ul>

Worked JavaScript

const item = document.createElement('li');
item.textContent = 'Learn DOM';
document.querySelector('#list').append(item);

Hint: Create, set text, then append.

Explain: When does a new element become part of the visible document?

When inserted into the document

Try next: Add a class before inserting the element.

Open interactive lesson →

Chapter 4 · Lesson 17

Insert at the right position

prepend inserts content before existing children. before and after insert siblings beside a node. These methods express placement directly and can move existing nodes too. Choose the operation that matches the desired relationship instead of rebuilding a whole container. Replacing content unnecessarily can discard focus or event handlers on child elements.

Your mission

Add Plan as the first li without removing Build or Test.

Starting HTML

<ol id="steps"><li>Build</li><li>Test</li></ol>

Worked JavaScript

const item = document.createElement('li'); item.textContent = 'Plan';
document.querySelector('#steps').prepend(item);

Hint: Create a new li, then call prepend on the list.

Explain: Which method inserts before existing children?

prepend

Try next: Insert Review after Test using after.

Open interactive lesson →

Chapter 4 · Lesson 18

Remove a targeted node

remove detaches the selected element from its parent. Removing a card is different from clearing all of its container’s content. Select the exact item, then remove it. If a feature may already have removed the element, guard a missing match. In an application with data state, update the data too or the next render may bring the item back.

Your mission

Remove #remove while preserving #keep.

Starting HTML

<ul><li id="keep">Keep</li><li id="remove">Remove me</li></ul>

Worked JavaScript

document.querySelector('#remove').remove();

Hint: Call remove on the selected element, not its parent.

Explain: What does element.remove() remove?

That element from its parent

Try next: Try optional chaining on a second removal and explain the difference.

Open interactive lesson →

Chapter 4 · Lesson 19

Clone a reusable template

A template element holds inert markup in its content DocumentFragment. cloneNode(true) makes a deep copy, including descendants. Fill the copied fields and append it to your visible list. Cloning does not copy listeners added with addEventListener; wire events on the copy or use delegation. Avoid duplicating ids across repeated cards.

Your mission

Clone the template, set its heading to DOM explorer and paragraph to Ready to build, then append it.

Starting HTML

<template id="cardTemplate"><article class="card"><h2></h2><p></p></article></template><div id="cards"></div>

Worked JavaScript

const copy = document.querySelector('#cardTemplate').content.cloneNode(true);
copy.querySelector('h2').textContent = 'DOM explorer';
copy.querySelector('p').textContent = 'Ready to build';
document.querySelector('#cards').append(copy);

Hint: Select template.content before cloning.

Explain: What does cloneNode(true) copy?

The node and its descendants

Try next: Make a second distinct card without adding duplicate ids.

Open interactive lesson →

Chapter 4 · Lesson 20

Render a batch with a fragment

A DocumentFragment groups detached nodes. Appending it moves its children into the destination. It is useful for constructing a batch clearly, but is not a guarantee of a measurable speedup in every situation. replaceChildren replaces old children in one explicit operation. Consider focus and event listeners when rebuilding a container; sometimes a targeted update is better.

Your mission

Replace the placeholder with Plan, Build and Test list items.

Starting HTML

<ul id="list"><li>Old placeholder</li></ul>

Worked JavaScript

const fragment = document.createDocumentFragment();
['Plan', 'Build', 'Test'].forEach(text => { const li = document.createElement('li'); li.textContent = text; fragment.append(li); });
document.querySelector('#list').replaceChildren(fragment);

Hint: Build a fragment then pass it to replaceChildren.

Explain: What happens when a fragment is appended?

Its children move into the destination

Try next: Explain when replacing all children might be disruptive.

Open interactive lesson →

Chapter 5 · Lesson 21

Respond to a click

addEventListener connects an event type with a callback. Pass the callback function rather than the result of calling it. The function runs later when the event occurs. A native button already supports keyboard activation, so it is a better starting point than making a clickable div. Keep visible feedback close to the action and meaningful to the user.

Your mission

On #button click, set #message to Hello!

Starting HTML

<button id="button">Say hello</button><p id="message">Waiting</p>

Worked JavaScript

document.querySelector('#button').addEventListener('click', () => { document.querySelector('#message').textContent = 'Hello!'; });

Hint: Use an arrow function as the second argument.

Explain: What should be passed to addEventListener?

A function to run later

Try next: Click with the keyboard as well as the pointer.

Open interactive lesson →

Chapter 5 · Lesson 22

Build a stateful counter

A UI often displays a value stored in JavaScript. Keep that value in one variable, update it after an action and render it into the DOM. Reading a string from textContent and doing arithmetic can cause coercion mistakes. Separating state from presentation makes reset, limits and testing clearer. This counter must handle multiple clicks, not only one.

Your mission

Increment #count on every #increment click.

Starting HTML

<button id="increment">Add one</button><output id="count">0</output>

Worked JavaScript

let count = 0;
document.querySelector('#increment').addEventListener('click', () => { count += 1; document.querySelector('#count').textContent = count; });

Hint: Declare count outside the listener so it survives between clicks.

Explain: Why keep a numeric state variable?

It separates the value from its text presentation

Try next: Add a reset control in an exported copy.

Open interactive lesson →

Chapter 5 · Lesson 23

Distinguish target and currentTarget

event.target identifies where an event began; currentTarget is the element whose listener is currently running. Clicking a nested span inside a button makes the span the target but the button the currentTarget for its listener. This distinction matters when controls contain icons or labels. Avoid assuming that every target is the control you need.

Your mission

Show target id/currentTarget id in #result when the button receives a click.

Starting HTML

<button id="button"><span id="label">Click nested label</span></button><output id="result"></output>

Worked JavaScript

document.querySelector('#button').addEventListener('click', event => { document.querySelector('#result').textContent = event.target.id + '/' + event.currentTarget.id; });

Hint: Read both properties from the event parameter.

Explain: When clicking a span inside a listening button, currentTarget is…

The button

Try next: Click the button padding and compare the result with clicking its label.

Open interactive lesson →

Chapter 5 · Lesson 24

Let events bubble

Many events bubble from their target through ancestor elements. This enables a container to observe interactions on its children. It also explains why more than one listener may run for a single action. stopPropagation prevents later propagation; preventDefault cancels a default action and is a different operation. Use propagation control deliberately rather than adding it everywhere.

Your mission

Append inner then outer to #trace using listeners on the button and its container.

Starting HTML

<div id="outer"><button id="inner">Click</button></div><output id="trace"></output>

Worked JavaScript

const trace = document.querySelector('#trace');
document.querySelector('#inner').addEventListener('click', () => trace.textContent += 'inner ');
document.querySelector('#outer').addEventListener('click', () => trace.textContent += 'outer');

Hint: Attach two ordinary click listeners without stopping propagation.

Explain: Does preventDefault stop bubbling?

No, it cancels a default action

Try next: Predict how capture: true on the outer listener changes the order.

Open interactive lesson →

Chapter 5 · Lesson 25

Delegate actions from a list

Delegation attaches one listener to a stable parent. Use target.closest to locate the relevant control even when a nested label receives the click. Verify the match belongs to the intended container, then find the row to change. This also works for controls added after the listener was attached. Here each delete control removes its own row.

Your mission

Use a listener on #tasks to remove the clicked button’s li, including nested span clicks.

Starting HTML

<ul id="tasks"><li>Read <button data-remove><span>Delete</span></button></li><li>Build <button data-remove>Delete</button></li></ul>

Worked JavaScript

const list = document.querySelector('#tasks');
list.addEventListener('click', event => { const button = event.target.closest('[data-remove]'); if (button && list.contains(button)) button.closest('li').remove(); });

Hint: closest("[data-remove]") handles clicks on nested labels.

Explain: Why is delegation useful for dynamic lists?

The parent listener can handle later children

Try next: Add a non-delete button and ensure the handler ignores it.

Open interactive lesson →

Chapter 6 · Lesson 26

React while someone types

The input event fires as a control’s value changes through user input. Read the current value property inside the handler. change often waits until a value is committed, so it is not the same experience for text fields. Do not rely on the original value attribute to read current text. Here a greeting updates with each input event.

Your mission

Update #greeting to Hello, followed by the trimmed name, or friend when blank.

Starting HTML

<label>Name <input id="name"></label><p id="greeting">Hello, friend</p>

Worked JavaScript

document.querySelector('#name').addEventListener('input', event => { document.querySelector('#greeting').textContent = 'Hello, ' + (event.target.value.trim() || 'friend'); });

Hint: Use input and value.trim().

Explain: Where is the current input text?

The value property

Try next: Try a name containing angle brackets; keep it literal text.

Open interactive lesson →

Chapter 6 · Lesson 27

Handle form submission

A form submit event represents the form-level action, including keyboard submission. Listen on the form rather than only a button click. preventDefault lets a demo handle submission without navigating. In a real application you still need appropriate server validation and an actual save or send operation. This lesson only updates an on-page status; it sends nothing.

Your mission

Prevent default form submission and display Saved: followed by the topic.

Starting HTML

<form id="form"><label>Topic <input id="topic" name="topic"></label><button>Save topic</button></form><p id="status"></p>

Worked JavaScript

document.querySelector('#form').addEventListener('submit', event => { event.preventDefault(); document.querySelector('#status').textContent = 'Saved: ' + document.querySelector('#topic').value; });

Hint: The event parameter provides preventDefault().

Explain: Why listen for submit on the form?

It represents the form action, including keyboard submission

Try next: Submit by pressing Enter in the preview.

Open interactive lesson →

Chapter 6 · Lesson 28

Collect named fields with FormData

FormData reads successful named controls from a form. Controls need name attributes; disabled controls and unchecked checkboxes are generally excluded. get returns the first matching value, which may be a string or File. In this text-only example, combine the title and level fields into an output. FormData itself does not send data anywhere.

Your mission

Read the form with FormData and show DOM / Beginner in #summary.

Starting HTML

<form id="form"><label>Title <input name="title" value="DOM"></label><label>Level <select name="level"><option>Beginner</option><option>Intermediate</option></select></label></form><output id="summary"></output>

Worked JavaScript

const data = new FormData(document.querySelector('#form'));
document.querySelector('#summary').textContent = data.get('title') + ' / ' + data.get('level');

Hint: Construct FormData with the form element.

Explain: Which attribute supplies a FormData field key?

name

Try next: Remove a name attribute in an exported copy and see which field disappears.

Open interactive lesson →

Chapter 6 · Lesson 29

Give specific validation feedback

Validation feedback should explain what to fix and connect to the input. Trimming catches whitespace-only values that look nonempty to a simple truthiness check. Set aria-invalid when invalid and clear it when the problem is fixed. Client-side checks help users but do not protect a server on their own. This exercise handles blank and valid submissions without navigation.

Your mission

Reject whitespace with Enter a task title and aria-invalid=true; show Saved and false for valid input.

Starting HTML

<form id="form"><label>Task <input id="task" aria-describedby="error"></label><button>Save</button></form><p id="error" role="status"></p>

Worked JavaScript

document.querySelector('#form').addEventListener('submit', event => { event.preventDefault(); const field = document.querySelector('#task'); const invalid = !field.value.trim(); field.setAttribute('aria-invalid', String(invalid)); document.querySelector('#error').textContent = invalid ? 'Enter a task title' : 'Saved'; });

Hint: Use trim, a Boolean invalid value and one render of the feedback.

Explain: Does client-side validation secure a server?

No, the server must validate independently

Try next: Focus the invalid field after submission and try the keyboard flow.

Open interactive lesson →

Chapter 6 · Lesson 30

Synchronize a select control

Controls can drive another part of the page. Read a select’s value in a change listener and render from that value. Keep a predictable mapping rather than depending on option label text that may change. Run the render function once during setup as well as on later changes so the initial UI matches the control.

Your mission

Keep #card data-theme equal to #theme’s current selected value.

Starting HTML

<label>Theme <select id="theme"><option value="light">Light</option><option value="night">Night</option></select></label><article id="card" data-theme="light">Preview</article>

Worked JavaScript

const select = document.querySelector('#theme');
function render() { document.querySelector('#card').dataset.theme = select.value; }
select.addEventListener('change', render); render();

Hint: Share one render function between setup and change.

Explain: Why render once during setup?

So initial output matches initial state

Try next: Add a descriptive text label reflecting the selected theme.

Open interactive lesson →

Chapter 7 · Lesson 31

Build an accessible disclosure

A disclosure button reveals or hides related content. Use a native button, aria-expanded for its state and aria-controls to identify the panel. The hidden property controls visibility and normally removes the panel from layout and accessibility exposure. Keep the visual and accessibility states synchronized in both directions. A CSS-only color change is not enough to communicate the state.

Your mission

Toggle #panel.hidden and synchronize aria-expanded on every click.

Starting HTML

<button id="toggle" aria-expanded="false" aria-controls="panel">Show details</button><section id="panel" hidden>Helpful details</section>

Worked JavaScript

const button = document.querySelector('#toggle'); const panel = document.querySelector('#panel');
button.addEventListener('click', () => { panel.hidden = !panel.hidden; button.setAttribute('aria-expanded', String(!panel.hidden)); });

Hint: Use panel.hidden as the state and invert it.

Explain: What should aria-expanded describe?

Whether the controlled content is expanded

Try next: Change the button text to Show details or Hide details as appropriate.

Open interactive lesson →

Chapter 7 · Lesson 32

Move focus with a purpose

Focus determines which element receives keyboard input. After an explicit action opens a search area, moving focus to its input can reduce effort. Unexpected focus moves during typing are disruptive, so tie them to a clear user action. Native controls are focusable without adding tabindex. Test the behavior using a keyboard, not only by inspecting an attribute.

Your mission

Reveal #panel and focus #search when Open search is clicked.

Starting HTML

<button id="open">Open search</button><div id="panel" hidden><label>Search <input id="search"></label></div>

Worked JavaScript

document.querySelector('#open').addEventListener('click', () => { document.querySelector('#panel').hidden = false; document.querySelector('#search').focus(); });

Hint: Reveal the field before calling focus().

Explain: When is moving focus usually appropriate here?

After the user explicitly opens search

Try next: Add a close action that restores focus to the opener in an exported copy.

Open interactive lesson →

Chapter 7 · Lesson 33

Respond to Escape

KeyboardEvent.key describes the key pressed, such as Escape. Handle only the key your feature needs and preserve ordinary typing. For a dismissible panel, closing on Escape should also restore focus to the control that opened it. This exercise starts with an open panel to isolate dismissal. More complex widgets may require additional keyboard behavior and testing.

Your mission

Close #panel and focus #open when Escape is pressed; ignore other keys.

Starting HTML

<button id="open">Open panel</button><section id="panel"><label>Note <input id="note"></label></section>

Worked JavaScript

document.addEventListener('keydown', event => { if (event.key === 'Escape') { document.querySelector('#panel').hidden = true; document.querySelector('#open').focus(); } });

Hint: Check the key before changing the page.

Explain: Which property identifies the Escape key?

event.key

Try next: Try Escape while focus is inside the input.

Open interactive lesson →

Chapter 7 · Lesson 34

Announce a status update

A status region can let assistive technology announce changes without moving focus. role=status implies polite live-region behavior. Keep the region present before its text changes and make the message useful and concise. A test can check markup and text changes but cannot prove how every screen reader announces it. Manual assistive-technology testing remains important.

Your mission

On Save draft, update the existing status region to Draft saved locally without moving focus.

Starting HTML

<button id="save">Save draft</button><p id="status" role="status"></p>

Worked JavaScript

document.querySelector('#save').addEventListener('click', () => { document.querySelector('#status').textContent = 'Draft saved locally'; });

Hint: Change the existing region’s text; do not call focus on it.

Explain: Does an automated markup check prove a screen reader announcement?

No, manual testing is still needed

Try next: Write a more useful message for a real save failure.

Open interactive lesson →

Chapter 7 · Lesson 35

Open and close a native dialog

The dialog element provides built-in modal behavior when opened with showModal. A modal needs an accessible name, a clear close control and sensible focus behavior. close dismisses it. Native support reduces custom code, but you must still test the complete keyboard and assistive-technology experience. This lesson uses a labeled dialog and explicit open and close buttons.

Your mission

Wire Open help to showModal and Close to close.

Starting HTML

<button id="open">Open help</button><dialog id="help" aria-labelledby="helpTitle"><h2 id="helpTitle">Help</h2><p>Try one step at a time.</p><button id="close">Close</button></dialog>

Worked JavaScript

const dialog = document.querySelector('#help');
document.querySelector('#open').addEventListener('click', () => dialog.showModal());
document.querySelector('#close').addEventListener('click', () => dialog.close());

Hint: Use the same dialog reference in both listeners.

Explain: Which method opens a modal dialog?

showModal()

Try next: Check Escape dismissal and focus return manually.

Open interactive lesson →

Chapter 8 · Lesson 36

Render from an array

Treat an array as data and the DOM as its current presentation. A render function maps each item into an element and replaces the list contents. It should be repeatable: rendering twice must not duplicate rows. Keep user-facing strings as textContent. This small pattern becomes the foundation of searchable lists, task boards and many framework components.

Your mission

Render Ada, Grace and Linus as list items in that order.

Starting HTML

<ul id="list"></ul>

Worked JavaScript

const names = ['Ada', 'Grace', 'Linus'];
function render() { document.querySelector('#list').replaceChildren(...names.map(name => { const li = document.createElement('li'); li.textContent = name; return li; })); }
render();

Hint: map can create elements, and replaceChildren accepts the spread array.

Explain: Why replace list contents during a full render?

It avoids accumulating duplicate rows

Try next: Call render twice and verify that only three rows remain.

Open interactive lesson →

Chapter 8 · Lesson 37

Filter without losing source data

Filtering should derive a visible subset from the original collection, not permanently delete nonmatches. Normalize both query and item text for a forgiving case-insensitive search. Render an empty state when no results match. Clearing the input should restore all items because the source data is still available. This exercise uses textContent-based list items built from an array.

Your mission

Filter Ada, Grace and Linus case-insensitively; show #empty for no matches and restore all when cleared.

Starting HTML

<label>Search names <input id="search"></label><ul id="list"></ul><p id="empty" hidden>No matches</p>

Worked JavaScript

const names = ['Ada', 'Grace', 'Linus'];
function render() { const term = document.querySelector('#search').value.trim().toLowerCase(); const visible = names.filter(name => name.toLowerCase().includes(term)); document.querySelector('#list').replaceChildren(...visible.map(name => { const li = document.createElement('li'); li.textContent = name; return li; })); document.querySelector('#empty').hidden = visible.length > 0; }
document.querySelector('#search').addEventListener('input', render); render();

Hint: Keep names unchanged and compute visible in render.

Explain: Should filtering overwrite the source collection?

No, derive a visible subset

Try next: Show a result count next to the search field.

Open interactive lesson →

Chapter 8 · Lesson 38

Delete by stable identity

A visible row’s position is not a reliable identity. Filtering and sorting change positions. Store a stable id on each row and use it when selecting the data or DOM node to remove. Here two rows carry explicit ids. Delegation lets one listener handle their buttons, including a nested span. This avoids deleting whichever row happens to share an index.

Your mission

Delete the row associated with the clicked delete control, preserving the other id.

Starting HTML

<ul id="list"><li data-id="a">Read <button data-delete><span>Delete</span></button></li><li data-id="b">Build <button data-delete>Delete</button></li></ul>

Worked JavaScript

document.querySelector('#list').addEventListener('click', event => { const button = event.target.closest('[data-delete]'); if (!button) return; const row = button.closest('[data-id]'); const id = row.dataset.id; document.querySelectorAll('#list [data-id]').forEach(item => { if (item.dataset.id === id) item.remove(); }); });

Hint: Read dataset.id from the closest row.

Explain: Why use a stable id rather than a displayed index?

Filtering and sorting can change positions

Try next: Describe how to apply the same id to an array.filter update.

Open interactive lesson →

Chapter 8 · Lesson 39

Derive a selection count

A derived value can be calculated from current state instead of maintained as a second independent counter. Counting checked controls avoids drifting out of sync after unchecking or repeated actions. Delegate change events from the group and render once initially. For larger apps the same principle applies to deriving totals from a data model rather than from the DOM.

Your mission

Show the current number of checked skill boxes in #count.

Starting HTML

<fieldset id="choices"><legend>Skills</legend><label><input type="checkbox"> Select</label><label><input type="checkbox"> Create</label><label><input type="checkbox"> Listen</label></fieldset><output id="count">0</output>

Worked JavaScript

function render() { document.querySelector('#count').textContent = document.querySelectorAll('#choices input:checked').length; }
document.querySelector('#choices').addEventListener('change', render); render();

Hint: Use the :checked selector inside render.

Explain: Why derive the count from selected state?

It avoids keeping two values synchronized manually

Try next: Disable a submit control when zero options are selected.

Open interactive lesson →

Chapter 8 · Lesson 40

Model an empty list

Empty states are part of the interface, not an afterthought. A list can start empty or become empty after deletion. Update its empty-state message after each mutation and once at initialization. Hide the message when rows exist and reveal it when none remain. A useful real-world empty state explains what action the user can take next.

Your mission

Reveal #empty after the only row is deleted, keeping it hidden initially.

Starting HTML

<ul id="list"><li>Draft <button id="delete">Delete</button></li></ul><p id="empty" hidden>No tasks yet. Add your first task.</p>

Worked JavaScript

function renderEmpty() { document.querySelector('#empty').hidden = document.querySelector('#list').children.length > 0; }
document.querySelector('#delete').addEventListener('click', event => { event.currentTarget.closest('li').remove(); renderEmpty(); }); renderEmpty();

Hint: Centralize the visibility calculation in a function.

Explain: When should empty state be updated?

At initialization and after relevant changes

Try next: Write an empty-state message for a search result versus a new account.

Open interactive lesson →

Chapter 9 · Lesson 41

Remove a listener cleanly

removeEventListener needs the same function reference used to add the listener, along with the matching event type and capture setting. Two identical-looking anonymous functions are different objects. Keep a named handler or stored function reference when later cleanup is required. This prevents old components from continuing to react after they are disabled or removed from use.

Your mission

Count Ping clicks until Stop listening removes that listener.

Starting HTML

<button id="ping">Ping</button><button id="stop">Stop listening</button><output id="count">0</output>

Worked JavaScript

let count = 0; const ping = document.querySelector('#ping');
function handle() { document.querySelector('#count').textContent = ++count; }
ping.addEventListener('click', handle);
document.querySelector('#stop').addEventListener('click', () => ping.removeEventListener('click', handle));

Hint: Define handle once and reuse it.

Explain: What must removeEventListener receive?

The same handler function reference

Try next: Explain when an AbortController could simplify a group of listener cleanups.

Open interactive lesson →

Chapter 9 · Lesson 42

Handle an action once

The once option removes a listener after its first invocation. It is useful for a one-time interaction, but it is not a substitute for server-side duplicate protection. Here the listener increments a counter only once even if the button is clicked again. Be clear whether an action should be permanently unavailable or just guarded while processing.

Your mission

Count only the first click on #claim.

Starting HTML

<button id="claim">Claim demo badge</button><output id="count">0</output>

Worked JavaScript

let count = 0;
document.querySelector('#claim').addEventListener('click', () => { document.querySelector('#count').textContent = ++count; }, { once: true });

Hint: Pass an options object as the third argument.

Explain: What does once: true do?

Removes the listener after its first invocation

Try next: Make the button visibly unavailable after the first click as an extra refinement.

Open interactive lesson →

Chapter 9 · Lesson 43

Observe changes to a list

MutationObserver reports selected DOM mutations after the current synchronous work. Configure it narrowly to avoid observing unrelated activity or creating feedback loops. childList tracks added and removed child nodes. disconnect stops future observation. It is often better to update your own UI directly when you control the mutation; observers help when another component owns it.

Your mission

Observe direct child changes to #list and update #count.

Starting HTML

<ul id="list"></ul><output id="count">0</output>

Worked JavaScript

const list = document.querySelector('#list');
const observer = new MutationObserver(() => { document.querySelector('#count').textContent = list.children.length; });
observer.observe(list, { childList: true });

Hint: Observe childList on the list, not on the output you modify.

Explain: When are mutation observer callbacks delivered?

After the current synchronous work

Try next: Explain how observing and changing the same subtree could cause a loop.

Open interactive lesson →

Chapter 9 · Lesson 44

Schedule a visual update

requestAnimationFrame asks the browser to run a callback before a future repaint. It is useful for coordinating visual work, but it does not mean exactly 60 frames per second. Animation should use timestamps and respect reduced-motion preferences when motion is introduced. This exercise schedules one class update, avoiding a permanent loop.

Your mission

Add highlight to #box inside a requestAnimationFrame callback.

Starting HTML

<div id="box" class="card">Ready for a visual update</div>

Worked JavaScript

requestAnimationFrame(() => { document.querySelector('#box').classList.add('highlight'); });

Hint: Pass a function without immediately calling it.

Explain: Does requestAnimationFrame guarantee exactly 60 calls per second?

No, timing depends on the browser and display

Try next: Inspect prefers-reduced-motion before adding motion in a real project.

Open interactive lesson →

Chapter 9 · Lesson 45

Separate data updates from custom events

CustomEvent lets one part of a page announce a domain-specific event with detail data. Dispatch it from an element and set bubbles when an ancestor should listen. Keep the event payload small and documented. An event is a notification, not persistent state; late listeners do not automatically receive old notifications. Here a component emits a score for an ancestor to display.

Your mission

On Award points, dispatch a bubbling scorechange event with detail.score=10; show it through the board listener.

Starting HTML

<section id="board"><button id="award">Award points</button><output id="score">0</output></section>

Worked JavaScript

document.querySelector('#board').addEventListener('scorechange', event => { document.querySelector('#score').textContent = event.detail.score; });
document.querySelector('#award').addEventListener('click', event => { event.currentTarget.dispatchEvent(new CustomEvent('scorechange', { bubbles: true, detail: { score: 10 } })); });

Hint: Use new CustomEvent with bubbles and detail.

Explain: Where does a CustomEvent carry its custom payload?

detail

Try next: Describe how a second independent listener could react to the same event.

Open interactive lesson →

Chapter 10 · Lesson 46

Project: a helpful FAQ

Combine delegation, hidden state and aria-expanded into a reusable FAQ. Each question controls its own answer. Keep sections independent so opening one does not accidentally change another. Native buttons provide keyboard activation, and explicit controls relationships help explain the interface. Automated checks cover state changes; manually check reading order and keyboard use too.

Your mission

Make both FAQ buttons independently toggle their matching panel and aria-expanded.

Starting HTML

<div id="faq"><button data-panel="a" aria-controls="a" aria-expanded="false">What is the DOM?</button><p id="a" hidden>A live document tree.</p><button data-panel="b" aria-controls="b" aria-expanded="false">How do I practice?</button><p id="b" hidden>Build one small interaction.</p></div>

Worked JavaScript

document.querySelector('#faq').addEventListener('click', event => { const button = event.target.closest('button[data-panel]'); if (!button) return; const panel = document.getElementById(button.dataset.panel); panel.hidden = !panel.hidden; button.setAttribute('aria-expanded', String(!panel.hidden)); });

Hint: Use dataset.panel to find the associated answer.

Explain: What must each FAQ button update?

Its own panel and expanded state

Try next: Add a third question in an exported copy without changing the listener.

Open interactive lesson →

Chapter 10 · Lesson 47

Project: a live character coach

A character counter combines input events, derived values and useful feedback. This example uses JavaScript string.length, which counts UTF-16 code units rather than user-perceived characters; emoji may count as more than one. State that limitation rather than presenting it as universal character counting. Keep the count accurate as text is added and removed, and clearly indicate when the limit is exceeded.

Your mission

Show length / 20 and reveal #warning only when the text length is over 20.

Starting HTML

<label>Short note <textarea id="note"></textarea></label><output id="count">0 / 20</output><p id="warning" hidden>Over the practice limit</p>

Worked JavaScript

function render() { const length = document.querySelector('#note').value.length; document.querySelector('#count').textContent = length + ' / 20'; document.querySelector('#warning').hidden = length <= 20; }
document.querySelector('#note').addEventListener('input', render); render();

Hint: Derive length and visibility inside the same render function.

Explain: What does JavaScript string.length count?

UTF-16 code units

Try next: Research Intl.Segmenter for user-perceived character counting after completing this build.

Open interactive lesson →

Chapter 10 · Lesson 48

Project: sortable learning cards

Sorting and rendering should preserve the original data when you need multiple views. Copy an array before sort because sort mutates it. Use localeCompare for ordinary text labels and a numeric comparator for numbers. This project toggles between title and duration ordering. Re-rendering cards is simple here because they contain no focused interactive controls.

Your mission

Render DOM, Events, Forms when sorted by title; Forms, DOM, Events when sorted by minutes.

Starting HTML

<label>Sort <select id="sort"><option value="title">Title</option><option value="minutes">Minutes</option></select></label><ul id="cards"></ul>

Worked JavaScript

const lessons = [{title:'Events',minutes:15},{title:'DOM',minutes:10},{title:'Forms',minutes:5}];
function render() { const key = document.querySelector('#sort').value; const sorted = [...lessons].sort((a,b) => key === 'title' ? a.title.localeCompare(b.title) : a.minutes-b.minutes); document.querySelector('#cards').replaceChildren(...sorted.map(item => { const li=document.createElement('li'); li.textContent=item.title+' · '+item.minutes+' min'; return li; })); }
document.querySelector('#sort').addEventListener('change',render);render();

Hint: Use [...lessons].sort with a comparator based on the selection.

Explain: Does Array.sort mutate its array?

Yes; copy first when preserving original order matters

Try next: Add a descending sort without changing the source array.

Open interactive lesson →

Chapter 10 · Lesson 49

Project: a small task composer

A task composer joins form handling, validation, safe text rendering and event delegation. Reject blank titles, append a row, clear the input and let the user remove individual tasks. Never interpolate a user title into innerHTML. This small end-to-end workflow is more meaningful than a button that only changes color. It still uses in-memory DOM state; refreshing does not save tasks.

Your mission

Build an add/delete task flow, reject blank input, render titles as text and clear the input on success.

Starting HTML

<form id="form"><label>Task <input id="title"></label><button>Add task</button></form><p id="status" role="status"></p><ul id="tasks"></ul>

Worked JavaScript

const list=document.querySelector('#tasks');
document.querySelector('#form').addEventListener('submit', event => { event.preventDefault(); const field=document.querySelector('#title'); const title=field.value.trim(); if(!title){document.querySelector('#status').textContent='Enter a task';return;} const li=document.createElement('li'); const label=document.createElement('span');label.textContent=title;const button=document.createElement('button');button.textContent='Delete';li.append(label,button);list.append(li);field.value='';document.querySelector('#status').textContent='Task added'; });
list.addEventListener('click',event=>{const button=event.target.closest('button');if(button)button.closest('li').remove();});

Hint: Create separate label and button nodes instead of an HTML string.

Explain: What is missing before claiming tasks survive a refresh?

A persistence design and its error handling

Try next: Extend an exported copy with a second task and an empty-state message.

Open interactive lesson →

Chapter 10 · Lesson 50

Capstone: a searchable resource shelf

Combine rendering, filtering, delegated actions and status feedback into a resource shelf. Keep the source array stable and store bookmarks in a Set keyed by resource id. Filtering should never erase a bookmark. Render button labels from state and announce the action. This capstone tests interactions across views, a common source of bugs in real interfaces. No data is uploaded or permanently stored by this preview.

Your mission

Build three searchable resources with independent Save/Saved toggles that survive filtering and update aria-pressed and status.

Starting HTML

<label>Find a resource <input id="search"></label><ul id="resources"></ul><p id="status" role="status"></p>

Worked JavaScript

const resources=[{id:'dom',title:'DOM basics'},{id:'events',title:'Event patterns'},{id:'forms',title:'Form design'}];
const saved=new Set();const list=document.querySelector('#resources');
function render(){const term=document.querySelector('#search').value.toLowerCase().trim();list.replaceChildren(...resources.filter(item=>item.title.toLowerCase().includes(term)).map(item=>{const li=document.createElement('li');const label=document.createElement('span');label.textContent=item.title;const button=document.createElement('button');button.dataset.id=item.id;button.textContent=saved.has(item.id)?'Saved':'Save';button.setAttribute('aria-pressed',String(saved.has(item.id)));li.append(label,button);return li;}));}
document.querySelector('#search').addEventListener('input',render);
list.addEventListener('click',event=>{const button=event.target.closest('button[data-id]');if(!button)return;const id=button.dataset.id;if(saved.has(id))saved.delete(id);else saved.add(id);document.querySelector('#status').textContent=saved.has(id)?'Resource saved':'Resource removed';render();});render();

Hint: Keep resources and saved outside render; derive each visible row from them.

Explain: Why key saved items by id rather than filtered position?

An item keeps its identity across different views

Try next: Add a saved-only view, an empty state and a manual keyboard test plan to your exported capstone.

Open interactive lesson →

Chapter 11 · Lesson 51

Initialize two independent counters

A reusable initializer receives a root element and finds controls inside it. State declared inside that initializer belongs to one instance. Global selectors or a single shared counter can accidentally connect independent widgets. Build a counter twice using the same function, then verify that interacting with one leaves the other unchanged.

Your mission

Initialize each .counter with independent state starting at zero; its button adds one.

Starting HTML

<section class="counter"><button>Add</button><output>0</output></section><section class="counter"><button>Add</button><output>0</output></section>

Worked JavaScript

function mountCounter(root){let value=0;root.querySelector('button').addEventListener('click',()=>{root.querySelector('output').textContent=String(++value);});}
document.querySelectorAll('.counter').forEach(mountCounter);

Hint: Accept a root parameter and select beneath that root.

Explain: Where should instance-specific counter state live?

Inside each initializer invocation

Try next: Add a third counter without adding another initializer function.

Open interactive lesson →

Chapter 11 · Lesson 52

Prevent duplicate initialization

Calling setup twice can register two separate listeners when each call creates a new callback. A WeakSet can record which roots are mounted without attaching application metadata to every node. Idempotent setup means repeating setup does not multiply the action. It does not mean that the action itself can happen only once.

Your mission

Call mount twice but ensure one click increases output by exactly one.

Starting HTML

<button id="add">Add</button><output>0</output>

Worked JavaScript

const mounted=new WeakSet();
function mount(root){if(mounted.has(root))return;mounted.add(root);let n=0;root.querySelector('#add').addEventListener('click',()=>root.querySelector('output').textContent=String(++n));}
mount(document.body);mount(document.body);

Hint: Guard setup with a WeakSet before adding listeners.

Explain: What does idempotent setup prevent?

Duplicate installation of the same behavior

Try next: Add cleanup that allows a component to be mounted again.

Open interactive lesson →

Chapter 11 · Lesson 53

Return a component cleanup function

A mounted component may later be removed or replaced. Returning a cleanup function makes ownership explicit: the caller can detach listeners before discarding the component. removeEventListener needs the same function reference used during registration. Merely writing a similar anonymous function will not remove the original listener.

Your mission

Make Add increment output; Dispose must detach that behavior.

Starting HTML

<button id="add">Add</button><button id="dispose">Dispose counter</button><output>0</output>

Worked JavaScript

function mount(){let n=0;const button=document.querySelector('#add');const onClick=()=>document.querySelector('output').textContent=String(++n);button.addEventListener('click',onClick);return ()=>button.removeEventListener('click',onClick);}
const cleanup=mount();document.querySelector('#dispose').addEventListener('click',cleanup);

Hint: Keep the named handler in a variable shared with cleanup.

Explain: What must removeEventListener receive?

The original listener function reference

Try next: Disable the Add button when the counter is disposed.

Open interactive lesson →

Chapter 11 · Lesson 54

Listen for a component’s custom event

CustomEvent lets a component announce an application-level fact without knowing every consumer. Put the payload in detail and choose whether the event bubbles. A containing section can listen for a bubbling event from a nested button. This separates a user action from the parent’s decision about how to display its result.

Your mission

Dispatch a bubbling topic-chosen event with detail.topic HTML; the panel renders that value.

Starting HTML

<section id="panel"><button id="choose">Choose HTML</button></section><output></output>

Worked JavaScript

document.querySelector('#choose').addEventListener('click',event=>event.currentTarget.dispatchEvent(new CustomEvent('topic-chosen',{bubbles:true,detail:{topic:'HTML'}})));document.querySelector('#panel').addEventListener('topic-chosen',event=>document.querySelector('output').textContent=event.detail.topic);

Hint: Use new CustomEvent(name,{detail,bubbles:true}).

Explain: Where does a CustomEvent carry application data?

In its detail property

Try next: Add a second topic button that uses the same event contract.

Open interactive lesson →

Chapter 11 · Lesson 55

Query inside a shadow root

A shadow root creates a separate DOM subtree with scoped style behavior. document.querySelector does not automatically search inside it. Attach an open shadow root to #host, create its elements, and query through host.shadowRoot. Shadow DOM is encapsulation, not a security boundary; an open root remains accessible to outside code.

Your mission

Create a button and counter inside an open shadow root; clicks increment its output.

Starting HTML

<div id="host"></div>

Worked JavaScript

const root=document.querySelector('#host').attachShadow({mode:'open'});const button=document.createElement('button');button.textContent='Shadow button';const output=document.createElement('output');output.textContent='0';let n=0;button.addEventListener('click',()=>output.textContent=String(++n));root.append(button,output);

Hint: Create elements normally, then append them to the shadow root.

Explain: How do you select an element in an open shadow root?

Query through the host’s shadowRoot

Try next: Add a style element inside the shadow root and compare its scope.

Open interactive lesson →

Chapter 12 · Lesson 56

Observe capture before bubbling

An event travels through capture, target and bubble phases. Registering a listener with capture:true lets an ancestor observe the event before target and bubble listeners run. Do not confuse the current listener’s element with the original event target. Logging the order makes propagation easier to understand than memorizing a diagram.

Your mission

Show capture > target > bubble after one button click.

Starting HTML

<section id="outer"><button id="inner">Trace event</button></section><output></output>

Worked JavaScript

const log=[];const record=word=>{log.push(word);document.querySelector('output').textContent=log.join(' > ');};document.querySelector('#outer').addEventListener('click',()=>record('capture'),{capture:true});document.querySelector('#inner').addEventListener('click',()=>record('target'));document.querySelector('#outer').addEventListener('click',()=>record('bubble'));

Hint: Pass {capture:true} only to the first ancestor listener.

Explain: Which ancestor listener runs first here?

The capture listener

Try next: Add another ancestor and predict the expanded trace.

Open interactive lesson →

Chapter 12 · Lesson 57

Cancel a default action, not propagation

preventDefault cancels a cancelable browser action; it does not stop an event reaching ancestors. A form submission is a useful example because an interface often needs to handle data without navigating. Check defaultPrevented on the same event and separately observe that a parent still receives it.

Your mission

Cancel form submission while allowing its submit event to reach the outer section.

Starting HTML

<section id="outer"><form id="entry"><button>Submit practice</button></form></section><output></output>

Worked JavaScript

document.querySelector('#entry').addEventListener('submit',event=>event.preventDefault());document.querySelector('#outer').addEventListener('submit',event=>document.querySelector('output').textContent=event.defaultPrevented?'Canceled and bubbled':'Not canceled');

Hint: Inspect event.defaultPrevented in the outer listener.

Explain: Does preventDefault stop bubbling?

No; default behavior and propagation are separate

Try next: Try stopPropagation separately and explain the changed result.

Open interactive lesson →

Chapter 12 · Lesson 58

Delegate clicks on nested icons

A click may land on a span inside a button rather than on the button itself. closest finds the nearest matching ancestor, including the target. Delegate one listener from the list and resolve the intended action with closest. Checking that the resolved control belongs to the component avoids accidentally handling unrelated markup.

Your mission

Remove the correct row even when its nested span receives the click.

Starting HTML

<ul id="list"><li><button data-id="a"><span>Remove A</span></button></li><li><button data-id="b"><span>Remove B</span></button></li></ul>

Worked JavaScript

const list=document.querySelector('#list');list.addEventListener('click',event=>{const button=event.target.closest('button[data-id]');if(!button||!list.contains(button))return;button.closest('li').remove();});

Hint: Resolve the button first, then resolve its row.

Explain: Why use closest rather than assuming target is the button?

The click can originate on a nested child

Try next: Append a new row after setup and verify delegation still works.

Open interactive lesson →

Chapter 12 · Lesson 59

Clean up listeners with AbortController

An AbortController can own several event listeners registered with its signal. Calling abort removes those listeners together. This is useful when a component has several controls that must share a lifecycle. It does not rewind DOM changes already made by earlier events.

Your mission

Wire Up and Down to shared numeric state; Stop removes both listeners.

Starting HTML

<button id="up">Up</button><button id="down">Down</button><button id="stop">Stop controls</button><output>0</output>

Worked JavaScript

let n=0;const controller=new AbortController();const render=()=>document.querySelector('output').textContent=String(n);document.querySelector('#up').addEventListener('click',()=>{n++;render();},{signal:controller.signal});document.querySelector('#down').addEventListener('click',()=>{n--;render();},{signal:controller.signal});document.querySelector('#stop').addEventListener('click',()=>controller.abort());

Hint: Use the same signal for both counter listeners.

Explain: What does abort do to listeners using its signal?

It removes those registered listeners

Try next: Create a new controller when implementing a restart action.

Open interactive lesson →

Chapter 12 · Lesson 60

Distinguish user input from programmatic value changes

Assigning input.value changes the control but does not automatically dispatch input or change. A render function should therefore be called intentionally after application-driven changes, or a documented custom event can carry the update. Do not depend on browser events that are not emitted by the operation you used.

Your mission

Reflect typed text and the Use DOM button’s programmatic value in output.

Starting HTML

<label>Topic <input id="topic"></label><button id="fill">Use DOM</button><output></output>

Worked JavaScript

const field=document.querySelector('#topic');function render(){document.querySelector('output').textContent=field.value;}field.addEventListener('input',render);document.querySelector('#fill').addEventListener('click',()=>{field.value='DOM';render();});

Hint: Share a render function between the input listener and fill action.

Explain: Does assigning value automatically emit an input event?

No; update the view or dispatch an intended event explicitly

Try next: Add a Clear button that uses the same render function.

Open interactive lesson →

Chapter 13 · Lesson 61

Read multiple checkbox values

FormData.getAll returns every value for a repeated field name. get returns only the first. Checkbox controls contribute only while checked, so a form can naturally describe a multi-selection. Render the selected values as text and show a clear empty state instead of assuming at least one option was chosen.

Your mission

Summarize all checked skill values, or show No skills selected.

Starting HTML

<form id="skills"><label><input type="checkbox" name="skill" value="HTML" checked>HTML</label><label><input type="checkbox" name="skill" value="CSS" checked>CSS</label><button>Summarize</button></form><output></output>

Worked JavaScript

document.querySelector('#skills').addEventListener('submit',event=>{event.preventDefault();const skills=new FormData(event.currentTarget).getAll('skill');document.querySelector('output').textContent=skills.length?skills.join(', '):'No skills selected';});

Hint: Use the shared field name skill.

Explain: Which method reads repeated values?

FormData.getAll

Try next: Add a third checkbox without changing the processing code.

Open interactive lesson →

Chapter 13 · Lesson 62

Use native validity with custom guidance

Native constraints such as required and minlength can handle many basic rules. setCustomValidity adds an application-specific message and must be cleared with an empty string when the problem is fixed. A stale custom error can keep a form invalid even after the user enters a good value. Avoid collecting real personal data in practice forms.

Your mission

Reject the nickname admin, then clear the custom error for a different nickname.

Starting HTML

<label>Public nickname <input id="nickname" required></label><p id="help" role="status"></p>

Worked JavaScript

const field=document.querySelector('#nickname');field.addEventListener('input',()=>{const blocked=field.value.trim().toLowerCase()==='admin';field.setCustomValidity(blocked?'Choose a non-reserved nickname.':'');document.querySelector('#help').textContent=blocked?'Choose a non-reserved nickname.':'Nickname available';});

Hint: Always set both the invalid and valid branches.

Explain: How is a custom validity error cleared?

Call setCustomValidity with an empty string

Try next: Add a visible description explaining the nickname rule before typing.

Open interactive lesson →

Chapter 13 · Lesson 63

Reset state after a native reset

A form reset restores controls to their default values. The reset event is fired before the reset operation completes, so reading current values synchronously can capture the old state. Schedule a small follow-up after the default action, then render from the restored controls. This example uses a microtask and tests the result after the current task.

Your mission

Update output while typing and after a native form reset to DOM.

Starting HTML

<form id="preferences"><label>Topic <input id="topic" value="DOM"></label><button type="reset">Reset topic</button></form><output>DOM</output>

Worked JavaScript

const form=document.querySelector('#preferences'),field=document.querySelector('#topic');const render=()=>document.querySelector('output').textContent=field.value;field.addEventListener('input',render);form.addEventListener('reset',()=>queueMicrotask(render));

Hint: Use queueMicrotask(render) inside the reset listener.

Explain: Why delay reading values in a reset listener?

The reset event occurs before the default reset finishes

Try next: Store and restore a second field using the same rendering function.

Open interactive lesson →

Chapter 13 · Lesson 64

Render a dependent select

One selection can determine the choices in another control. Rebuild the second select from a clear data map when the first changes. Replace old options rather than appending duplicates, and choose a predictable initial option. Keeping data outside DOM construction makes the mapping easier to test and extend.

Your mission

Populate HTML/DOM for Web and JSON/Tables for Data without duplicate options.

Starting HTML

<label>Track <select id="track"><option value="web">Web</option><option value="data">Data</option></select></label><label>Lesson <select id="lesson"></select></label>

Worked JavaScript

const topics={web:['HTML','DOM'],data:['JSON','Tables']};const track=document.querySelector('#track'),lesson=document.querySelector('#lesson');function render(){lesson.replaceChildren(...topics[track.value].map(text=>{const option=document.createElement('option');option.value=text;option.textContent=text;return option;}));}track.addEventListener('change',render);render();

Hint: Use replaceChildren with freshly created option nodes.

Explain: Why replace the options instead of continually appending?

Old choices no longer belong to the selected track

Try next: Add a third track and test switching back and forth.

Open interactive lesson →

Chapter 13 · Lesson 65

Prevent duplicate asynchronous submission

A pending operation needs a visible state. Disable the submit action and use a busy flag so repeated submit events do not start overlapping work. Always restore the control in finally. This lesson simulates a local delay, not a real network save; its status must not imply data was sent anywhere.

Your mission

Run one local simulation at a time; disable the button while waiting and restore it afterward.

Starting HTML

<form id="save"><button>Simulate save</button></form><output>Ready</output>

Worked JavaScript

let busy=false,count=0;const form=document.querySelector('#save'),button=form.querySelector('button'),out=document.querySelector('output');form.addEventListener('submit',async event=>{event.preventDefault();if(busy)return;busy=true;button.disabled=true;out.textContent='Working locally';try{await new Promise(resolve=>setTimeout(resolve,30));out.textContent='Local simulation '+(++count);}finally{busy=false;button.disabled=false;}});

Hint: Return early when busy, and reset busy in finally.

Explain: Why use a busy flag as well as disabling the button?

Submission can be triggered through more than one path

Try next: Add a simulated failure path and verify that the control is still restored.

Open interactive lesson →

Chapter 14 · Lesson 66

Build a tab state model

A tab interface pairs selectable controls with panels. aria-selected communicates the current tab, hidden controls which panel is shown, and tabindex keeps only the active tab in the ordinary tab order. This lesson focuses on state changes through clicks; the next lesson adds arrow-key movement. Do not use tab roles for ordinary page-navigation links.

Your mission

Select the first tab initially; clicking another updates selected state, tab stops and panel visibility.

Starting HTML

<div role="tablist"><button id="tab-a" role="tab" aria-controls="panel-a">HTML</button><button id="tab-b" role="tab" aria-controls="panel-b">DOM</button></div><section id="panel-a" role="tabpanel" aria-labelledby="tab-a">Structure</section><section id="panel-b" role="tabpanel" aria-labelledby="tab-b">Behavior</section>

Worked JavaScript

const tabs=[...document.querySelectorAll('[role=tab]')];function activate(active){tabs.forEach(tab=>{const selected=tab===active;tab.setAttribute('aria-selected',String(selected));tab.tabIndex=selected?0:-1;document.getElementById(tab.getAttribute('aria-controls')).hidden=!selected;});}tabs.forEach(tab=>tab.addEventListener('click',()=>activate(tab)));activate(tabs[0]);

Hint: Use aria-controls to find the panel belonging to each tab.

Explain: What must stay synchronized in a tab interface?

Selected state, focusability and visible panel

Try next: Combine this state model with keyboard navigation from the next lesson.

Open interactive lesson →

Chapter 14 · Lesson 67

Move through a toolbar with arrow keys

Roving tabindex keeps one item in the Tab sequence while arrow keys move focus inside a widget. Update tabindex as focus moves and prevent the handled arrow key’s default action. This compact toolbar uses three buttons and wraps at the ends. It is a focus exercise, not a complete pattern for every possible widget.

Your mission

Move focus with Left/Right, wrap at the ends, and keep one button in the Tab sequence.

Starting HTML

<div id="tools" role="toolbar" aria-label="Formatting"><button tabindex="0">Bold</button><button tabindex="-1">Italic</button><button tabindex="-1">Underline</button></div>

Worked JavaScript

const buttons=[...document.querySelectorAll('#tools button')];document.querySelector('#tools').addEventListener('keydown',event=>{if(!['ArrowRight','ArrowLeft'].includes(event.key))return;const index=buttons.indexOf(document.activeElement);if(index<0)return;event.preventDefault();const next=(index+(event.key==='ArrowRight'?1:-1)+buttons.length)%buttons.length;buttons.forEach((b,i)=>b.tabIndex=i===next?0:-1);buttons[next].focus();});

Hint: Use the currently focused button’s index, not a separate stale index.

Explain: What does roving tabindex accomplish?

One Tab entry point with internal keyboard movement

Try next: Add Home and End to move to the first and last buttons.

Open interactive lesson →

Chapter 14 · Lesson 68

Restore focus after a dialog closes

Opening a dialog changes the interaction context. On close, restore focus to the control that launched it so the user can continue from a predictable place. The native dialog handles important modal behavior, but you still need meaningful content and a clear close control. Test both the visible close action and Escape manually.

Your mission

Open the modal dialog and restore focus to Review details when closed.

Starting HTML

<button id="open">Review details</button><dialog id="review"><h2>Practice details</h2><p>No information is sent.</p><button id="close">Close</button></dialog>

Worked JavaScript

const trigger=document.querySelector('#open'),dialog=document.querySelector('#review');trigger.addEventListener('click',()=>dialog.showModal());document.querySelector('#close').addEventListener('click',()=>{dialog.close();trigger.focus();});dialog.addEventListener('close',()=>trigger.focus());

Hint: Keep a reference to the opener outside the event handler.

Explain: Why restore focus after closing a dialog?

To preserve a predictable continuation point

Try next: Use Escape in the preview and verify focus restoration.

Open interactive lesson →

Chapter 14 · Lesson 69

Synchronize a toggle button

A toggle button uses aria-pressed to expose its on/off state. Keep state separate from the visible label so the same label can remain stable. A native button already supports Enter and Space, so do not add duplicate keyboard click handlers. Render the status and accessibility state from the same boolean.

Your mission

Toggle aria-pressed and status while keeping the button label stable.

Starting HTML

<button id="favorite" aria-pressed="false">Favorite lesson</button><output>Not saved</output>

Worked JavaScript

let saved=false;const button=document.querySelector('#favorite');button.addEventListener('click',()=>{saved=!saved;button.setAttribute('aria-pressed',String(saved));document.querySelector('output').textContent=saved?'Saved':'Not saved';});

Hint: Treat one boolean as the source of truth.

Explain: Do native buttons need an extra Enter-to-click handler?

Usually no; native keyboard activation already exists

Try next: Add a visible icon without making the icon the only state indicator.

Open interactive lesson →

Chapter 14 · Lesson 70

Build a small accordion with clear state

An accordion contains disclosure buttons linked to content regions. Each button’s aria-expanded value must match its panel’s hidden state. This version allows only one open panel at a time and lets the current panel close. Native details would be simpler when its built-in behavior fits; custom markup requires you to own the state logic.

Your mission

Allow one expanded section; selecting another closes the previous, and selecting it again closes it.

Starting HTML

<h2><button aria-controls="a" aria-expanded="false">HTML</button></h2><div id="a" hidden>Content structure</div><h2><button aria-controls="b" aria-expanded="false">DOM</button></h2><div id="b" hidden>Live document objects</div>

Worked JavaScript

const buttons=[...document.querySelectorAll('button[aria-controls]')];buttons.forEach(button=>button.addEventListener('click',()=>{const open=button.getAttribute('aria-expanded')!=='true';buttons.forEach(other=>{const expanded=other===button&&open;other.setAttribute('aria-expanded',String(expanded));document.getElementById(other.getAttribute('aria-controls')).hidden=!expanded;});}));

Hint: Compute the intended new state before closing other panels.

Explain: Which two values must agree?

aria-expanded and the panel’s visibility

Try next: Compare this implementation with native details elements.

Open interactive lesson →

Chapter 15 · Lesson 71

Update a record by stable identity

Array positions can change after sorting or filtering. Store stable IDs in data attributes and use those IDs to find the intended record. A delegated action can then remain correct across re-renders. This example edits a title for the record with id b without relying on its current visible index.

Your mission

Render two identified records and rename only id b to Better Beta.

Starting HTML

<ul id="items"></ul><button id="rename">Rename B</button>

Worked JavaScript

const items=[{id:'a',name:'Alpha'},{id:'b',name:'Beta'}];const list=document.querySelector('#items');function render(){list.replaceChildren(...items.map(item=>{const li=document.createElement('li');li.dataset.id=item.id;li.textContent=item.name;return li;}));}document.querySelector('#rename').addEventListener('click',()=>{items.find(item=>item.id==='b').name='Better Beta';render();});render();

Hint: Find the data record, update it, then render.

Explain: Why use a stable ID instead of a row index?

Identity survives changes in ordering

Try next: Sort the array before renaming and verify the same record changes.

Open interactive lesson →

Chapter 15 · Lesson 72

Render a visible empty state

Filtering can produce zero matches even when the source collection is not empty. Show a clear message for the visible result set and remove it when matches return. Do not insert the empty-state message into your source array as if it were a real record. The UI should derive it from the filtered collection’s length.

Your mission

Filter the three lessons and show No matching lessons only for an empty result.

Starting HTML

<label>Search <input id="find"></label><ul id="list"></ul><p id="empty" hidden>No matching lessons</p>

Worked JavaScript

const names=['HTML','DOM','CSS'];const field=document.querySelector('#find'),list=document.querySelector('#list');function render(){const shown=names.filter(n=>n.toLowerCase().includes(field.value.toLowerCase()));list.replaceChildren(...shown.map(n=>{const li=document.createElement('li');li.textContent=n;return li;}));document.querySelector('#empty').hidden=shown.length!==0;}field.addEventListener('input',render);render();

Hint: Update the list and empty message in the same render function.

Explain: Should an empty-state message be a source data record?

No; derive it from the current results

Try next: Add the number of visible results in a status region.

Open interactive lesson →

Chapter 15 · Lesson 73

Undo a removal using a snapshot

Undo requires enough information to restore the previous state. For a one-step deletion, a shallow array snapshot is sufficient when the records are not mutated. Save before the change, then render from the restored array. Clearly limit this example to one undo step rather than implying a complete history system.

Your mission

Remove the final record and offer one-step undo, disabling undo when unavailable.

Starting HTML

<ul id="list"></ul><button id="remove">Remove last</button><button id="undo" disabled>Undo removal</button>

Worked JavaScript

let items=['HTML','CSS','DOM'],previous=null;const list=document.querySelector('#list'),undo=document.querySelector('#undo');function render(){list.replaceChildren(...items.map(text=>{const li=document.createElement('li');li.textContent=text;return li;}));undo.disabled=previous===null;}document.querySelector('#remove').addEventListener('click',()=>{if(!items.length)return;previous=[...items];items=items.slice(0,-1);render();});undo.addEventListener('click',()=>{if(previous===null)return;items=previous;previous=null;render();});render();

Hint: Keep previous separate from current items.

Explain: When should the undo snapshot be captured?

Before changing the state

Try next: Extend to a bounded history of five snapshots.

Open interactive lesson →

Chapter 15 · Lesson 74

Paginate a local collection

Pagination is a view over source data. Compute a slice from page index and page size, then disable controls at the boundaries. Changing the page should not remove records from the source. This small collection has five items and a page size of two, so the last page contains one item.

Your mission

Show two items per page and disable navigation at the first and last pages.

Starting HTML

<ul id="list"></ul><button id="prev">Previous</button><button id="next">Next</button><output></output>

Worked JavaScript

const items=['One','Two','Three','Four','Five'];let pageIndex=0;const size=2;function render(){document.querySelector('#list').replaceChildren(...items.slice(pageIndex*size,(pageIndex+1)*size).map(text=>{const li=document.createElement('li');li.textContent=text;return li;}));document.querySelector('#prev').disabled=pageIndex===0;document.querySelector('#next').disabled=(pageIndex+1)*size>=items.length;document.querySelector('output').textContent='Page '+(pageIndex+1);}document.querySelector('#prev').addEventListener('click',()=>{if(pageIndex>0){pageIndex--;render();}});document.querySelector('#next').addEventListener('click',()=>{if((pageIndex+1)*size<items.length){pageIndex++;render();}});render();

Hint: Use slice with pageIndex multiplied by page size.

Explain: Does pagination need to delete hidden records?

No; render a slice of the source data

Try next: Show total pages and handle a source collection that becomes empty.

Open interactive lesson →

Chapter 15 · Lesson 75

Keep selection when changing filters

Selection and visibility are different state dimensions. A Set of selected IDs can persist while the list is filtered. Recreate checked controls from that Set when the view changes, rather than inferring all selection from currently visible checkboxes. This distinction matters in bulk-action interfaces.

Your mission

Preserve checked selections by ID when filtering hides and reveals a record.

Starting HTML

<label>Filter <input id="filter"></label><div id="list"></div><output>0 selected</output>

Worked JavaScript

const items=[{id:'a',name:'Alpha'},{id:'b',name:'Beta'}],selected=new Set();const list=document.querySelector('#list');function render(){list.replaceChildren(...items.filter(x=>x.name.toLowerCase().includes(document.querySelector('#filter').value.toLowerCase())).map(x=>{const label=document.createElement('label'),box=document.createElement('input');box.type='checkbox';box.dataset.id=x.id;box.checked=selected.has(x.id);label.append(box,x.name);return label;}));document.querySelector('output').textContent=selected.size+' selected';}list.addEventListener('change',e=>{const box=e.target;if(!box.matches('input[data-id]'))return;box.checked?selected.add(box.dataset.id):selected.delete(box.dataset.id);render();});document.querySelector('#filter').addEventListener('input',render);render();

Hint: Render checked from selected.has(id).

Explain: Where should persistent selection be stored?

In state keyed by record ID

Try next: Add a clear-selection control that also updates hidden selections.

Open interactive lesson →

Chapter 16 · Lesson 76

Debounce a local search preview

Debouncing waits for a pause before doing work. Clear the previous timeout when another input arrives, then schedule the latest value. This example delays a local output by 40 milliseconds; it does not fetch anything. Debouncing is useful only when waiting improves the experience or reduces meaningful work.

Your mission

Render only the latest input value after a short 40ms pause.

Starting HTML

<label>Search draft <input id="search"></label><output>Waiting</output>

Worked JavaScript

let timer;document.querySelector('#search').addEventListener('input',event=>{const value=event.currentTarget.value;clearTimeout(timer);timer=setTimeout(()=>document.querySelector('output').textContent=value,40);});

Hint: Capture the value before the timer callback.

Explain: What does clearing the previous timer prevent?

Outdated scheduled work from running

Try next: Add a visible pending indicator and clear it after rendering.

Open interactive lesson →

Chapter 16 · Lesson 77

Cancel a scheduled message

setTimeout returns an identifier that can be passed to clearTimeout. Cancellation should also update the interface so the user knows nothing further is expected. A cancel action cannot undo work that already finished. Keep a visible status for scheduled, canceled and completed states.

Your mission

Schedule Finished after 60ms; Cancel must prevent it and show Canceled.

Starting HTML

<button id="schedule">Schedule message</button><button id="cancel">Cancel</button><output>Idle</output>

Worked JavaScript

let timer;const out=document.querySelector('output');document.querySelector('#schedule').addEventListener('click',()=>{clearTimeout(timer);out.textContent='Scheduled';timer=setTimeout(()=>out.textContent='Finished',60);});document.querySelector('#cancel').addEventListener('click',()=>{clearTimeout(timer);out.textContent='Canceled';});

Hint: Store the timeout identifier outside both handlers.

Explain: What can clearTimeout cancel?

A callback that has not yet run

Try next: Disable Cancel when there is no pending timer.

Open interactive lesson →

Chapter 16 · Lesson 78

Coalesce visual writes into one frame

requestAnimationFrame schedules work before a future repaint. A pending flag can combine several synchronous requests into one update. The latest state should win when the callback finally runs. Do not use animation frames as a guaranteed clock for background tasks; browsers may pause them in hidden documents.

Your mission

Coalesce three immediate clicks into one animation-frame render showing 3.

Starting HTML

<button id="add">Add</button><output>0</output><p id="renders">0 renders</p>

Worked JavaScript

let n=0,pending=false,renders=0;document.querySelector('#add').addEventListener('click',()=>{n++;if(pending)return;pending=true;requestAnimationFrame(()=>{pending=false;document.querySelector('output').textContent=String(n);document.querySelector('#renders').textContent=(++renders)+' renders';});});

Hint: Set pending before requesting the frame and clear it inside the callback.

Explain: What should the scheduled render use?

The latest state when it runs

Try next: Add a cleanup function that cancels a pending frame.

Open interactive lesson →

Chapter 16 · Lesson 79

Ignore stale asynchronous results

Two asynchronous requests can finish in a different order from their start order. A sequence number lets the interface accept only the most recent request’s result. This lesson uses local timers to model the race, so there is no network or AI service. The pattern prevents an older response from overwriting a newer choice.

Your mission

Keep Fast visible when a later fast action finishes before an earlier slow action.

Starting HTML

<button id="slow">Slow result</button><button id="fast">Fast result</button><output>Idle</output>

Worked JavaScript

let latest=0;async function load(label,delay){const id=++latest;document.querySelector('output').textContent='Loading';await new Promise(r=>setTimeout(r,delay));if(id!==latest)return;document.querySelector('output').textContent=label;}document.querySelector('#slow').addEventListener('click',()=>load('Slow',70));document.querySelector('#fast').addEventListener('click',()=>load('Fast',10));

Hint: Compare a captured request ID with the latest sequence number.

Explain: What should determine which result is accepted?

Whether it belongs to the latest requested action

Try next: Add a cancel action that invalidates the latest sequence.

Open interactive lesson →

Chapter 16 · Lesson 80

Disconnect an observer during teardown

MutationObserver can monitor changes, but it should stop when its component no longer needs updates. disconnect stops observation. Pending notifications may require thought in a real lifecycle; this example waits for each mutation before disconnecting so the behavior is clear. Count only the mutations your observer was configured to watch.

Your mission

Count added list nodes until Stop observing is clicked.

Starting HTML

<ul id="list"></ul><button id="add">Add item</button><button id="stop">Stop observing</button><output>0</output>

Worked JavaScript

let count=0;const list=document.querySelector('#list'),observer=new MutationObserver(records=>{count+=records.reduce((n,r)=>n+r.addedNodes.length,0);document.querySelector('output').textContent=String(count);});observer.observe(list,{childList:true});document.querySelector('#add').addEventListener('click',()=>{const li=document.createElement('li');li.textContent='Item';list.append(li);});document.querySelector('#stop').addEventListener('click',()=>observer.disconnect());

Hint: Keep the observer reference accessible to the Stop handler.

Explain: What does disconnect stop?

Further observation by that observer

Try next: Use takeRecords to inspect pending records before cleanup.

Open interactive lesson →

Chapter 17 · Lesson 81

Measure a rendered element

getBoundingClientRect returns the element’s rendered border box relative to the viewport. Its measurements can be fractional and include transforms. Read after the relevant styles have been applied. This exercise measures a fixed authored fixture, not the entire browser window, so the result is predictable across preview sizes.

Your mission

Display the box’s rendered width and height as 180 × 80.

Starting HTML

<div id="box" style="width:180px;height:80px;border:2px solid teal">Measure me</div><button id="measure">Measure</button><output></output>

Worked JavaScript

document.querySelector('#measure').addEventListener('click',()=>{const rect=document.querySelector('#box').getBoundingClientRect();document.querySelector('output').textContent=Math.round(rect.width)+' × '+Math.round(rect.height);});

Hint: Read width and height from getBoundingClientRect().

Explain: What coordinate system does the rectangle use?

The viewport

Try next: Apply a scale transform and compare the rendered measurement.

Open interactive lesson →

Chapter 17 · Lesson 82

Read a computed style

element.style only exposes inline declarations. getComputedStyle reports resolved styles after stylesheets and inheritance have been considered. Do not assume it returns the exact authored spelling; colors and lengths are often normalized. This example reads a CSS class’s color rather than an inline value.

Your mission

Read the sample’s computed color into output.

Starting HTML

<style>.sample{color:rgb(15,118,110)}</style><p class="sample">Computed color</p><button id="read">Read color</button><output></output>

Worked JavaScript

document.querySelector('#read').addEventListener('click',()=>document.querySelector('output').textContent=getComputedStyle(document.querySelector('.sample')).color);

Hint: Call getComputedStyle(element).color.

Explain: Why might element.style.color be empty here?

The color comes from a stylesheet rule

Try next: Set an inline color and inspect how the resolved value changes.

Open interactive lesson →

Chapter 17 · Lesson 83

Observe element resizing

ResizeObserver reacts when a particular element’s observed size changes. This is different from listening only to window resize, because a component can resize for many reasons. Avoid resizing the same target endlessly inside its callback. This example changes a fixed width through a button and reports the observed content width.

Your mission

Observe #box and report its content width after growing it to 200px.

Starting HTML

<div id="box" style="width:120px;height:40px;background:#ccfbf1">Resize target</div><button id="grow">Grow to 200</button><output></output>

Worked JavaScript

const box=document.querySelector('#box');const observer=new ResizeObserver(entries=>{document.querySelector('output').textContent=String(Math.round(entries[0].contentRect.width));});observer.observe(box);document.querySelector('#grow').addEventListener('click',()=>box.style.width='200px');

Hint: Read contentRect.width from the delivered entry.

Explain: What does ResizeObserver watch?

Changes to observed element sizes

Try next: Add a cleanup action that disconnects the observer.

Open interactive lesson →

Chapter 17 · Lesson 84

Read a media preference

matchMedia evaluates a media query and exposes whether it currently matches. It can also notify you when the result changes. User preferences such as reduced motion should influence nonessential animation without hiding content. This exercise renders an honest status based on the actual browser preference rather than assuming a fixed setting.

Your mission

Display Reduced motion or Standard motion based on the actual preference.

Starting HTML

<output id="motion"></output>

Worked JavaScript

const query=matchMedia('(prefers-reduced-motion: reduce)');function render(){document.querySelector('#motion').textContent=query.matches?'Reduced motion':'Standard motion';}query.addEventListener('change',render);render();

Hint: Keep the MediaQueryList and listen for change.

Explain: What does matchMedia.matches represent?

Whether the query currently matches

Try next: Switch your operating system’s motion preference and observe the output.

Open interactive lesson →

Chapter 17 · Lesson 85

Use a live region without replacing its container

A status region should exist before you update its text. Replacing the entire region can make announcements less predictable. Keep the container stable and update textContent when an action completes. Automatic tests can verify DOM state, but a real screen-reader test is still needed to evaluate announcement timing and usefulness.

Your mission

Update the existing status region without replacing its node.

Starting HTML

<button id="finish">Finish local task</button><p id="status" role="status" aria-live="polite"></p>

Worked JavaScript

document.querySelector('#finish').addEventListener('click',()=>document.querySelector('#status').textContent='Local task complete');

Hint: Set textContent on the existing paragraph.

Explain: What can this automated test establish?

That the region and text are correct, not how every screen reader announces them

Try next: Test with a screen reader and document what is announced.

Open interactive lesson →

Chapter 18 · Lesson 86

Render user text without parsing markup

User input is data, not trusted HTML. textContent renders angle brackets literally rather than creating elements from them. Avoid innerHTML for ordinary messages, comments or names. The preview has additional isolation, but the code you export should still use safe DOM construction when handling untrusted content.

Your mission

Show the input as literal text, including any angle brackets.

Starting HTML

<label>Message <input id="message"></label><button id="show">Show text</button><p id="result"></p>

Worked JavaScript

document.querySelector('#show').addEventListener('click',()=>document.querySelector('#result').textContent=document.querySelector('#message').value);

Hint: Assign the value to the paragraph’s textContent.

Explain: Which API is appropriate for plain user text?

textContent

Try next: Try an image-shaped string and verify it remains text.

Open interactive lesson →

Chapter 18 · Lesson 87

Validate a link destination before assigning it

A URL can carry a scheme such as https or javascript. Validate the parsed protocol before turning user-provided data into an active link. This exercise accepts only HTTPS and rejects malformed or other-scheme values. A production product may need stricter host and path rules; accepting HTTPS alone does not prove a destination is trustworthy.

Your mission

Set href only for an absolute HTTPS URL; clear href for invalid or other-scheme input.

Starting HTML

<label>Destination <input id="url"></label><button id="apply">Apply link</button><a id="link">Preview destination</a><output></output>

Worked JavaScript

document.querySelector('#apply').addEventListener('click',()=>{const link=document.querySelector('#link');try{const url=new URL(document.querySelector('#url').value);if(url.protocol!=='https:')throw Error('HTTPS required');link.href=url.href;document.querySelector('output').textContent='HTTPS destination set';}catch{link.removeAttribute('href');document.querySelector('output').textContent='Enter an absolute HTTPS URL';}});

Hint: Use new URL and inspect protocol before assigning href.

Explain: Does HTTPS alone prove a destination is trustworthy?

No; it is only one part of a URL policy

Try next: Add an allowed-host list and test deceptive hostnames.

Open interactive lesson →

Chapter 18 · Lesson 88

Handle missing elements intentionally

A selector can return null when markup changes or a component is optional. Decide whether to skip, report or fail clearly rather than letting an unrelated property access throw later. This example reports a useful message when a target is absent and leaves the rest of the page operational.

Your mission

Handle the absent #optional target without a runtime error.

Starting HTML

<button id="attempt">Try update</button><output></output>

Worked JavaScript

document.querySelector('#attempt').addEventListener('click',()=>{const target=document.querySelector('#optional');if(!target){document.querySelector('output').textContent='Optional panel is not present';return;}target.textContent='Updated';});

Hint: Check the result before using textContent.

Explain: What is a useful response to an expected missing element?

Handle the null result explicitly

Try next: Add the optional panel and make the successful branch observable.

Open interactive lesson →

Chapter 18 · Lesson 89

Debug a selector within its component

A broad query can select a similarly named element elsewhere on the page. Scope selectors to the component root and inspect which node was actually returned. This exercise contains two outputs with the same class, deliberately showing why a global query would update the wrong place.

Your mission

Update only the output inside #widget.

Starting HTML

<section id="other"><output class="value">Unchanged</output></section><section id="widget"><button>Update widget</button><output class="value">Waiting</output></section>

Worked JavaScript

const root=document.querySelector('#widget');root.querySelector('button').addEventListener('click',()=>root.querySelector('.value').textContent='Widget updated');

Hint: Keep root in a variable and query beneath it.

Explain: What is the simplest way to narrow this selection?

Query from the widget root

Try next: Add a second widget instance and initialize both independently.

Open interactive lesson →

Chapter 18 · Lesson 90

Keep exception feedback useful

An action may fail because its input is malformed. Catch the expected failure, display a useful message, and allow the next attempt to succeed. Do not swallow errors without feedback or expose private data in messages. This exercise parses a small JSON array locally and reports the count; no data is uploaded.

Your mission

Count a valid JSON array and show a helpful message for malformed or non-array input.

Starting HTML

<label>JSON array <textarea id="data">["HTML","DOM"]</textarea></label><button id="parse">Count items</button><output></output>

Worked JavaScript

document.querySelector('#parse').addEventListener('click',()=>{try{const data=JSON.parse(document.querySelector('#data').value);if(!Array.isArray(data))throw Error('Expected array');document.querySelector('output').textContent=data.length+' items';}catch{document.querySelector('output').textContent='Enter a valid JSON array';}});

Hint: Validate Array.isArray after JSON.parse.

Explain: What should happen after an expected parse failure?

The user should be able to correct the input and retry

Try next: Add a maximum item count and explain the limit before parsing.

Open interactive lesson →

Chapter 19 · Lesson 91

Keep a disclosure synchronized after reset

A component may have more than one action that changes its state. Put the DOM updates in one render function so reset and toggle cannot disagree about visibility or aria-expanded. This is a small example of a single source of truth: the boolean state drives every representation of the disclosure.

Your mission

Toggle the panel and let Reset return both visibility and aria-expanded to closed.

Starting HTML

<button id="toggle" aria-controls="panel" aria-expanded="false">Details</button><button id="reset">Reset</button><section id="panel" hidden>Useful details</section>

Worked JavaScript

let open=false;function render(){document.querySelector('#panel').hidden=!open;document.querySelector('#toggle').setAttribute('aria-expanded',String(open));}document.querySelector('#toggle').addEventListener('click',()=>{open=!open;render();});document.querySelector('#reset').addEventListener('click',()=>{open=false;render();});render();

Hint: Both event handlers should change state then call render.

Explain: Why centralize the DOM updates in render?

Every action uses the same state-to-view mapping

Try next: Add a Close control inside the panel that uses the same render function.

Open interactive lesson →

Chapter 19 · Lesson 92

Preserve focus while updating a list

Replacing the focused element can send focus somewhere unexpected. For a small list, update existing nodes when their identity has not changed. This exercise increments a row’s count in place so the row button keeps focus. Full keyed reconciliation is more complex, but the principle is simple: avoid destroying useful interaction state unnecessarily.

Your mission

Increment the row output without replacing the focused button.

Starting HTML

<ul><li><button id="add">Add point</button><output>0</output></li></ul>

Worked JavaScript

let n=0;const button=document.querySelector('#add'),output=document.querySelector('output');button.addEventListener('click',()=>{output.textContent=String(++n);});

Hint: Update only the output text.

Explain: Why avoid replacing this button during rendering?

It preserves node identity and focus

Try next: Compare with replacing the entire row and observe focus behavior.

Open interactive lesson →

Chapter 19 · Lesson 93

Model a dismissible notification

A notification has a small lifecycle: hidden, shown and dismissed. Use a stable status region for the message, and a separate close action when needed. Avoid moving keyboard focus merely to announce routine status. This local demonstration has no timer, so users control how long the notice stays visible.

Your mission

Show the notice, dismiss it, and return focus to the show control.

Starting HTML

<button id="show">Show notice</button><section id="notice" hidden><p id="message" role="status"></p><button id="dismiss">Dismiss</button></section>

Worked JavaScript

const notice=document.querySelector('#notice');document.querySelector('#show').addEventListener('click',()=>{notice.hidden=false;document.querySelector('#message').textContent='Your local practice is ready';});document.querySelector('#dismiss').addEventListener('click',()=>{notice.hidden=true;document.querySelector('#show').focus();});

Hint: Keep notification visibility separate from the message content.

Explain: Should routine status updates always steal focus?

No; use an appropriate announcement region

Try next: Test announcement behavior with assistive technology.

Open interactive lesson →

Chapter 19 · Lesson 94

Derive a master checkbox state

A master checkbox summarizes a group. It is checked when every item is selected and indeterminate when some but not all are selected. indeterminate is a JavaScript property, not a persistent HTML attribute. Update the summary after individual changes and update all items when the master is toggled.

Your mission

Keep the master checkbox checked, unchecked or indeterminate to match its two items.

Starting HTML

<label><input id="all" type="checkbox">All topics</label><label><input class="item" type="checkbox">HTML</label><label><input class="item" type="checkbox">DOM</label>

Worked JavaScript

const all=document.querySelector('#all'),items=[...document.querySelectorAll('.item')];function summarize(){const n=items.filter(x=>x.checked).length;all.checked=n===items.length;all.indeterminate=n>0&&n<items.length;}items.forEach(box=>box.addEventListener('change',summarize));all.addEventListener('change',()=>{items.forEach(box=>box.checked=all.checked);summarize();});summarize();

Hint: Compute the selected count after each change.

Explain: What does indeterminate represent here?

A partial selection

Try next: Add a third item and verify all three summary states.

Open interactive lesson →

Chapter 19 · Lesson 95

Design a bounded stepper

A numeric stepper should enforce its range in state updates, not only through visual disabled buttons. Disable unavailable actions as helpful feedback, but keep guards in the handlers too. This example supports values zero through three. It tests repeated attempts at both boundaries without letting the state escape.

Your mission

Keep the value between 0 and 3 and disable unavailable actions.

Starting HTML

<button id="minus">Minus</button><output>0</output><button id="plus">Plus</button>

Worked JavaScript

let n=0;function render(){document.querySelector('output').textContent=String(n);document.querySelector('#minus').disabled=n===0;document.querySelector('#plus').disabled=n===3;}document.querySelector('#minus').addEventListener('click',()=>{n=Math.max(0,n-1);render();});document.querySelector('#plus').addEventListener('click',()=>{n=Math.min(3,n+1);render();});render();

Hint: Use Math.min and Math.max, then render both controls.

Explain: Why guard state updates as well as disabling controls?

The state rule should hold regardless of the action path

Try next: Make the minimum and maximum configurable.

Open interactive lesson →

Chapter 20 · Lesson 96

Project: an accessible reading switcher

Combine tab state, keyboard movement and panel visibility into one small reading interface. The active tab is the single Tab entry point; Left and Right move and activate tabs. Use linked IDs for tabs and panels. This is a focused two-tab implementation; a production component also needs testing with its actual content and assistive technologies.

Your mission

Build synchronized two-tab selection with click and wrapping Left/Right keyboard navigation.

Starting HTML

<div id="tabs" role="tablist" aria-label="Reading topics"><button id="html-tab" role="tab" aria-controls="html-panel">HTML</button><button id="dom-tab" role="tab" aria-controls="dom-panel">DOM</button></div><section id="html-panel" role="tabpanel" aria-labelledby="html-tab">Structure the page.</section><section id="dom-panel" role="tabpanel" aria-labelledby="dom-tab">Make the page respond.</section>

Worked JavaScript

const tabs=[...document.querySelectorAll('[role=tab]')];function activate(index,focus=false){tabs.forEach((tab,i)=>{const selected=i===index;tab.tabIndex=selected?0:-1;tab.setAttribute('aria-selected',String(selected));document.getElementById(tab.getAttribute('aria-controls')).hidden=!selected;});if(focus)tabs[index].focus();}tabs.forEach((tab,i)=>{tab.addEventListener('click',()=>activate(i));tab.addEventListener('keydown',e=>{if(!['ArrowLeft','ArrowRight'].includes(e.key))return;e.preventDefault();activate((i+(e.key==='ArrowRight'?1:-1)+tabs.length)%tabs.length,true);});});activate(0);

Hint: Reuse the same activate function for both input methods.

Explain: What makes this more than a visual tab effect?

Keyboard behavior and accessibility state match the visible panel

Try next: Add Home/End navigation and a third tab, then test with a screen reader.

Open interactive lesson →

Chapter 20 · Lesson 97

Project: a validated task queue

A task composer combines form handling, safe text rendering, delegated deletion and status feedback. Trim input and reject blank titles. Use monotonically increasing IDs rather than visible positions. Render all displayed text with textContent so a task containing angle brackets is still a task name, not markup.

Your mission

Add nonblank tasks safely, clear the input, remove by ID and announce the outcome.

Starting HTML

<form id="tasks"><label>Task <input id="title"></label><button>Add task</button></form><ul id="list"></ul><p id="status" role="status"></p>

Worked JavaScript

let items=[],nextId=1;const list=document.querySelector('#list'),status=document.querySelector('#status');function render(){list.replaceChildren(...items.map(item=>{const li=document.createElement('li'),span=document.createElement('span'),button=document.createElement('button');span.textContent=item.title;button.textContent='Remove';button.dataset.id=String(item.id);li.append(span,button);return li;}));}document.querySelector('#tasks').addEventListener('submit',e=>{e.preventDefault();const field=document.querySelector('#title'),title=field.value.trim();if(!title){status.textContent='Enter a task';return;}items.push({id:nextId++,title});field.value='';status.textContent='Task added';render();});list.addEventListener('click',e=>{const button=e.target.closest('button[data-id]');if(!button)return;items=items.filter(x=>String(x.id)!==button.dataset.id);status.textContent='Task removed';render();});render();

Hint: Keep the data array separate from DOM construction.

Explain: What should happen to a task title that looks like HTML?

It should render as literal text

Try next: Add completion toggles and a filter while preserving stable IDs.

Open interactive lesson →

Chapter 20 · Lesson 98

Project: a local data explorer

A local data explorer combines JSON parsing, validation, sorting and rendering. Accept only arrays of objects with a string name and finite numeric score. Validate every record before changing the displayed collection so a failed import does not partly replace good data. No file or network access is required; the text area is the data source.

Your mission

Validate all records before replacing the list, then sort descending by numeric score.

Starting HTML

<label>Records JSON <textarea id="data">[{"name":"Ada","score":8},{"name":"Lee","score":12}]</textarea></label><button id="load">Load records</button><button id="sort">Highest first</button><ul id="list"></ul><p id="status" role="status"></p>

Worked JavaScript

let items=[];const list=document.querySelector('#list'),status=document.querySelector('#status');function render(){list.replaceChildren(...items.map(x=>{const li=document.createElement('li');li.textContent=x.name+': '+x.score;return li;}));}document.querySelector('#load').addEventListener('click',()=>{try{const next=JSON.parse(document.querySelector('#data').value);if(!Array.isArray(next)||!next.every(x=>x&&typeof x.name==='string'&&typeof x.score==='number'&&Number.isFinite(x.score)))throw Error('Invalid records');items=next.map(x=>({name:x.name,score:x.score}));render();status.textContent=items.length+' records loaded';}catch{status.textContent='Use an array of name/score records';}});document.querySelector('#sort').addEventListener('click',()=>{items=[...items].sort((a,b)=>b.score-a.score);render();});

Hint: Validate into a temporary next variable before assigning items.

Explain: When should the current collection be replaced?

After the entire new dataset passes validation

Try next: Add a record limit and a filter without mutating the source order.

Open interactive lesson →

Chapter 20 · Lesson 99

Project: a multistep learning planner

A multistep interface needs clear state, validation and predictable focus movement. Keep entered values in the controls as sections are hidden and shown. This three-step planner asks for a goal, then a duration, then shows a summary. It is a local practice interface with no submission or persistence in the preview.

Your mission

Validate a goal and duration, navigate steps, preserve input and show a final summary.

Starting HTML

<section id="step1"><label>Learning goal <input id="goal"></label></section><section id="step2" hidden><label>Minutes <input id="minutes" type="number" value="20" min="5" max="120"></label></section><section id="step3" hidden><h2>Plan summary</h2><output></output></section><button id="back">Back</button><button id="next">Next</button><p id="status" role="status"></p>

Worked JavaScript

let step=1;const status=document.querySelector('#status');function render(){for(let i=1;i<=3;i++)document.querySelector('#step'+i).hidden=i!==step;document.querySelector('#back').disabled=step===1;document.querySelector('#next').disabled=step===3;if(step===1)document.querySelector('#goal').focus();if(step===2)document.querySelector('#minutes').focus();if(step===3)document.querySelector('output').textContent=document.querySelector('#goal').value.trim()+' — '+document.querySelector('#minutes').value+' minutes';}document.querySelector('#next').addEventListener('click',()=>{if(step===1&&!document.querySelector('#goal').value.trim()){status.textContent='Enter a learning goal';return;}const minutes=Number(document.querySelector('#minutes').value);if(step===2&&(!Number.isFinite(minutes)||minutes<5||minutes>120)){status.textContent='Choose 5 to 120 minutes';return;}if(step<3)step++;status.textContent='Step '+step+' of 3';render();});document.querySelector('#back').addEventListener('click',()=>{if(step>1)step--;render();});render();

Hint: Change the step only after its current inputs validate.

Explain: What should moving between steps preserve?

The learner’s entered values

Try next: Add an editable summary and a text download in your exported project.

Open interactive lesson →

Chapter 20 · Lesson 100

Capstone: a resilient resource dashboard

Combine filtering, stable IDs, selection state, safe rendering, empty feedback and reset into one small dashboard. The source collection remains constant; saved IDs and the query describe the changing view. Derive counts and button labels from state after every action. The test crosses several views to catch bugs that a single happy-path click would miss.

Your mission

Build searchable resources, persistent saved IDs, saved-only mode, empty feedback and a full reset.

Starting HTML

<label>Search resources <input id="query"></label><label><input id="savedOnly" type="checkbox">Saved only</label><button id="reset">Reset dashboard</button><ul id="resources"></ul><p id="empty" hidden>No matching resources</p><output></output>

Worked JavaScript

const resources=[{id:'html',title:'HTML structure'},{id:'dom',title:'DOM events'},{id:'forms',title:'Form patterns'}],saved=new Set();const list=document.querySelector('#resources'),query=document.querySelector('#query'),only=document.querySelector('#savedOnly');function render(){const term=query.value.trim().toLowerCase();const visible=resources.filter(x=>x.title.toLowerCase().includes(term)&&(!only.checked||saved.has(x.id)));list.replaceChildren(...visible.map(x=>{const li=document.createElement('li'),span=document.createElement('span'),button=document.createElement('button');span.textContent=x.title;button.dataset.id=x.id;button.textContent=saved.has(x.id)?'Unsave':'Save';button.setAttribute('aria-pressed',String(saved.has(x.id)));li.append(span,button);return li;}));document.querySelector('#empty').hidden=visible.length>0;document.querySelector('output').textContent=visible.length+' visible / '+saved.size+' saved';}query.addEventListener('input',render);only.addEventListener('change',render);list.addEventListener('click',event=>{const button=event.target.closest('button[data-id]');if(!button)return;const id=button.dataset.id;saved.has(id)?saved.delete(id):saved.add(id);render();});document.querySelector('#reset').addEventListener('click',()=>{saved.clear();query.value='';only.checked=false;render();query.focus();});render();

Hint: Use one render function for query, saved-only, toggles and reset.

Explain: What keeps state consistent across all dashboard views?

Rendering each view from stable source data and shared state

Try next: Export the project, add a documented local persistence strategy, and manually test keyboard and screen-reader journeys.

Open interactive lesson →