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.