← Interactive studio

50 experiments.
A whole new view.

Read each concept, attempt the task in the studio and compare with the worked example. Automated checks cover the mission, not every possible behavior.

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 example
document.querySelector('#welcome').textContent = 'Hello, DOM!';

Explain the idea

What did your script change?

Answer

The live document in the browser

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

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 example
document.querySelector('#result').textContent = document.querySelector('#line').childNodes[0].nodeType;

Explain the idea

Which collection includes text nodes?

Answer

childNodes

Take it further: Inspect children.length and childNodes.length with console.log.

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 example
const status = document.getElementById('status');
status.textContent = 'Ready';

Explain the idea

What argument does getElementById expect?

Answer

The id without a # prefix

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

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 example
function init() { document.querySelector('#status').textContent = 'Initialized'; }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();

Explain the idea

Why check readyState in late-loading code?

Answer

DOMContentLoaded may already have happened

Take it further: Explain why a listener alone could miss initialization on a real site.

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 example
const optional = document.querySelector('#optional');
document.querySelector('#result').textContent = optional ? optional.textContent : 'No optional panel';

Explain the idea

What does querySelector return for no match?

Answer

null

Take it further: Add a guard around a second optional feature and log a helpful message.

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 example
document.querySelector('.card.featured').textContent = 'Featured pick';

Explain the idea

What does .card.featured mean?

Answer

One element with both classes

Take it further: Compare .card.featured with .card .featured in the inspector.

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 example
document.querySelectorAll('.item').forEach(item => item.classList.add('ready'));

Explain the idea

How do you update every match?

Answer

Iterate over the returned elements

Take it further: Use the callback index to number each item.

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 example
const panel = document.querySelector('#right');
panel.querySelector('.count').textContent = '5';

Explain the idea

Why query within a container?

Answer

It limits changes to that component

Take it further: Create a helper accepting a panel element and a number.

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 example
document.querySelector('#first').nextElementSibling.textContent = 'Next';

Explain the idea

Which property skips whitespace text siblings?

Answer

nextElementSibling

Take it further: Log the parent element’s tagName and number of children.

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

Explain the idea

Does a querySelectorAll result automatically gain later matches?

Answer

No, it is static

Take it further: Repeat mentally with getElementsByClassName and predict the counts.

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 example
document.querySelector('#message').textContent = '<strong>Hello</strong>';

Explain the idea

Which API renders an untrusted name as text?

Answer

textContent

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

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 example
const link = document.querySelector('#guide');
link.setAttribute('href', '#learn');
link.textContent = 'Start learning';

Explain the idea

What does getAttribute return for an absent attribute?

Answer

null

Take it further: Explain why a label and destination must both be checked.

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 example
const card = document.querySelector('#card');
card.classList.remove('muted');
card.classList.add('highlight');

Explain the idea

Which operation preserves unrelated classes?

Answer

classList.add

Take it further: Use toggle("highlight", false) and predict the result.

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 example
const product = document.querySelector('#product');
document.querySelector('#total').textContent = Number(product.dataset.price) * Number(product.dataset.quantity);

Explain the idea

What type is a dataset value?

Answer

String

Take it further: Try addition instead of multiplication and observe why conversion matters.

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 example
document.querySelector('#agree').checked = true;
document.querySelector('#continue').disabled = false;

Explain the idea

Does disabled="false" enable a button?

Answer

No, the Boolean attribute is still present

Take it further: Compare getAttribute("checked") with the checked property.

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 example
const item = document.createElement('li');
item.textContent = 'Learn DOM';
document.querySelector('#list').append(item);

Explain the idea

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

Answer

When inserted into the document

Take it further: Add a class before inserting the element.

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 example
const item = document.createElement('li'); item.textContent = 'Plan';
document.querySelector('#steps').prepend(item);

Explain the idea

Which method inserts before existing children?

Answer

prepend

Take it further: Insert Review after Test using after.

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 example
document.querySelector('#remove').remove();

Explain the idea

What does element.remove() remove?

Answer

That element from its parent

Take it further: Try optional chaining on a second removal and explain the difference.

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 example
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);

Explain the idea

What does cloneNode(true) copy?

Answer

The node and its descendants

Take it further: Make a second distinct card without adding duplicate ids.

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 example
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);

Explain the idea

What happens when a fragment is appended?

Answer

Its children move into the destination

Take it further: Explain when replacing all children might be disruptive.

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 example
document.querySelector('#button').addEventListener('click', () => { document.querySelector('#message').textContent = 'Hello!'; });

Explain the idea

What should be passed to addEventListener?

Answer

A function to run later

Take it further: Click with the keyboard as well as the pointer.

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 example
let count = 0;
document.querySelector('#increment').addEventListener('click', () => { count += 1; document.querySelector('#count').textContent = count; });

Explain the idea

Why keep a numeric state variable?

Answer

It separates the value from its text presentation

Take it further: Add a reset control in an exported copy.

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 example
document.querySelector('#button').addEventListener('click', event => { document.querySelector('#result').textContent = event.target.id + '/' + event.currentTarget.id; });

Explain the idea

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

Answer

The button

Take it further: Click the button padding and compare the result with clicking its label.

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 example
const trace = document.querySelector('#trace');
document.querySelector('#inner').addEventListener('click', () => trace.textContent += 'inner ');
document.querySelector('#outer').addEventListener('click', () => trace.textContent += 'outer');

Explain the idea

Does preventDefault stop bubbling?

Answer

No, it cancels a default action

Take it further: Predict how capture: true on the outer listener changes the order.

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 example
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(); });

Explain the idea

Why is delegation useful for dynamic lists?

Answer

The parent listener can handle later children

Take it further: Add a non-delete button and ensure the handler ignores it.

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 example
document.querySelector('#name').addEventListener('input', event => { document.querySelector('#greeting').textContent = 'Hello, ' + (event.target.value.trim() || 'friend'); });

Explain the idea

Where is the current input text?

Answer

The value property

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

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 example
document.querySelector('#form').addEventListener('submit', event => { event.preventDefault(); document.querySelector('#status').textContent = 'Saved: ' + document.querySelector('#topic').value; });

Explain the idea

Why listen for submit on the form?

Answer

It represents the form action, including keyboard submission

Take it further: Submit by pressing Enter in the preview.

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 example
const data = new FormData(document.querySelector('#form'));
document.querySelector('#summary').textContent = data.get('title') + ' / ' + data.get('level');

Explain the idea

Which attribute supplies a FormData field key?

Answer

name

Take it further: Remove a name attribute in an exported copy and see which field disappears.

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 example
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'; });

Explain the idea

Does client-side validation secure a server?

Answer

No, the server must validate independently

Take it further: Focus the invalid field after submission and try the keyboard flow.

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 example
const select = document.querySelector('#theme');
function render() { document.querySelector('#card').dataset.theme = select.value; }
select.addEventListener('change', render); render();

Explain the idea

Why render once during setup?

Answer

So initial output matches initial state

Take it further: Add a descriptive text label reflecting the selected theme.

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 example
const button = document.querySelector('#toggle'); const panel = document.querySelector('#panel');
button.addEventListener('click', () => { panel.hidden = !panel.hidden; button.setAttribute('aria-expanded', String(!panel.hidden)); });

Explain the idea

What should aria-expanded describe?

Answer

Whether the controlled content is expanded

Take it further: Change the button text to Show details or Hide details as appropriate.

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 example
document.querySelector('#open').addEventListener('click', () => { document.querySelector('#panel').hidden = false; document.querySelector('#search').focus(); });

Explain the idea

When is moving focus usually appropriate here?

Answer

After the user explicitly opens search

Take it further: Add a close action that restores focus to the opener in an exported copy.

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 example
document.addEventListener('keydown', event => { if (event.key === 'Escape') { document.querySelector('#panel').hidden = true; document.querySelector('#open').focus(); } });

Explain the idea

Which property identifies the Escape key?

Answer

event.key

Take it further: Try Escape while focus is inside the input.

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 example
document.querySelector('#save').addEventListener('click', () => { document.querySelector('#status').textContent = 'Draft saved locally'; });

Explain the idea

Does an automated markup check prove a screen reader announcement?

Answer

No, manual testing is still needed

Take it further: Write a more useful message for a real save failure.

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 example
const dialog = document.querySelector('#help');
document.querySelector('#open').addEventListener('click', () => dialog.showModal());
document.querySelector('#close').addEventListener('click', () => dialog.close());

Explain the idea

Which method opens a modal dialog?

Answer

showModal()

Take it further: Check Escape dismissal and focus return manually.

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 example
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();

Explain the idea

Why replace list contents during a full render?

Answer

It avoids accumulating duplicate rows

Take it further: Call render twice and verify that only three rows remain.

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 example
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();

Explain the idea

Should filtering overwrite the source collection?

Answer

No, derive a visible subset

Take it further: Show a result count next to the search field.

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 example
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(); }); });

Explain the idea

Why use a stable id rather than a displayed index?

Answer

Filtering and sorting can change positions

Take it further: Describe how to apply the same id to an array.filter update.

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 example
function render() { document.querySelector('#count').textContent = document.querySelectorAll('#choices input:checked').length; }
document.querySelector('#choices').addEventListener('change', render); render();

Explain the idea

Why derive the count from selected state?

Answer

It avoids keeping two values synchronized manually

Take it further: Disable a submit control when zero options are selected.

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 example
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();

Explain the idea

When should empty state be updated?

Answer

At initialization and after relevant changes

Take it further: Write an empty-state message for a search result versus a new account.

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 example
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));

Explain the idea

What must removeEventListener receive?

Answer

The same handler function reference

Take it further: Explain when an AbortController could simplify a group of listener cleanups.

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 example
let count = 0;
document.querySelector('#claim').addEventListener('click', () => { document.querySelector('#count').textContent = ++count; }, { once: true });

Explain the idea

What does once: true do?

Answer

Removes the listener after its first invocation

Take it further: Make the button visibly unavailable after the first click as an extra refinement.

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 example
const list = document.querySelector('#list');
const observer = new MutationObserver(() => { document.querySelector('#count').textContent = list.children.length; });
observer.observe(list, { childList: true });

Explain the idea

When are mutation observer callbacks delivered?

Answer

After the current synchronous work

Take it further: Explain how observing and changing the same subtree could cause a loop.

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 example
requestAnimationFrame(() => { document.querySelector('#box').classList.add('highlight'); });

Explain the idea

Does requestAnimationFrame guarantee exactly 60 calls per second?

Answer

No, timing depends on the browser and display

Take it further: Inspect prefers-reduced-motion before adding motion in a real project.

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

Explain the idea

Where does a CustomEvent carry its custom payload?

Answer

detail

Take it further: Describe how a second independent listener could react to the same event.

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

Explain the idea

What must each FAQ button update?

Answer

Its own panel and expanded state

Take it further: Add a third question in an exported copy without changing the listener.

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 example
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();

Explain the idea

What does JavaScript string.length count?

Answer

UTF-16 code units

Take it further: Research Intl.Segmenter for user-perceived character counting after completing this build.

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 example
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();

Explain the idea

Does Array.sort mutate its array?

Answer

Yes; copy first when preserving original order matters

Take it further: Add a descending sort without changing the source array.

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 example
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();});

Explain the idea

What is missing before claiming tasks survive a refresh?

Answer

A persistence design and its error handling

Take it further: Extend an exported copy with a second task and an empty-state message.

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 example
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();

Explain the idea

Why key saved items by id rather than filtered position?

Answer

An item keeps its identity across different views

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