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.
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.
An id identifies a particular element in a document and should be unique. getElementById accepts the identifier without a # prefix; querySelector accepts a CSS selector such as #status. Both can return null when nothing matches. Store the element in a descriptive variable so later changes are easy to understand. Avoid relying on automatic window properties created from ids.
Your mission
Use getElementById to change #status to Ready.
Starting HTML
<p id="status">Waiting</p>
Worked JavaScript
const status = document.getElementById('status');
status.textContent = 'Ready';
Hint: getElementById("status") selects this paragraph.
Explain: What argument does getElementById expect?
The id without a # prefix
Try next: Try an absent id and log the result without accessing textContent on it.
A script cannot select markup that the browser has not parsed yet. On a normal site, defer on an external script lets parsing finish before execution; a script at the end of body also follows earlier markup. DOMContentLoaded announces parsing completion. If your script may load later, check readyState before adding a listener, because an event that already happened will not replay. Here your code always runs after the fixture exists.
Your mission
Initialize #status to Initialized whether DOMContentLoaded has fired or not.
Starting HTML
<p id="status">Not initialized</p>
Worked JavaScript
function init() { document.querySelector('#status').textContent = 'Initialized'; }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
Hint: Use a readyState branch and an init function.
Explain: Why check readyState in late-loading code?
DOMContentLoaded may already have happened
Try next: Explain why a listener alone could miss initialization on a real site.
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.
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.
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.
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.
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.
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.
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.
Attributes hold values in markup. setAttribute writes a string value, and getAttribute reads one or returns null. Some DOM properties reflect attributes, but not all behave identically. Links, images and accessibility state often need explicit attribute updates. When building real links from external data, validate allowed destinations as well as safely rendering the label.
Your mission
Change #guide to Start learning and its href attribute to #learn.
Starting HTML
<a id="guide" href="#old">Old guide</a>
Worked JavaScript
const link = document.querySelector('#guide');
link.setAttribute('href', '#learn');
link.textContent = 'Start learning';
Hint: Use setAttribute for href and textContent for the label.
Explain: What does getAttribute return for an absent attribute?
null
Try next: Explain why a label and destination must both be checked.
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.
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.
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.
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.
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.
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.
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.
A DocumentFragment groups detached nodes. Appending it moves its children into the destination. It is useful for constructing a batch clearly, but is not a guarantee of a measurable speedup in every situation. replaceChildren replaces old children in one explicit operation. Consider focus and event listeners when rebuilding a container; sometimes a targeted update is better.
Your mission
Replace the placeholder with Plan, Build and Test list items.
Starting HTML
<ul id="list"><li>Old placeholder</li></ul>
Worked JavaScript
const fragment = document.createDocumentFragment();
['Plan', 'Build', 'Test'].forEach(text => { const li = document.createElement('li'); li.textContent = text; fragment.append(li); });
document.querySelector('#list').replaceChildren(fragment);
Hint: Build a fragment then pass it to replaceChildren.
Explain: What happens when a fragment is appended?
Its children move into the destination
Try next: Explain when replacing all children might be disruptive.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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>
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.
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.
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.
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.
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>
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.
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.
MutationObserver reports selected DOM mutations after the current synchronous work. Configure it narrowly to avoid observing unrelated activity or creating feedback loops. childList tracks added and removed child nodes. disconnect stops future observation. It is often better to update your own UI directly when you control the mutation; observers help when another component owns it.
Your mission
Observe direct child changes to #list and update #count.
Starting HTML
<ul id="list"></ul><output id="count">0</output>
Worked JavaScript
const list = document.querySelector('#list');
const observer = new MutationObserver(() => { document.querySelector('#count').textContent = list.children.length; });
observer.observe(list, { childList: true });
Hint: Observe childList on the list, not on the output you modify.
Explain: When are mutation observer callbacks delivered?
After the current synchronous work
Try next: Explain how observing and changing the same subtree could cause a loop.
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>
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.
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>
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>
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.
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.
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>
A reusable initializer receives a root element and finds controls inside it. State declared inside that initializer belongs to one instance. Global selectors or a single shared counter can accidentally connect independent widgets. Build a counter twice using the same function, then verify that interacting with one leaves the other unchanged.
Your mission
Initialize each .counter with independent state starting at zero; its button adds one.
function mountCounter(root){let value=0;root.querySelector('button').addEventListener('click',()=>{root.querySelector('output').textContent=String(++value);});}
document.querySelectorAll('.counter').forEach(mountCounter);
Hint: Accept a root parameter and select beneath that root.
Explain: Where should instance-specific counter state live?
Inside each initializer invocation
Try next: Add a third counter without adding another initializer function.
Calling setup twice can register two separate listeners when each call creates a new callback. A WeakSet can record which roots are mounted without attaching application metadata to every node. Idempotent setup means repeating setup does not multiply the action. It does not mean that the action itself can happen only once.
Your mission
Call mount twice but ensure one click increases output by exactly one.
Starting HTML
<button id="add">Add</button><output>0</output>
Worked JavaScript
const mounted=new WeakSet();
function mount(root){if(mounted.has(root))return;mounted.add(root);let n=0;root.querySelector('#add').addEventListener('click',()=>root.querySelector('output').textContent=String(++n));}
mount(document.body);mount(document.body);
Hint: Guard setup with a WeakSet before adding listeners.
Explain: What does idempotent setup prevent?
Duplicate installation of the same behavior
Try next: Add cleanup that allows a component to be mounted again.
A mounted component may later be removed or replaced. Returning a cleanup function makes ownership explicit: the caller can detach listeners before discarding the component. removeEventListener needs the same function reference used during registration. Merely writing a similar anonymous function will not remove the original listener.
Your mission
Make Add increment output; Dispose must detach that behavior.
CustomEvent lets a component announce an application-level fact without knowing every consumer. Put the payload in detail and choose whether the event bubbles. A containing section can listen for a bubbling event from a nested button. This separates a user action from the parent’s decision about how to display its result.
Your mission
Dispatch a bubbling topic-chosen event with detail.topic HTML; the panel renders that value.
A shadow root creates a separate DOM subtree with scoped style behavior. document.querySelector does not automatically search inside it. Attach an open shadow root to #host, create its elements, and query through host.shadowRoot. Shadow DOM is encapsulation, not a security boundary; an open root remains accessible to outside code.
Your mission
Create a button and counter inside an open shadow root; clicks increment its output.
An event travels through capture, target and bubble phases. Registering a listener with capture:true lets an ancestor observe the event before target and bubble listeners run. Do not confuse the current listener’s element with the original event target. Logging the order makes propagation easier to understand than memorizing a diagram.
Your mission
Show capture > target > bubble after one button click.
preventDefault cancels a cancelable browser action; it does not stop an event reaching ancestors. A form submission is a useful example because an interface often needs to handle data without navigating. Check defaultPrevented on the same event and separately observe that a parent still receives it.
Your mission
Cancel form submission while allowing its submit event to reach the outer section.
document.querySelector('#entry').addEventListener('submit',event=>event.preventDefault());document.querySelector('#outer').addEventListener('submit',event=>document.querySelector('output').textContent=event.defaultPrevented?'Canceled and bubbled':'Not canceled');
Hint: Inspect event.defaultPrevented in the outer listener.
Explain: Does preventDefault stop bubbling?
No; default behavior and propagation are separate
Try next: Try stopPropagation separately and explain the changed result.
A click may land on a span inside a button rather than on the button itself. closest finds the nearest matching ancestor, including the target. Delegate one listener from the list and resolve the intended action with closest. Checking that the resolved control belongs to the component avoids accidentally handling unrelated markup.
Your mission
Remove the correct row even when its nested span receives the click.
An AbortController can own several event listeners registered with its signal. Calling abort removes those listeners together. This is useful when a component has several controls that must share a lifecycle. It does not rewind DOM changes already made by earlier events.
Your mission
Wire Up and Down to shared numeric state; Stop removes both listeners.
let n=0;const controller=new AbortController();const render=()=>document.querySelector('output').textContent=String(n);document.querySelector('#up').addEventListener('click',()=>{n++;render();},{signal:controller.signal});document.querySelector('#down').addEventListener('click',()=>{n--;render();},{signal:controller.signal});document.querySelector('#stop').addEventListener('click',()=>controller.abort());
Hint: Use the same signal for both counter listeners.
Explain: What does abort do to listeners using its signal?
It removes those registered listeners
Try next: Create a new controller when implementing a restart action.
Distinguish user input from programmatic value changes
Assigning input.value changes the control but does not automatically dispatch input or change. A render function should therefore be called intentionally after application-driven changes, or a documented custom event can carry the update. Do not depend on browser events that are not emitted by the operation you used.
Your mission
Reflect typed text and the Use DOM button’s programmatic value in output.
FormData.getAll returns every value for a repeated field name. get returns only the first. Checkbox controls contribute only while checked, so a form can naturally describe a multi-selection. Render the selected values as text and show a clear empty state instead of assuming at least one option was chosen.
Your mission
Summarize all checked skill values, or show No skills selected.
Native constraints such as required and minlength can handle many basic rules. setCustomValidity adds an application-specific message and must be cleared with an empty string when the problem is fixed. A stale custom error can keep a form invalid even after the user enters a good value. Avoid collecting real personal data in practice forms.
Your mission
Reject the nickname admin, then clear the custom error for a different nickname.
const field=document.querySelector('#nickname');field.addEventListener('input',()=>{const blocked=field.value.trim().toLowerCase()==='admin';field.setCustomValidity(blocked?'Choose a non-reserved nickname.':'');document.querySelector('#help').textContent=blocked?'Choose a non-reserved nickname.':'Nickname available';});
Hint: Always set both the invalid and valid branches.
Explain: How is a custom validity error cleared?
Call setCustomValidity with an empty string
Try next: Add a visible description explaining the nickname rule before typing.
A form reset restores controls to their default values. The reset event is fired before the reset operation completes, so reading current values synchronously can capture the old state. Schedule a small follow-up after the default action, then render from the restored controls. This example uses a microtask and tests the result after the current task.
Your mission
Update output while typing and after a native form reset to DOM.
One selection can determine the choices in another control. Rebuild the second select from a clear data map when the first changes. Replace old options rather than appending duplicates, and choose a predictable initial option. Keeping data outside DOM construction makes the mapping easier to test and extend.
Your mission
Populate HTML/DOM for Web and JSON/Tables for Data without duplicate options.
A pending operation needs a visible state. Disable the submit action and use a busy flag so repeated submit events do not start overlapping work. Always restore the control in finally. This lesson simulates a local delay, not a real network save; its status must not imply data was sent anywhere.
Your mission
Run one local simulation at a time; disable the button while waiting and restore it afterward.
let busy=false,count=0;const form=document.querySelector('#save'),button=form.querySelector('button'),out=document.querySelector('output');form.addEventListener('submit',async event=>{event.preventDefault();if(busy)return;busy=true;button.disabled=true;out.textContent='Working locally';try{await new Promise(resolve=>setTimeout(resolve,30));out.textContent='Local simulation '+(++count);}finally{busy=false;button.disabled=false;}});
Hint: Return early when busy, and reset busy in finally.
Explain: Why use a busy flag as well as disabling the button?
Submission can be triggered through more than one path
Try next: Add a simulated failure path and verify that the control is still restored.
A tab interface pairs selectable controls with panels. aria-selected communicates the current tab, hidden controls which panel is shown, and tabindex keeps only the active tab in the ordinary tab order. This lesson focuses on state changes through clicks; the next lesson adds arrow-key movement. Do not use tab roles for ordinary page-navigation links.
Your mission
Select the first tab initially; clicking another updates selected state, tab stops and panel visibility.
Roving tabindex keeps one item in the Tab sequence while arrow keys move focus inside a widget. Update tabindex as focus moves and prevent the handled arrow key’s default action. This compact toolbar uses three buttons and wraps at the ends. It is a focus exercise, not a complete pattern for every possible widget.
Your mission
Move focus with Left/Right, wrap at the ends, and keep one button in the Tab sequence.
Opening a dialog changes the interaction context. On close, restore focus to the control that launched it so the user can continue from a predictable place. The native dialog handles important modal behavior, but you still need meaningful content and a clear close control. Test both the visible close action and Escape manually.
Your mission
Open the modal dialog and restore focus to Review details when closed.
Starting HTML
<button id="open">Review details</button><dialog id="review"><h2>Practice details</h2><p>No information is sent.</p><button id="close">Close</button></dialog>
A toggle button uses aria-pressed to expose its on/off state. Keep state separate from the visible label so the same label can remain stable. A native button already supports Enter and Space, so do not add duplicate keyboard click handlers. Render the status and accessibility state from the same boolean.
Your mission
Toggle aria-pressed and status while keeping the button label stable.
let saved=false;const button=document.querySelector('#favorite');button.addEventListener('click',()=>{saved=!saved;button.setAttribute('aria-pressed',String(saved));document.querySelector('output').textContent=saved?'Saved':'Not saved';});
Hint: Treat one boolean as the source of truth.
Explain: Do native buttons need an extra Enter-to-click handler?
Usually no; native keyboard activation already exists
Try next: Add a visible icon without making the icon the only state indicator.
An accordion contains disclosure buttons linked to content regions. Each button’s aria-expanded value must match its panel’s hidden state. This version allows only one open panel at a time and lets the current panel close. Native details would be simpler when its built-in behavior fits; custom markup requires you to own the state logic.
Your mission
Allow one expanded section; selecting another closes the previous, and selecting it again closes it.
Array positions can change after sorting or filtering. Store stable IDs in data attributes and use those IDs to find the intended record. A delegated action can then remain correct across re-renders. This example edits a title for the record with id b without relying on its current visible index.
Your mission
Render two identified records and rename only id b to Better Beta.
Filtering can produce zero matches even when the source collection is not empty. Show a clear message for the visible result set and remove it when matches return. Do not insert the empty-state message into your source array as if it were a real record. The UI should derive it from the filtered collection’s length.
Your mission
Filter the three lessons and show No matching lessons only for an empty result.
Undo requires enough information to restore the previous state. For a one-step deletion, a shallow array snapshot is sufficient when the records are not mutated. Save before the change, then render from the restored array. Clearly limit this example to one undo step rather than implying a complete history system.
Your mission
Remove the final record and offer one-step undo, disabling undo when unavailable.
Pagination is a view over source data. Compute a slice from page index and page size, then disable controls at the boundaries. Changing the page should not remove records from the source. This small collection has five items and a page size of two, so the last page contains one item.
Your mission
Show two items per page and disable navigation at the first and last pages.
Selection and visibility are different state dimensions. A Set of selected IDs can persist while the list is filtered. Recreate checked controls from that Set when the view changes, rather than inferring all selection from currently visible checkboxes. This distinction matters in bulk-action interfaces.
Your mission
Preserve checked selections by ID when filtering hides and reveals a record.
Debouncing waits for a pause before doing work. Clear the previous timeout when another input arrives, then schedule the latest value. This example delays a local output by 40 milliseconds; it does not fetch anything. Debouncing is useful only when waiting improves the experience or reduces meaningful work.
Your mission
Render only the latest input value after a short 40ms pause.
let timer;document.querySelector('#search').addEventListener('input',event=>{const value=event.currentTarget.value;clearTimeout(timer);timer=setTimeout(()=>document.querySelector('output').textContent=value,40);});
Hint: Capture the value before the timer callback.
Explain: What does clearing the previous timer prevent?
Outdated scheduled work from running
Try next: Add a visible pending indicator and clear it after rendering.
setTimeout returns an identifier that can be passed to clearTimeout. Cancellation should also update the interface so the user knows nothing further is expected. A cancel action cannot undo work that already finished. Keep a visible status for scheduled, canceled and completed states.
Your mission
Schedule Finished after 60ms; Cancel must prevent it and show Canceled.
let timer;const out=document.querySelector('output');document.querySelector('#schedule').addEventListener('click',()=>{clearTimeout(timer);out.textContent='Scheduled';timer=setTimeout(()=>out.textContent='Finished',60);});document.querySelector('#cancel').addEventListener('click',()=>{clearTimeout(timer);out.textContent='Canceled';});
Hint: Store the timeout identifier outside both handlers.
Explain: What can clearTimeout cancel?
A callback that has not yet run
Try next: Disable Cancel when there is no pending timer.
requestAnimationFrame schedules work before a future repaint. A pending flag can combine several synchronous requests into one update. The latest state should win when the callback finally runs. Do not use animation frames as a guaranteed clock for background tasks; browsers may pause them in hidden documents.
Your mission
Coalesce three immediate clicks into one animation-frame render showing 3.
let n=0,pending=false,renders=0;document.querySelector('#add').addEventListener('click',()=>{n++;if(pending)return;pending=true;requestAnimationFrame(()=>{pending=false;document.querySelector('output').textContent=String(n);document.querySelector('#renders').textContent=(++renders)+' renders';});});
Hint: Set pending before requesting the frame and clear it inside the callback.
Explain: What should the scheduled render use?
The latest state when it runs
Try next: Add a cleanup function that cancels a pending frame.
Two asynchronous requests can finish in a different order from their start order. A sequence number lets the interface accept only the most recent request’s result. This lesson uses local timers to model the race, so there is no network or AI service. The pattern prevents an older response from overwriting a newer choice.
Your mission
Keep Fast visible when a later fast action finishes before an earlier slow action.
let latest=0;async function load(label,delay){const id=++latest;document.querySelector('output').textContent='Loading';await new Promise(r=>setTimeout(r,delay));if(id!==latest)return;document.querySelector('output').textContent=label;}document.querySelector('#slow').addEventListener('click',()=>load('Slow',70));document.querySelector('#fast').addEventListener('click',()=>load('Fast',10));
Hint: Compare a captured request ID with the latest sequence number.
Explain: What should determine which result is accepted?
Whether it belongs to the latest requested action
Try next: Add a cancel action that invalidates the latest sequence.
MutationObserver can monitor changes, but it should stop when its component no longer needs updates. disconnect stops observation. Pending notifications may require thought in a real lifecycle; this example waits for each mutation before disconnecting so the behavior is clear. Count only the mutations your observer was configured to watch.
Your mission
Count added list nodes until Stop observing is clicked.
let count=0;const list=document.querySelector('#list'),observer=new MutationObserver(records=>{count+=records.reduce((n,r)=>n+r.addedNodes.length,0);document.querySelector('output').textContent=String(count);});observer.observe(list,{childList:true});document.querySelector('#add').addEventListener('click',()=>{const li=document.createElement('li');li.textContent='Item';list.append(li);});document.querySelector('#stop').addEventListener('click',()=>observer.disconnect());
Hint: Keep the observer reference accessible to the Stop handler.
Explain: What does disconnect stop?
Further observation by that observer
Try next: Use takeRecords to inspect pending records before cleanup.
getBoundingClientRect returns the element’s rendered border box relative to the viewport. Its measurements can be fractional and include transforms. Read after the relevant styles have been applied. This exercise measures a fixed authored fixture, not the entire browser window, so the result is predictable across preview sizes.
Your mission
Display the box’s rendered width and height as 180 × 80.
element.style only exposes inline declarations. getComputedStyle reports resolved styles after stylesheets and inheritance have been considered. Do not assume it returns the exact authored spelling; colors and lengths are often normalized. This example reads a CSS class’s color rather than an inline value.
ResizeObserver reacts when a particular element’s observed size changes. This is different from listening only to window resize, because a component can resize for many reasons. Avoid resizing the same target endlessly inside its callback. This example changes a fixed width through a button and reports the observed content width.
Your mission
Observe #box and report its content width after growing it to 200px.
Starting HTML
<div id="box" style="width:120px;height:40px;background:#ccfbf1">Resize target</div><button id="grow">Grow to 200</button><output></output>
matchMedia evaluates a media query and exposes whether it currently matches. It can also notify you when the result changes. User preferences such as reduced motion should influence nonessential animation without hiding content. This exercise renders an honest status based on the actual browser preference rather than assuming a fixed setting.
Your mission
Display Reduced motion or Standard motion based on the actual preference.
A status region should exist before you update its text. Replacing the entire region can make announcements less predictable. Keep the container stable and update textContent when an action completes. Automatic tests can verify DOM state, but a real screen-reader test is still needed to evaluate announcement timing and usefulness.
Your mission
Update the existing status region without replacing its node.
Starting HTML
<button id="finish">Finish local task</button><p id="status" role="status" aria-live="polite"></p>
User input is data, not trusted HTML. textContent renders angle brackets literally rather than creating elements from them. Avoid innerHTML for ordinary messages, comments or names. The preview has additional isolation, but the code you export should still use safe DOM construction when handling untrusted content.
Your mission
Show the input as literal text, including any angle brackets.
A URL can carry a scheme such as https or javascript. Validate the parsed protocol before turning user-provided data into an active link. This exercise accepts only HTTPS and rejects malformed or other-scheme values. A production product may need stricter host and path rules; accepting HTTPS alone does not prove a destination is trustworthy.
Your mission
Set href only for an absolute HTTPS URL; clear href for invalid or other-scheme input.
A selector can return null when markup changes or a component is optional. Decide whether to skip, report or fail clearly rather than letting an unrelated property access throw later. This example reports a useful message when a target is absent and leaves the rest of the page operational.
Your mission
Handle the absent #optional target without a runtime error.
document.querySelector('#attempt').addEventListener('click',()=>{const target=document.querySelector('#optional');if(!target){document.querySelector('output').textContent='Optional panel is not present';return;}target.textContent='Updated';});
Hint: Check the result before using textContent.
Explain: What is a useful response to an expected missing element?
Handle the null result explicitly
Try next: Add the optional panel and make the successful branch observable.
A broad query can select a similarly named element elsewhere on the page. Scope selectors to the component root and inspect which node was actually returned. This exercise contains two outputs with the same class, deliberately showing why a global query would update the wrong place.
An action may fail because its input is malformed. Catch the expected failure, display a useful message, and allow the next attempt to succeed. Do not swallow errors without feedback or expose private data in messages. This exercise parses a small JSON array locally and reports the count; no data is uploaded.
Your mission
Count a valid JSON array and show a helpful message for malformed or non-array input.
A component may have more than one action that changes its state. Put the DOM updates in one render function so reset and toggle cannot disagree about visibility or aria-expanded. This is a small example of a single source of truth: the boolean state drives every representation of the disclosure.
Your mission
Toggle the panel and let Reset return both visibility and aria-expanded to closed.
let open=false;function render(){document.querySelector('#panel').hidden=!open;document.querySelector('#toggle').setAttribute('aria-expanded',String(open));}document.querySelector('#toggle').addEventListener('click',()=>{open=!open;render();});document.querySelector('#reset').addEventListener('click',()=>{open=false;render();});render();
Hint: Both event handlers should change state then call render.
Explain: Why centralize the DOM updates in render?
Every action uses the same state-to-view mapping
Try next: Add a Close control inside the panel that uses the same render function.
Replacing the focused element can send focus somewhere unexpected. For a small list, update existing nodes when their identity has not changed. This exercise increments a row’s count in place so the row button keeps focus. Full keyed reconciliation is more complex, but the principle is simple: avoid destroying useful interaction state unnecessarily.
Your mission
Increment the row output without replacing the focused button.
let n=0;const button=document.querySelector('#add'),output=document.querySelector('output');button.addEventListener('click',()=>{output.textContent=String(++n);});
Hint: Update only the output text.
Explain: Why avoid replacing this button during rendering?
It preserves node identity and focus
Try next: Compare with replacing the entire row and observe focus behavior.
A notification has a small lifecycle: hidden, shown and dismissed. Use a stable status region for the message, and a separate close action when needed. Avoid moving keyboard focus merely to announce routine status. This local demonstration has no timer, so users control how long the notice stays visible.
Your mission
Show the notice, dismiss it, and return focus to the show control.
const notice=document.querySelector('#notice');document.querySelector('#show').addEventListener('click',()=>{notice.hidden=false;document.querySelector('#message').textContent='Your local practice is ready';});document.querySelector('#dismiss').addEventListener('click',()=>{notice.hidden=true;document.querySelector('#show').focus();});
Hint: Keep notification visibility separate from the message content.
Explain: Should routine status updates always steal focus?
No; use an appropriate announcement region
Try next: Test announcement behavior with assistive technology.
A master checkbox summarizes a group. It is checked when every item is selected and indeterminate when some but not all are selected. indeterminate is a JavaScript property, not a persistent HTML attribute. Update the summary after individual changes and update all items when the master is toggled.
Your mission
Keep the master checkbox checked, unchecked or indeterminate to match its two items.
A numeric stepper should enforce its range in state updates, not only through visual disabled buttons. Disable unavailable actions as helpful feedback, but keep guards in the handlers too. This example supports values zero through three. It tests repeated attempts at both boundaries without letting the state escape.
Your mission
Keep the value between 0 and 3 and disable unavailable actions.
let n=0;function render(){document.querySelector('output').textContent=String(n);document.querySelector('#minus').disabled=n===0;document.querySelector('#plus').disabled=n===3;}document.querySelector('#minus').addEventListener('click',()=>{n=Math.max(0,n-1);render();});document.querySelector('#plus').addEventListener('click',()=>{n=Math.min(3,n+1);render();});render();
Hint: Use Math.min and Math.max, then render both controls.
Explain: Why guard state updates as well as disabling controls?
The state rule should hold regardless of the action path
Try next: Make the minimum and maximum configurable.
Combine tab state, keyboard movement and panel visibility into one small reading interface. The active tab is the single Tab entry point; Left and Right move and activate tabs. Use linked IDs for tabs and panels. This is a focused two-tab implementation; a production component also needs testing with its actual content and assistive technologies.
Your mission
Build synchronized two-tab selection with click and wrapping Left/Right keyboard navigation.
A task composer combines form handling, safe text rendering, delegated deletion and status feedback. Trim input and reject blank titles. Use monotonically increasing IDs rather than visible positions. Render all displayed text with textContent so a task containing angle brackets is still a task name, not markup.
Your mission
Add nonblank tasks safely, clear the input, remove by ID and announce the outcome.
A local data explorer combines JSON parsing, validation, sorting and rendering. Accept only arrays of objects with a string name and finite numeric score. Validate every record before changing the displayed collection so a failed import does not partly replace good data. No file or network access is required; the text area is the data source.
Your mission
Validate all records before replacing the list, then sort descending by numeric score.
let items=[];const list=document.querySelector('#list'),status=document.querySelector('#status');function render(){list.replaceChildren(...items.map(x=>{const li=document.createElement('li');li.textContent=x.name+': '+x.score;return li;}));}document.querySelector('#load').addEventListener('click',()=>{try{const next=JSON.parse(document.querySelector('#data').value);if(!Array.isArray(next)||!next.every(x=>x&&typeof x.name==='string'&&typeof x.score==='number'&&Number.isFinite(x.score)))throw Error('Invalid records');items=next.map(x=>({name:x.name,score:x.score}));render();status.textContent=items.length+' records loaded';}catch{status.textContent='Use an array of name/score records';}});document.querySelector('#sort').addEventListener('click',()=>{items=[...items].sort((a,b)=>b.score-a.score);render();});
Hint: Validate into a temporary next variable before assigning items.
Explain: When should the current collection be replaced?
After the entire new dataset passes validation
Try next: Add a record limit and a filter without mutating the source order.
A multistep interface needs clear state, validation and predictable focus movement. Keep entered values in the controls as sections are hidden and shown. This three-step planner asks for a goal, then a duration, then shows a summary. It is a local practice interface with no submission or persistence in the preview.
Your mission
Validate a goal and duration, navigate steps, preserve input and show a final summary.
let step=1;const status=document.querySelector('#status');function render(){for(let i=1;i<=3;i++)document.querySelector('#step'+i).hidden=i!==step;document.querySelector('#back').disabled=step===1;document.querySelector('#next').disabled=step===3;if(step===1)document.querySelector('#goal').focus();if(step===2)document.querySelector('#minutes').focus();if(step===3)document.querySelector('output').textContent=document.querySelector('#goal').value.trim()+' — '+document.querySelector('#minutes').value+' minutes';}document.querySelector('#next').addEventListener('click',()=>{if(step===1&&!document.querySelector('#goal').value.trim()){status.textContent='Enter a learning goal';return;}const minutes=Number(document.querySelector('#minutes').value);if(step===2&&(!Number.isFinite(minutes)||minutes<5||minutes>120)){status.textContent='Choose 5 to 120 minutes';return;}if(step<3)step++;status.textContent='Step '+step+' of 3';render();});document.querySelector('#back').addEventListener('click',()=>{if(step>1)step--;render();});render();
Hint: Change the step only after its current inputs validate.
Explain: What should moving between steps preserve?
The learner’s entered values
Try next: Add an editable summary and a text download in your exported project.
Combine filtering, stable IDs, selection state, safe rendering, empty feedback and reset into one small dashboard. The source collection remains constant; saved IDs and the query describe the changing view. Derive counts and button labels from state after every action. The test crosses several views to catch bugs that a single happy-path click would miss.
Your mission
Build searchable resources, persistent saved IDs, saved-only mode, empty feedback and a full reset.