This course introduces the fundamental concepts around the Document Object Model (DOM) and shows how to add interactivity to HTML using JavaScript. Throughout the course, a single-page application (a fictional ticket-selling site called GloboTicket) is built and progressively enhanced with DOM manipulation, event handling, keyboard/mouse/touch/pointer input, drag and drop, animations, and audio/video playback.
Table of Contents
- Module 1: Working with the Document Object Model
- Module 2: Enhancing the Interaction with the User
- Module 3: Adding Drag and Drop Support
- Module 4: Animating Your Content
- Module 5: Using Audio and Video in HTML
- Summary
Module 1: Working with the Document Object Model
The DOM: Connecting HTML and JavaScript
The Document Object Model (DOM) connects web pages to JavaScript by representing the structure of a document in memory. It is the bridge between the static HTML markup and the dynamic behavior implemented with JavaScript.
The DOM API — the collection of functions and properties described throughout this course — is available on several kinds of objects:
- The
windowglobal object, which represents the current page navigation shown in the browser. - The
documentobject, which represents the current HTML document as a whole. - One object per HTML element (and other node types) in the document.
Every element that exists in the HTML, or is currently rendered on the page, has a corresponding JavaScript object. If you have an HTML document, each element in it is represented by an object of the HTMLElement interface, or of a more specific interface that inherits from it (for example, HTMLBodyElement, HTMLHeadingElement, HTMLImageElement). These objects expose instance properties and methods, exactly like any other object in JavaScript.
Key characteristics of this relationship:
- Changes trigger updates. Changing a property or the children of an object triggers an update in the rendered user interface. Change a paragraph’s property from JavaScript, and the rendering updates immediately.
- Events can be observed. You can listen to events happening on an element and react accordingly — for example, knowing when a user clicks a button.
flowchart LR
subgraph HTML_Document["HTML Document"]
A["<body>"] --> B["<header>"]
B --> C["<h1>"]
A --> D["<p>"]
A --> E["<img>"]
end
subgraph DOM_Memory["Document Object Model (in JavaScript memory)"]
A2["HTMLBodyElement object"] --> B2["HTMLElement object (header)"]
B2 --> C2["HTMLHeadingElement object"]
A2 --> D2["HTMLParagraphElement object"]
A2 --> E2["HTMLImageElement object"]
end
HTML_Document -. "represented in memory as" .-> DOM_Memory
To work with DOM elements you can:
- Pick them from the current DOM (search for an existing element), or create new elements and inject them into the DOM.
- Interact with the current HTML, or build an entirely new dynamic DOM structure that generates its own HTML.
Once you have a reference to an element in JavaScript you can read its content, change its content, remove it completely from the DOM (removing it from the screen), or add new child elements to it. For instance, deleting the reference to an <h1> element from JavaScript removes it from the HTML — and the heading disappears from the screen. Similarly, removing the hidden attribute from an <img> reference makes a previously hidden image visible, and creating a brand new HTMLImageElement in memory and inserting it into the DOM adds a new <img> tag to the rendered HTML.
Preparing the GloboTicket Web App
The project used throughout the course is GloboTicket, a sample web app for selling tickets to events such as concerts, theater shows, and sports events. It is built as a single-page application (SPA): there is only one HTML file, and JavaScript is used to change the displayed content dynamically, rather than navigating to multiple HTML files.
The starting project already contains:
- The static HTML and CSS files.
- All the images needed.
- A
data.jsonfile describing the events currently for sale.
The job throughout the course is to use the DOM APIs to add interactivity on top of this static markup.
Project structure
index.html
css/
normalize.css
main.css
img/
thumb/
media/
data.json
scripts/
app.js
router.js
cart.js
keyboard.js
mouse.js
dragdrop.js
media.js
index.html is the only HTML file in the project. It has a standard <head> and <body>. Inside the body there is a <header> section (containing the logo and cart order information, e.g. “0 tickets in your cart”), followed by three <section> elements, each with the class page:
page-home— the home page, listing all events.page-details— the details of a single event.page-cart— the shopping cart and the order form.
Each “page” is simply an HTML element; the router (built later in this module) decides which one is visible at any given time.
The css folder has two stylesheets that are not modified much during the course. The img folder holds the images currently used (for example, an events thumbnail such as sports3.jpg, referenced from img/thumb). The media folder holds audio and video used later in the course. Finally, data.json contains an array of objects — the events available for sale — for example a Concert, a Theater ticket (“The Phantom of the Opera”), and Sports events (“Champions League Final: Real Madrid vs Liverpool”). Each event object includes properties such as date, description, price, image name, and a color used to theme the UI.
Setting up the first script
In the original HTML, a placeholder comment (<!-- Insert your scripts here -->) marks where scripts should be added. It is replaced with:
<script src="scripts/app.js" defer></script>
The Boolean defer attribute is used for performance — the script is downloaded in parallel with page parsing but executed only after the document has been parsed.
Since the scripts folder and app.js do not exist yet, they are created. The first code written in app.js is a “spoiler” of what is coming:
window.addEventListener("DOMContentLoaded", () => {
console.log("App is ready");
});
addEventListener (note the capitalization: capital E in Event, capital L in Listener) takes an event name as its first argument — here, DOMContentLoaded, which fires when the page is ready for DOM manipulation — and a function as the second argument, written here as an arrow function.
To view the page over HTTP (rather than as a local file), a “Live Server” extension is installed in Visual Studio Code, which serves the project at http://localhost:5500. Once loaded, the console shows App is ready, confirming that the script executed after the DOM was parsed.
Using the browser’s Elements panel, it is possible to inspect the current state of the DOM: the page-cart section is hidden by default (removing the hidden attribute in the inspector reveals the cart), and the page-home section is currently empty — nothing has been rendered into it yet. With the environment set up, the DOM APIs can now be used to fill the home page with content and to add page navigation with JavaScript.
DOM Essentials
Elements can be selected from the DOM by:
- ID
- class name
name(the HTML attribute typically used on form elements)- CSS selector
- navigating the existing DOM structure (e.g., from a list element to its children)
Some selection functions return a single element (a node); others return multiple elements (a NodeList).
| Function | Returns | Notes |
|---|---|---|
document.getElementById(id) | One element or null | Fastest single-element lookup |
document.querySelector(selector) | First matching element or null | Accepts any CSS selector |
document.getElementsByTagName(tag) | Live collection | E.g., all <button> or <p> elements |
document.getElementsByClassName(class) | Live collection | |
document.querySelectorAll(selector) | Static NodeList | Accepts any CSS selector, can be iterated with forEach |
document.getElementsByName(name) | Live collection | Useful mainly for form elements |
Functions that return a single element can return null if nothing matches, so the result should always be checked:
const element = document.getElementById("first-item");
if (element) {
// do something with element
}
const row = document.querySelector("section header table tr");
Functions that return multiple elements can return an empty collection if nothing matches:
const items = document.querySelectorAll("#menu li");
if (items.length > 0) {
const first = items[0];
}
const importantElements = document.getElementsByClassName("important");
for (const element of importantElements) {
// ...
}
Once you have a reference to an HTML element in JavaScript, you can:
- Read and change attribute values.
- Read and change styles.
- Hook event listeners.
- Add, remove, or move child elements.
- Read and change its contents.
Attributes and styles
Most HTML attributes map to a JavaScript property of the same name, with some exceptions — for example, the class HTML attribute maps to the className JavaScript property.
element.hidden = false;
imageElement.src = "new-image.jpg";
Styles are accessed through the style object, translating CSS’s kebab-case property names (e.g., font-size) into JavaScript camelCase (fontSize):
element.style.color = "red";
element.style.fontSize = "16px";
element.style.borderRightColor = "black"; // border-right-color -> borderRightColor
Event binding basics
element.addEventListener("click", eventHandler);
// or, more commonly, using an inline literal/arrow function:
element.addEventListener("load", (event) => {
// event contains additional metadata about what happened
});
If the event argument is not needed, the parentheses can be left empty: () => { ... }. Because addEventListener is used (rather than an onevent property), more than one listener can be attached to the same event on the same object. There is also a matching removeEventListener for more advanced scenarios.
Reading and writing content
| Technique | Purpose |
|---|---|
element.textContent | Read/write plain text only (no HTML parsing) |
element.innerHTML | Read/write an HTML string; the browser parses it into DOM nodes |
document.createElement() + appendChild() | Build new nodes programmatically |
// textContent
const p = document.querySelector("p");
console.log(p.textContent);
p.textContent = "New text";
// innerHTML
const header = document.getElementById("section1");
console.log(header.innerHTML);
header.innerHTML = `<h2>New heading</h2><p>New paragraph</p>`;
// DOM APIs
const h1 = document.createElement("h1");
h1.textContent = "New heading";
header.appendChild(h1);
const p2 = document.createElement("p");
p2.textContent = "New paragraph";
header.appendChild(p2);
Both approaches (innerHTML with a template string, or manual createElement/appendChild) achieve the same visual result; they are just different ways to work with the DOM.
Creating Dynamic HTML Content
With the essentials covered, the home page can now be filled with content read from data.json.
In the HTML, the home page contains a <table> with an (initially empty) <tbody>, and a hidden search-bar (kept hidden for now, used later for filtering).
The overall approach:
- Wait for
DOMContentLoaded. fetch("data.json"),awaitthe response, and parse it withresponse.json().- Store the parsed array in a global
eventsvariable. - Loop through
eventsand, for each one, build a table row and append it to thetbody.
let events = [];
window.addEventListener("DOMContentLoaded", async () => {
const response = await fetch("data.json");
events = await response.json();
loadEvents();
});
function loadEvents() {
events.forEach(event => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td class="event-date">${event.date}</td>
<td class="event-name">${event.title}</td>
<td class="event-artist">${event.artist}</td>
<td class="event-price">$ ${event.price}</td>
<td class="event-image">
<img src="img/thumb/${event.image}.jpg" alt="Event Thumbnail">
</td>
`;
document.querySelector("#page-home tbody").appendChild(tr);
});
}
Because await requires an async function, the DOMContentLoaded handler is upgraded to async () => { ... }. Note that fetch() requires an active local server (which is why Live Server was installed earlier) — loading the file directly from disk (file://) does not work for the fetch API in most browsers.
An intermediate step in the walkthrough builds each <td> manually with document.createElement("td") and appendChild, before switching to the more concise innerHTML template-string approach shown above — both are valid DOM techniques, and the template-string version scales better as more cells are added (title, date, artist, price, and an image cell).
A few important observations from this exercise:
- Everything added this way is visible in the DevTools Elements panel (you can see the dynamically created
<tr>/<td>elements), but it is not present if you “View Page Source” — the original HTML file’s<tbody>is still empty on disk. - All DOM manipulation happens in memory. Refreshing the page, or navigating back to it, reloads the original static HTML and re-executes the JavaScript from scratch — any in-memory changes are lost and rebuilt.
flowchart TD
A["DOMContentLoaded fires"] --> B["fetch('data.json')"]
B --> C["await response.json()"]
C --> D["events array populated"]
D --> E["loadEvents(): forEach event"]
E --> F["createElement('tr') + innerHTML template"]
F --> G["appendChild to #page-home tbody"]
G --> H["Browser re-renders table row"]
Navigating Between Pages Using the DOM
Clicking on an event (e.g., “Lana Del Rey Live”) should navigate to its details page; clicking the cart icon should navigate to the cart. In the HTML, these are anchor (<a>) elements pointing to / and /cart, but by default clicking them does nothing useful in an SPA context — this behavior must be implemented.
There are two general techniques for changing content in a single-page application when the user navigates from page A to page B:
- Remove/inject: remove the previous page’s element from the DOM and inject the new page’s element.
- Hide/show: keep all pages in the DOM permanently, and toggle which one is visible (via the
hiddenproperty, or CSS).
GloboTicket uses the hide/show technique: all three page-* sections always exist in the DOM; only one is visible (not hidden) at a time.
stateDiagram-v2
[*] --> page_home
page_home --> page_details: click event row
page_details --> page_home: click logo / Back
page_home --> page_cart: click cart icon
page_cart --> page_home: click logo
page_details --> page_cart: Add to Cart -> click cart icon
To implement navigation, a router.js script is added, which:
- Enhances existing links (those with the class
spa-link) so clicks are intercepted. - Pushes new history entries using the History API whenever the URL changes.
- Listens for
popstate(browser Back/Forward buttons) to re-render the correct page. - Hides/shows the relevant
page-*element based on the current path.
addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("a.spa-link").forEach(element => {
element.addEventListener("click", event => {
event.preventDefault();
goTo(element.href, null);
});
});
});
function goTo(path, data) {
history.pushState(data, null, path);
renderRoute();
}
function renderRoute() {
const path = document.location.pathname;
document.querySelectorAll(".page").forEach(page => {
page.hidden = true;
});
switch (path) {
case "/":
document.getElementById("page-home").hidden = false;
break;
case "/details":
document.getElementById("page-details").hidden = false;
break;
case "/cart":
document.getElementById("page-cart").hidden = false;
break;
}
}
addEventListener("popstate", () => {
renderRoute();
});
Key points about this implementation:
event.preventDefault()stops the browser’s default navigation for the clicked link, since the router handles it instead.history.pushState(data, unusedTitle, path)changes the URL bar without reloading the page. The second argument (page title) is effectively unused by browsers today, but a value (e.g.,null) must still be passed.- Because
addEventListenersupports multiple listeners per event/object combination,router.jscan register its ownDOMContentLoadedhandler independently fromapp.js’s handler — each script stays focused on a single concern. document.querySelectorAll(".page").forEach(page => page.hidden = true)hides every page before showing the correct one, using aswitchondocument.location.pathname.- The
popstateevent fires whenever the user uses the browser’s Back/Forward navigation buttons, sorenderRoute()must also be called there to keep the UI in sync with the address bar.
With this in place, clicking the cart icon shows the cart, clicking the logo returns to the home page, and the browser’s Back button correctly restores the previous page.
Keeping the DOM Updated: Rendering Event Details
Clicking on an event row should navigate to /details and show that specific event’s information. Because each <tr> created in loadEvents() is a genuine DOM element, an event listener can be attached to it directly:
tr.addEventListener("click", () => {
goTo("details", event);
});
The second argument to goTo is the metadata (data) that flows through pushState into renderRoute, allowing the router to know which event to render, not just which page.
renderRoute is updated to accept and forward that data to a new function, renderEventDetails(event):
function renderEventDetails(event) {
console.log(`We will render ${event.type}`);
const page = document.getElementById("page-details");
page.querySelector(".event-type").textContent = event.type;
page.querySelector(".event-title").textContent = event.title;
page.querySelector(".event-artist").textContent = event.artist;
page.querySelector(".event-date").textContent = event.date;
page.querySelector(".event-description").textContent = event.description;
page.querySelector(".event-price").textContent = `$ ${event.price}`;
page.querySelector(".event-image").src = `img/thumb/${event.image}.jpg`;
}
Rather than creating new elements from scratch (as with createElement/innerHTML on the home page), the details page already has all the necessary placeholder elements in the static HTML — they simply need their content filled in. For performance, instead of repeatedly calling document.querySelector(...) against the entire document, a reference to the page-details container is obtained once (const page = document.getElementById("page-details")), and subsequent lookups use page.querySelector(...), scoping the search to just that section. This avoids re-scanning the whole document for every property.
For the image, the src attribute is updated rather than textContent.
With this in place, clicking on any event row navigates to its details and fills in the correct data; the Back button and navigating to a different event both work correctly.
Module 1 Summary
This module introduced the DOM, the Document Object Model, and its basic concepts:
- Finding elements with
getElementById,querySelector, andquerySelectorAll. - Creating new elements with
document.createElementand injecting them withappendChild. - Changing text and HTML content, and managing attribute values.
- Basic event handling, including listening for
DOMContentLoadedon thewindowobject. - Manipulating the browser’s history to push new URLs and build single-page-application navigation.
The next module continues the GloboTicket project by adding more event handlers to add tickets to the cart and to style content dynamically with CSS.
Module 2: Enhancing the Interaction with the User
This module advances dynamic content creation with more DOM API techniques: changing CSS styles from JavaScript, working with form controls, advanced event-binding techniques, form validation, reacting to keyboard events, and reacting to mouse and pointer events — including multi-touch support.
Event Binding
Every DOM element exposes a set of possible events. Some categories:
| Category | Examples |
|---|---|
| General | load, click, dblclick |
| Form control value changes | change |
| Keyboard | keyup, keydown, keypress |
| Mouse | mouseover, mouseout, and more |
| Pointer/touch | Covered later in this module |
| Special/global | DOMContentLoaded, popstate (on window) |
To bind functions to events, there are two options:
| Technique | Syntax | Limitation |
|---|---|---|
onevent property | element.onclick = function | Only one handler per event/object combination — assigning a second one replaces the first |
addEventListener | element.addEventListener("click", function) | Supports multiple handlers per event/object combination (recommended) |
// onevent property - only the LAST assignment wins
button.onload = handlerA;
button.onload = handlerB; // handlerA is now gone
// addEventListener - both handlers run
button.addEventListener("click", handlerA);
button.addEventListener("load", () => { /* ... */ });
Advanced addEventListener options:
element.addEventListener("scroll", handler, { once: true }); // handler fires only once, useful for performance
element.removeEventListener("click", handler); // stop listening
Custom events let you reuse the same event system for your own application messages: you create an event with your own name, dispatch it on any DOM element (or the document), and any part of the app can listen for it — even though it is not a native browser event.
Adding an “Add to Cart” button
In the details page, there is currently a quantity selector but no way to add a ticket to the cart. The button does not exist in the DOM yet, so it is created in renderEventDetails:
function renderEventDetails(event) {
// ...existing field rendering...
const button = document.createElement("button");
button.textContent = "Add to Cart";
button.onclick = () => {
const quantity = parseInt(document.getElementById("quantity-dropdown").value);
const item = { event, quantity };
const cartAddEvent = new Event("cartadd");
cartAddEvent.item = item;
document.dispatchEvent(cartAddEvent);
};
document.querySelector(".event-purchase-button").appendChild(button);
}
Notes on this snippet:
button.onclick = ...demonstrates theoneventproperty style (deliberately, to show the technique) — note that assigningonclickagain later would replace this handler.parseInt(...)converts the string value of the<select id="quantity-dropdown">element into a number.- A custom event named
cartaddis created withnew Event("cartadd"). Because JavaScript event objects are just objects, an arbitraryitemproperty can be attached to it before dispatching. document.dispatchEvent(cartAddEvent)broadcasts to the whole document that something should be added to the cart — any listener anywhere in the app can react.- The button element must also be appended into the DOM (inside the
.event-purchase-buttondiv) or it will never be visible, since creating it only builds it in memory.
Working with Custom Events
A new script, cart.js, is created (and referenced with <script src="scripts/cart.js" defer>) to own the cart’s state and rendering.
let cart = [];
document.addEventListener("cartadd", event => {
cart.push(event.item);
document.dispatchEvent(new Event("cartupdate"));
});
document.addEventListener("cartupdate", () => {
let quantity = 0;
cart.forEach(item => quantity += item.quantity);
document.querySelector(".header-cart span").textContent = quantity;
const tbody = document.querySelector("#page-cart tbody");
tbody.innerHTML = ""; // clear before re-rendering, avoids duplicated rows
cart.forEach(item => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td class="event-name">${item.event.title}</td>
<td class="event-date">${item.event.date}</td>
<td class="event-basket-quantity">x${item.quantity}</td>
<td class="event-total">$ ${item.event.price * item.quantity}</td>
<td class="event-remove"><img src="img/quantity-x.svg" alt="Remove"></td>
`;
tr.querySelector(".event-remove img").addEventListener("click", () => {
cart.splice(cart.indexOf(item), 1);
document.dispatchEvent(new Event("cartupdate"));
});
tbody.appendChild(tr);
});
}
sequenceDiagram
participant User
participant DetailsPage as app.js (details page)
participant Document
participant CartJS as cart.js
User->>DetailsPage: click "Add to Cart"
DetailsPage->>Document: dispatchEvent("cartadd", item)
Document->>CartJS: cartadd listener fires
CartJS->>CartJS: cart.push(item)
CartJS->>Document: dispatchEvent("cartupdate")
Document->>CartJS: cartupdate listener fires
CartJS->>CartJS: recompute quantity, rebuild cart table
CartJS-->>User: header counter + cart table refreshed
Two bugs surfaced (and were fixed) during this exercise:
- Duplicated “Add to Cart” buttons. Because
renderEventDetailscreates a brand-new button every time details are rendered, navigating back and forth into details repeatedly appended additional buttons. Fix: clear the button container before appending a new one, either withcontainer.innerHTML = ""or by iterating and removing eachchildrennode. - Duplicated cart rows. The cart table was accumulating rows on every
cartupdatebecause the table body was never cleared before appending new rows. Fix: settbody.innerHTML = ""before theforEachthat rebuilds the rows (shown above). This is a common pattern any time a render function can run more than once.
To remove an item, an “x” remove icon (img/quantity-x.svg) is added to each row, and a click listener uses cart.indexOf(item) with Array.prototype.splice to remove exactly one element from the array before dispatching another cartupdate event, keeping the array and the UI in sync.
Working with Form Elements
The home page has a hidden filter <select name="filter-dropdown"> with options All, Concert, Theater, Sports. Removing its hidden attribute reveals the filter UI, but selecting an option initially does nothing.
Binding must happen only after DOMContentLoaded, since the elements do not exist before the DOM is parsed:
let filteredEvents = [];
addEventListener("DOMContentLoaded", () => {
filteredEvents = events; // start with everything visible
const dropdown = document.getElementsByName("filter-dropdown")[0];
// getElementsByName ALWAYS returns a collection - [0] gets the actual element
dropdown.addEventListener("change", () => {
if (dropdown.value === "All") {
filteredEvents = events;
} else {
filteredEvents = events.filter(event => event.type === dropdown.value);
}
loadEvents(); // re-render using filteredEvents
});
});
loadEvents() is updated to loop over filteredEvents instead of events. Because loadEvents() can now run more than once (once at startup, and again on every filter change), it must also clear the tbody before appending rows — the same “clear before re-render” pattern seen with the cart.
Form submission and validation
The cart page’s <form> already declares native HTML validation attributes (minlength, required, type="email", etc.). Listening for the button’s click event would miss form submissions triggered by pressing Enter on a physical keyboard, or tapping the Go/Submit key on a mobile virtual keyboard. The correct event to listen for is the form’s own submit event, which fires in both cases:
addEventListener("DOMContentLoaded", () => {
const form = document.querySelector("#page-cart form");
form.addEventListener("submit", event => {
event.preventDefault(); // stop the browser's default POST-and-navigate behavior
if (form.checkValidity()) {
alert("Your order was submitted");
for (const control of form.elements) {
control.value = "";
}
} else {
form.reportValidity(); // shows native validation error UI to the user
}
});
});
event.preventDefault()stops the browser from submitting the form to a server, since the app handles it in JavaScript instead.form.checkValidity()runs all HTML5 semantic validations (required,minlength,type="email", etc.) and returns a Boolean.form.reportValidity()forces the browser to display its native validation error bubbles to the user when the form is invalid.form.elementsis a “form controls” NodeList (inputs,<select>,<textarea>— but not plain<div>s or<p>s inside the form). Looping over it withfor...ofand resettingvalue = ""clears the entire form after a successful submission.
Changing Styles on DOM Elements
Each event object in data.json has a color property (e.g., darkblue, darkred, purple). This is used to theme rows and, later, the details page and header.
There are two general approaches to styling from JavaScript: toggling CSS classes (via className or the classList API), or setting inline styles directly through the style object. This section focuses on inline styling.
tr.style.borderTop = "1px solid black"; // shorthand property
// or, property by property:
tr.style.borderTopColor = event.color;
tr.style.borderTopStyle = "solid";
tr.style.borderTopWidth = "5px"; // numeric CSS values MUST be strings with a unit
CSS properties are translated to camelCase in JavaScript (border-top-color → borderTopColor). This is used to color-code each row per event, and to color the event title on the details page:
page.querySelector(".event-title").style.color = event.color;
Theming the header
A reusable helper changes the header’s background color (and, optionally, the mobile browser toolbar color) based on the current event:
function setThemeColor(color) {
document.querySelector("header").style.backgroundColor = color;
document.querySelector("meta[name=theme-color]").content = color;
}
The <meta name="theme-color"> tag is used by some mobile/desktop browsers (Safari on macOS/iOS/iPadOS, Chrome on Android) to tint the browser’s own toolbar or title bar to match the page.
Calling setThemeColor(event.color) when rendering event details tints the header to match that event. However, the color needs to be reset when navigating away from details (back to home or to the cart). Rather than hard-coding this reset inside the router, a custom event is dispatched every time a route renders:
function renderRoute() {
// ... hide/show pages as before ...
document.dispatchEvent(new Event("routerender"));
}
document.addEventListener("routerender", () => {
setThemeColor("black"); // reset to the default theme on every navigation
});
renderEventDetails can then call setThemeColor(event.color) after the generic reset has already run, since routerender fires as part of renderRoute(), and the details-specific override happens afterward in the details-rendering code path. This pattern — dispatching a generic custom event on every route change so that any module can react — recurs throughout the rest of the course (mouse hover effects, media playback cleanup, etc.).
Using Keyboard Events
A new script, keyboard.js, handles keyboard shortcuts. There are two ways to listen for keyboard input: globally on document, or scoped to a focused form control. This course uses the global approach.
| Event | Fires when | Available on |
|---|---|---|
keydown | Key is pressed down (not yet released); repeats while held | All keys, including modifiers (Ctrl, Alt, Meta/Cmd) |
keyup | Key is released | All keys, including modifiers |
keypress | Similar to a filtered keyup | ”Normal” printable keys only, not all special keys |
Every keyboard event carries the pressed key’s name in event.key, plus Boolean modifier flags: ctrlKey, altKey, shiftKey, metaKey (the Cmd key on macOS).
document.addEventListener("keyup", event => {
console.log(`Key pressed: ${event.key}`);
if (event.ctrlKey && event.key === "a") {
// Ctrl+A -> reset filter to "All"
const dropdown = document.getElementsByName("filter-dropdown")[0];
dropdown.value = "All";
dropdown.dispatchEvent(new Event("change")); // force the change handler to run
} else if (event.key === "h") {
goTo("/");
} else if (event.key === "c") {
goTo("/cart");
}
});
An important nuance: setting dropdown.value = "All" from JavaScript does not fire the change event by itself (the user didn’t manually change it). To trigger the filtering logic that is bound to change, the event must be dispatched manually: dropdown.dispatchEvent(new Event("change")).
Arrow-key navigation between details pages
When viewing an event’s details, pressing the Left/Right arrow keys navigates to the previous/next event in the currently filtered list. keydown (rather than keyup) is preferred here because holding the key down causes the browser to keep firing keydown repeatedly (roughly once every couple of seconds, then faster), allowing rapid navigation while the key is held.
let currentEvent = null; // tracked globally, set whenever /details is rendered
document.addEventListener("keydown", event => {
if (location.pathname !== "/details") return;
const position = filteredEvents.indexOf(currentEvent);
let nextEvent;
switch (event.code) {
case "ArrowRight":
nextEvent = position < filteredEvents.length - 1
? filteredEvents[position + 1]
: filteredEvents[0]; // wrap around to the first
break;
case "ArrowLeft":
nextEvent = position > 0
? filteredEvents[position - 1]
: filteredEvents[filteredEvents.length - 1]; // wrap around to the last
break;
}
if (nextEvent) {
goTo("/details", nextEvent);
}
});
event.code (rather than event.key) is used to detect physical arrow keys (ArrowLeft, ArrowRight) reliably regardless of keyboard layout. console.log(event.code) is a handy way to discover the correct code for any key.
Using Mouse, Touch, and Pointer Events
There are three overlapping event families for pointing devices:
| Event family | Devices | Multitouch? | Extra metadata |
|---|---|---|---|
| Mouse events | Mouse, trackpad, touch, pen/stylus (legacy, since the early web) | No — single pointer only | Coordinates (clientX/clientY), keyboard modifiers |
| Touch events | Touch screens only | Yes — native, always an array of touch points | Force/pressure on some devices |
| Pointer events | Mouse, trackpad, touch, pen/stylus (modern, unified) | Yes — one event execution per pointer | Pointer ID, pointer type (mouse/touch/pen), pressure, tilt, twist |
flowchart TB
A["User interacts with a pointing device"] --> B{"What device / API?"}
B -->|"Mouse, trackpad, touch, or pen -- classic, single pointer"| C["Mouse Events\nclick, dblclick, mousedown/up,\nmouseover/out, mousemove,\nmouseenter/leave"]
B -->|"Touch screen only, native multitouch"| D["Touch Events\ntouchstart, touchmove,\ntouchend, touchcancel\n(array of touch points)"]
B -->|"Any device, modern unified API"| E["Pointer Events\npointerdown/up, pointerover/out,\npointermove, pointerenter/leave\n(one event per pointer + pointerId,\npointerType, pressure, tilt)"]
Mouse events include the classic click/dblclick, press/release pairs, hover detection (mouseover/mouseout), and drag-related events (mousemove, mouseenter, mouseleave). All carry clientX/clientY coordinates and keyboard modifiers (e.g., detecting Ctrl+click), but they never reveal what triggered them (mouse vs. pen vs. finger), and they do not support true multitouch.
Touch events (touchstart, touchmove, touchend, touchcancel) are touch-screen only. touchcancel fires when the OS interrupts the touch (e.g., an incoming alert covers the screen). touchmove reports an array of coordinates — one per finger — and on some devices also reports force/pressure.
Pointer events are the most modern option, unifying mouse, trackpad, touch, and pen under one model, while still supporting multitouch (pointerdown/pointerup fire once per finger/pointer, each carrying a pointerId, a pointerType — "mouse", "touch", or "pen" — and, on supporting hardware, pressure, tilt, and twist).
Hover effect on table rows
A new script, mouse.js, listens for a custom eventsload event (dispatched from app.js right after loadEvents() finishes rendering the table) to attach hover handlers to every row:
// in app.js, after rendering all rows:
document.dispatchEvent(new Event("eventsload"));
// mouse.js
document.addEventListener("eventsload", () => {
document.querySelectorAll("#page-home tr").forEach(tr => {
tr.addEventListener("mouseover", event => {
tr.classList.add("over");
setThemeColor(tr.dataset.color);
});
tr.addEventListener("mouseout", () => {
tr.classList.remove("over");
if (location.pathname === "/") {
setThemeColor("black");
}
});
});
});
classList is a convenient API for adding/removing CSS classes (tr.classList.add("over") / tr.classList.remove("over")) without touching className directly.
To know which color to apply per row without re-parsing the JSON, the color is stashed directly on the row element using dataset — the DOM API for reading/writing HTML data-* custom attributes:
// when building each row in app.js:
tr.dataset.color = event.color; // creates/reads a data-color="..." attribute
// in the mouseover handler:
setThemeColor(event.currentTarget.dataset.color);
event.currentTarget refers to the element the listener is actually attached to (as opposed to event.target, which could be a descendant that triggered the event via bubbling) — a useful alternative to capturing tr in a closure.
One subtlety fixed during the demo: because a click navigates to /details between the mouseover and the mouseout firing, the mouseout handler must check location.pathname === "/" before resetting the header color to black — otherwise it would incorrectly reset the orange details-page theme color back to black right after navigating away from the home page.
Working with Multi-touch Gestures
A two-finger tap anywhere on the screen is wired up to navigate back to the home page — a simple multi-touch gesture.
With touch events, this is simple because the browser already reports the full list of active touches:
document.addEventListener("touchstart", event => {
if (event.touches.length === 2) {
goTo("/");
}
});
With pointer events, it takes more code, because each pointer fires its own pointerdown/pointerup, so a manual counter is needed:
let pointers = 0;
document.addEventListener("pointerdown", () => {
pointers++;
if (pointers === 2) {
goTo("/");
}
});
document.addEventListener("pointerup", () => {
pointers--;
});
Only one of these two approaches should be used in production — most touch-capable devices/browsers support both APIs, so mixing them would double-count gestures. Testing on an iOS Simulator (where a two-finger tap is emulated by holding the Option key while clicking) confirms the gesture correctly returns to the home page.
Module 2 Summary
This module covered advanced dynamic content creation: finishing the shopping cart; changing CSS styles by adding/removing classes and via inline styling from JavaScript; working with form controls (change and submit events, and native form validation); adding keyboard shortcuts and keyboard-driven navigation (e.g., arrow keys to move between event details); and using mouse, touch, and pointer events for hover effects and multi-touch gestures.
Module 3: Adding Drag and Drop Support
Drag and Drop Fundamentals
Many HTML elements — images and links among them — are draggable by default in the browser, with no special setup required. But the drag operation’s metadata (any JSON or other data payload transferred alongside the drag) must be defined explicitly if you want the recipient to be able to use it.
Any element can also be made explicitly draggable (a <div>, a <header>, a <section>, etc.) using the draggable attribute/property. There are dedicated drag events for tracking a drag operation as it moves over DOM elements, and any DOM element can act as a drop zone to receive drops and read the transferred metadata.
Crucially, drag-and-drop operations are not limited to a single page: content can be dragged within the page, or exchanged with external sources — other browser tabs/pages, other native applications, or the operating system’s file system.
flowchart LR
A["Draggable element\n(e.g. an <img>)"] -- "dragstart" --> B["dataTransfer.setData(...)\ndataTransfer.setDragImage(...)"]
B -- "user drags over" --> C["Drop zone\ndragover / dragleave"]
C -- "user releases" --> D["drop event\ndataTransfer.getData(...)"]
D --> E["Application logic\n(e.g. add ticket to cart)"]
A -- "dragend" --> F["Cleanup (restore opacity, etc.)"]
Enabling Drag on Elements
The goal: dragging an event’s thumbnail image and dropping it on the cart icon should add one ticket for that event — a quick shortcut alternative to the “Add to Cart” button.
A new script, dragdrop.js, listens for the eventsload custom event (the same one used by mouse.js) to wire up every row’s image for dragging:
document.addEventListener("eventsload", () => {
document.querySelectorAll("#page-home tr img").forEach(image => {
image.addEventListener("dragstart", event => {
const id = image.dataset.id;
const eventToDrag = events.find(e => e.id === id);
event.dataTransfer.dropEffect = "copy"; // shows a "+" cursor -- we copy, not move
event.dataTransfer.setData("eventdata", JSON.stringify(eventToDrag));
event.target.style.opacity = 0.2; // visual feedback while dragging
});
image.addEventListener("dragend", event => {
event.target.style.opacity = 1; // restore opacity once the drag operation ends
});
});
});
Key details:
event.dataTransferis the standard object carrying all drag metadata.dataTransfer.dropEffect(values such as"move"or"copy") is a hint shown to the user (e.g., a ”+” icon for a copy), not an enforced behavior — here"copy"is used since the original image stays in place.dataTransfer.setDragImage(image, x, y)can replace the default drag preview image; it is not needed here since the browser already drags the actual<img>.dataTransfer.setData(format, data)accepts only strings. Since the payload is an event object, it must be serialized first withJSON.stringify(...). An arbitrary format name (here,"eventdata") is chosen — the receiving end must use the exact same name to retrieve it.- To look up which event object corresponds to the dragged
<img>, its DOMidis stashed viadataset.idwhen the row is created (img.dataset.id = event.id), then read back withimage.dataset.idinsidedragstart, and matched against the in-memoryeventsarray withArray.prototype.find. dragendresets the opacity applied duringdragstart, otherwise the image would remain semi-transparent after the user finishes (or cancels) the drag.
Defining a Drop Zone
The header’s cart icon area is turned into a drop zone. Because the target element might not exist yet when the script runs, setup happens inside DOMContentLoaded:
addEventListener("DOMContentLoaded", () => {
const headerCart = document.querySelector(".header-cart");
headerCart.addEventListener("dragover", event => {
event.preventDefault(); // required: tells the browser we will handle the drop ourselves
headerCart.style.backgroundColor = "green"; // visual feedback: "you can drop here"
});
headerCart.addEventListener("dragleave", () => {
headerCart.style.backgroundColor = ""; // reset when the drag leaves without dropping
});
});
dragoverfires repeatedly while an element is dragged over the drop zone. Callingevent.preventDefault()here is what tells the browser “I am handling this drag/drop myself” — without it, the drop zone will not accept drops at all.dragleaveresets the visual feedback when the user drags out of the zone without dropping.- As an optional refinement,
dragovercan inspectevent.dataTransfer.typesto check whether the expected data format is present before applying the “acceptable drop target” styling — this avoids, for example, highlighting the cart as a valid target when the user is dragging the site logo instead of an event thumbnail.
Receiving and Parsing the Data
The actual drop event completes the interaction:
headerCart.addEventListener("drop", event => {
event.preventDefault(); // prevent the browser from navigating to a dropped image, etc.
headerCart.style.backgroundColor = ""; // we never got a dragleave, so reset manually here too
try {
const eventToAdd = JSON.parse(event.dataTransfer.getData("eventdata"));
const cartAddEvent = new Event("cartadd");
cartAddEvent.item = { event: eventToAdd, quantity: 1 };
document.dispatchEvent(cartAddEvent);
} catch (error) {
// no valid "eventdata" payload was dropped - ignore
}
});
event.dataTransfer.getData("eventdata")must use the exact same format name thatsetDataused duringdragstart.- Wrapping the parse in
try/catchprotects against drops that do not contain the expected payload (e.g., accidentally dragging something else onto the cart). - The retrieved event is dispatched through the same
cartaddcustom event used by the “Add to Cart” button (Module 2), with a fixed quantity of1— reusing the existing cart logic rather than duplicating it. - Because
dragleavenever fires on a successful drop (the pointer is released inside the zone), the background color reset must also happen explicitly inside thedrophandler — otherwise the green highlight would remain stuck.
Different browsers behave differently with drag-and-drop defaults — for example, some browsers will replace the entire page with a dropped image unless event.preventDefault() is called on drop, which is why it is included even though the code path doesn’t use the default browser action.
Exporting Data to the Operating System
The same drop zone element can also act as a drag source for exporting data — for example, dragging the cart icon itself out to the desktop/file system creates a downloadable cart.json file containing the current cart contents.
headerCart.addEventListener("dragstart", event => {
event.dataTransfer.clearData(); // clear any metadata previously set by the browser
const fileContents = JSON.stringify(cart);
event.dataTransfer.setData(
"DownloadURL",
`text/json:cart.json:data:text/json;base64,${btoa(fileContents)}`
);
const cartImage = new Image();
cartImage.src = "img/cart.svg";
event.dataTransfer.setDragImage(cartImage, 10, 10);
event.dataTransfer.dropEffect = "copy";
event.currentTarget.style.opacity = 0.5;
});
headerCart.addEventListener("dragend", event => {
event.currentTarget.style.opacity = 1;
});
The "DownloadURL" format name is a special string that browsers recognize as instructing the OS to materialize a downloadable file when the drag is dropped outside the browser (e.g., onto the desktop or a folder in a file manager). Its value follows the pattern:
<MIME type>:<file name>:data:<MIME type>;base64,<base64-encoded contents>
Here, btoa(fileContents) base64-encodes the JSON string produced by JSON.stringify(cart). setDragImage supplies a custom drag preview image (img/cart.svg) with an offset of (10, 10) pixels from the pointer. dropEffect = "copy" again signals a copy operation. dragend restores the icon’s opacity.
This was demonstrated in a browser with full OS-level drag-and-drop file support (the walkthrough switched to Chrome for this part): dragging event thumbnails onto the cart adds tickets, and then dragging the cart icon out to the desktop produces a cart.json file — opening it in Visual Studio Code shows the array of { event, quantity } items currently in the cart. Exporting other data formats works the same way; only the MIME type and payload need to change.
Module 3 Summary
This module covered setting up drag and drop from scratch: configuring drag support (setting transfer data, changing CSS while dragging), enabling a drop zone with dragover/dragleave/drop and appropriate styling, receiving and parsing dropped data, and exporting application data to the operating system as a downloadable file. This is a general technique for connecting your web content to other apps and websites — dropping files into a web app is also possible, with appropriate security considerations.
Module 4: Animating Your Content
Animation Options in the Browser
To animate content in a modern web app, there are three general options:
| Technique | Defined in | Style |
|---|---|---|
| CSS Transitions | Stylesheet (transition property) | Automatic interpolation between a property’s old and new value |
| CSS Animations | Stylesheet (@keyframes + animation property) | Explicit keyframes defined in CSS |
| Web Animations API | JavaScript | Explicit keyframes defined as a JS object/array, driven and controlled entirely from script |
The Web Animations API is also keyframe-based, like CSS Animations, but the settings and keyframes are described in a JavaScript object rather than in a stylesheet, and it is directly controllable (pause/play/reverse/cancel) from code.
Working with CSS Transitions and Animations
If you animate content with pure CSS, DOM APIs are still useful for reacting to when those animations happen — but note that there is no playback control API for CSS-driven animations from JavaScript; you can only observe them via events.
| Technique | Events available |
|---|---|
| CSS Transitions | transitionrun, transitionstart, transitionend |
| CSS Animations | animationstart, animationend, animationcancel, animationiteration |
flowchart LR
subgraph CSS_Transition["CSS Transition timeline"]
direction LR
T1["Property changes\n-> transitionrun"] --> T2["transition-delay elapses"]
T2 --> T3["Animation visibly begins\n-> transitionstart"]
T3 --> T4["Animation completes\n-> transitionend"]
end
flowchart LR
subgraph CSS_Animation["CSS Animation timeline"]
direction LR
A1["animation property applied\n-> keyframe animation begins"] --> A2{"More iterations?\n(animation-iteration-count > 1)"}
A2 -->|"yes"| A3["-> animationiteration\nrestart keyframes"]
A3 --> A2
A2 -->|"no / last iteration"| A4["-> animationend"]
A1 -.->|"changed dynamically mid-flight"| A5["-> animationcancel"]
end
For CSS Transitions, transitionrun fires as soon as the animated property’s value changes (even before any visible movement, if a transition-delay is set); transitionstart fires once the visible animation actually begins; transitionend fires when it completes.
For CSS Animations, there is no delay concept in the same way: applying the animation property starts the keyframe animation immediately. If animation-iteration-count is greater than one, animationiteration fires each time an iteration completes and another one begins; animationend fires only after the last iteration finishes. animationcancel can fire unexpectedly, typically when the animation property is changed dynamically from JavaScript or CSS while it is still running.
Listening to any of these is the same pattern used throughout the course:
element.addEventListener("transitionend", () => { /* ... */ });
Applying a CSS transition to the header
/* main.css */
header {
transition: background-color 1500ms; /* 1.5 seconds */
}
With this rule, hovering table rows (Module 2) now animates the header’s background color smoothly instead of changing instantly. However, the <meta name="theme-color"> tag (used to tint the mobile browser toolbar) still updates immediately, because meta tags are not visible/animatable elements — the CSS transition has no effect on it.
To keep them in sync, setThemeColor is updated to change the meta tag only after the CSS transition completes:
function setThemeColor(color) {
const header = document.querySelector("header"); // cache the query - avoid duplicate DOM searches
header.style.backgroundColor = color;
header.addEventListener("transitionend", () => {
document.querySelector("meta[name=theme-color]").content = color;
});
}
Caching the header reference in a local constant (instead of calling document.querySelector("header") twice within the same function) is a small performance improvement — each querySelector call re-scans the document.
Using the Web Animations API
The Web Animations API provides fine-grained, high-performance, browser-managed control over animations directly from JavaScript, effectively able to fully replace CSS transitions and CSS animations.
Core concepts:
- An array of keyframes (minimum of two: an initial and a final state). Each keyframe is a plain object mapping CSS properties (in camelCase, since these are JavaScript, not CSS, property names) to values.
- A playback API:
pause(),play(),finish(),reverse(), and aplaybackRateyou can change at any time. - Two native events:
finishandcancel, plus a Promise-basedfinishedproperty (usable with.then()orasync/await).
const fadeInAnimation = [
{ opacity: 0 }, // initial keyframe
{ opacity: 1 } // final keyframe
];
const animation = element.animate(fadeInAnimation, {
duration: 1000 // milliseconds - always ms in this API, never seconds
});
element.animate(keyframes, options) takes the keyframe array as its first argument and a settings object as its second — duration (in milliseconds) is effectively mandatory in practice. When more than two keyframes are supplied, they are spaced evenly by default unless an explicit offset (a value between 0 and 1) is set on individual keyframes, overriding the automatic distribution.
Fading in page transitions
The router (renderRoute) currently sets hidden = false directly on each page section. This is refactored to animate a fade-in on whichever page becomes visible:
function renderRoute() {
document.querySelectorAll(".page").forEach(page => page.hidden = true);
const path = document.location.pathname;
let nextPageId;
switch (path) {
case "/": nextPageId = "page-home"; break;
case "/details": nextPageId = "page-details"; break;
case "/cart": nextPageId = "page-cart"; break;
}
const nextPage = document.getElementById(nextPageId);
nextPage.hidden = false;
nextPage.animate([
{ opacity: 0 },
{ opacity: 1 }
], {
duration: 500 // 0.5s -- increase temporarily (e.g. to 1500) while tuning, to see it clearly
});
document.dispatchEvent(new Event("routerender"));
}
Calling .animate(...) starts the animation immediately. Keyframes can set more than one CSS property simultaneously (e.g., combining an opacity fade with a background color change), simply by adding more keys to each keyframe object.
Using the Animation Playback APIs
A problem surfaces when the user navigates between pages faster than the fade-in animation’s duration: a second animation starts before the first has finished, potentially causing visual glitches or wasted work. The fix is to retain a reference to the Animation object returned by .animate(...) and use the playback API to cancel any still-running animation before starting a new one:
let routerAnimation = null;
function renderRoute() {
// ... hide all pages, determine nextPage as before ...
if (routerAnimation && routerAnimation.playState !== "finished") {
routerAnimation.cancel(); // stop the still-playing previous animation
}
routerAnimation = nextPage.animate([
{ opacity: 0 },
{ opacity: 1 }
], { duration: 500 });
document.dispatchEvent(new Event("routerender"));
}
animation.playState reports one of "running", "paused", or "finished" (among other values), letting you query the current state at any time. Combined with play(), pause(), and cancel(), this makes a Web Animations API animation behave much like a media player’s playback controls (see Module 5). In short: to control or query an animation later, you must retain the Animation object returned when you call .animate().
Orchestrating Animations Through Events
A more elaborate animation sequence highlights the “Add to Cart” interaction: the event’s image visually flies toward the cart icon and shrinks, and once that finishes, the cart icon flashes to draw attention.
button.onclick = () => {
const quantity = parseInt(document.getElementById("quantity-dropdown").value);
const item = { event, quantity };
const cartAddEvent = new Event("cartadd");
cartAddEvent.item = item;
document.dispatchEvent(cartAddEvent);
// Animate the event image flying toward the cart
const image = document.querySelector(".event-image");
const addAnimation = image.animate([
{ opacity: 1, transform: "scale(1)" },
{ opacity: 1, transform: "scale(0.2)", offset: 0.5 },
{ opacity: 0, transform: `translate(${window.innerWidth - 200}px, -220px) scale(0.2)` }
], { duration: 600 });
// Once the "flying image" animation completes, flash the cart icon
addAnimation.addEventListener("finish", () => {
document.querySelector(".header-cart").animate([
{ backgroundColor: "yellow" },
{ backgroundColor: "" }
], {
duration: 100,
iterations: 2 // flash twice
});
});
};
- CSS properties are again written in camelCase (
backgroundColor, notbackground-color) since these are JavaScript objects, not CSS rules. transform: translate(Xpx, Ypx) scale(s)combines a translation and a scale in a single keyframe; the horizontal distance is computed dynamically fromwindow.innerWidthso it works regardless of viewport width, rather than a hard-coded pixel value.- The
offsetproperty (0.5in the middle keyframe here) overrides the automatic even-spacing of keyframes, letting the scale-down happen faster or slower relative to the fade/translate. - Chaining animations in sequence is done by listening for the
finishevent of the first animation and only then starting the second one (as shown above). Running animations in parallel simply means calling.animate()on both elements without waiting for one to finish.
sequenceDiagram
participant User
participant Button as Add to Cart button
participant Image as event image (Web Animations API)
participant Cart as header cart icon (Web Animations API)
User->>Button: click
Button->>Image: image.animate([...], {duration: 600})
Note over Image: fly + shrink + fade out
Image-->>Button: "finish" event
Button->>Cart: cartIcon.animate([...], {duration: 100, iterations: 2})
Note over Cart: yellow flash x2
The additional settings object accepted by .animate() supports many more options beyond duration and iterations — e.g. easing, delay, direction, and fill — mirroring most of what is available in CSS Animations, but fully scriptable.
Module 4 Summary
This module covered animating content from JavaScript: binding DOM events to CSS Transitions and CSS Animations when animating purely from a stylesheet, and using the modern Web Animations API to animate any DOM element by calling .animate() directly. It also covered orchestrating animations in sequence via the finish event (or in parallel by simply starting them together), and customizing animations with keyframes, offset, duration, and iterations.
Module 5: Using Audio and Video in HTML
Working with Media Players in HTML
Both <audio> and <video> elements produce an object that inherits from the shared HTMLMediaElement interface — learning the API for one effectively teaches you the other.
Playback methods and properties
| Member | Type | Purpose |
|---|---|---|
load() | Method | (Re)loads the current source |
play() / pause() | Method | Start/pause playback |
fastSeek(time) | Method | Seek quickly within the media |
canPlayType(mimeType) | Method | Checks whether a given format/codec is likely playable on this device |
volume | Property | Playback volume |
playbackRate | Property | Playback speed (1 = normal, 2 = double speed) |
currentTime | Property | Current playback position |
duration | Property | Total media duration |
seekable | Property | Whether the media supports seeking to arbitrary positions (not guaranteed for every codec) |
paused | Property | Boolean — whether playback is currently paused |
Note: there is no stop() concept in this API — media is either playing or paused; to fully reset playback position you set currentTime = 0 (or reload the source).
Key lifecycle/playback events
flowchart LR
A["loadstart\n(browser begins fetching media)"] --> B["loadedmetadata\n(duration, dimensions known)"]
B --> C["canplay\n(playback can start, but\nbuffering may pause it soon)"]
C --> D["canplaythrough\n(browser estimates it can\nplay through without buffering)"]
D --> E["play\n(playback in progress)"]
E --> F["waiting\n(buffering -- update UI accordingly)"]
F --> E
E --> G["pause\n(user or script paused it)"]
E --> H["ended\n(reached end of media)"]
| Event | Meaning |
|---|---|
loadstart | The browser has begun loading the media file |
loadedmetadata | Metadata (duration, video dimensions, etc.) is now available, but playback may not be ready yet |
canplay | Playback can start, but the browser estimates buffering may still be needed soon after |
canplaythrough | The browser estimates the media can likely play through to the end without buffering interruptions |
play | Playback has started (or resumed) |
pause | Playback was paused (by the user or by script) |
waiting | Playback has stalled because the media is buffering |
ended | Playback reached the end of the media |
These are only some of the events exposed by HTMLMediaElement; more exist for error handling and finer-grained state changes.
Playing Audio Files
In data.json, some events carry an audio property (pointing to a media file in the media/ folder) and/or a video property, plus a license property crediting the source. When rendering an event’s details, the appropriate player is created if that property is present.
A new script, media.js, listens for a custom eventrender event (dispatched from app.js whenever renderEventDetails runs) carrying the event’s data:
// in app.js, inside renderEventDetails(event):
const renderEvent = new Event("eventrender");
renderEvent.data = event;
document.dispatchEvent(renderEvent);
// media.js
let mediaPlayer = null;
document.addEventListener("eventrender", domEvent => {
const event = domEvent.data; // the "show" being rendered - not to be confused with the DOM event itself
const mediaContainer = document.querySelector(".event-media");
mediaContainer.innerHTML = ""; // clear previous controls first - avoids duplicated players
if (event.audio) {
const button = document.createElement("button");
button.textContent = "Sample Audio \u23EF"; // play/pause emoji, UTF-8 is valid in HTML
mediaPlayer = new Audio(`media/${event.audio}`);
button.addEventListener("click", () => {
if (mediaPlayer.paused) {
mediaPlayer.play();
} else {
mediaPlayer.pause();
}
});
mediaContainer.appendChild(button);
}
});
Important details:
- Naming collision avoided deliberately: the DOM event received by the listener is called
domEvent, while the application “show” object (the ticketed event, e.g. a concert) it carries isdomEvent.data— both are conceptually “an event,” which is why the distinction matters. new Audio(url)constructs anHTMLAudioElementin memory without ever inserting it into the DOM. This works because<audio>has no default visual representation — there is nothing to see, only something to hear — so there is no need to attach it to the page the way an<img>would need to be.- The container is cleared (
mediaContainer.innerHTML = "") at the top of every render, since re-entering a details page (or visiting one without media) must not leave stale players/buttons behind — the same “clear before re-render” pattern seen earlier with the cart table and event rows.
Creating a Visible Audio Player
A bare play/pause button doesn’t show progress or allow seeking. A visible <input type="range"> slider is added, built with the DOM API just like everything else in the course:
const slider = document.createElement("input");
slider.type = "range";
slider.max = 100;
slider.value = 0;
mediaContainer.appendChild(slider); // insert after the play/pause button
Two-way binding between the slider and the audio element is needed: the slider should move as the audio plays, and dragging the slider should seek the audio.
// media progress -> slider position
mediaPlayer.addEventListener("timeupdate", () => {
slider.value = parseInt(mediaPlayer.currentTime / mediaPlayer.duration * 100);
});
// slider position -> media seek
slider.addEventListener("change", () => {
mediaPlayer.currentTime = mediaPlayer.duration * slider.value / 100;
});
timeupdate fires periodically while the media plays (the exact frequency is browser-dependent) and is used to compute a 0–100 percentage from currentTime / duration, applied to the slider’s value. Conversely, the slider’s change event (fired when the user releases the thumb after dragging) recalculates currentTime from the slider’s percentage, seeking the audio to that position.
Playing Video Files
Working with video is comparatively simple because video playback UI is mostly native HTML: a <video controls> element already renders a full set of playback controls without any custom JavaScript UI.
if (event.video) {
mediaPlayer = document.createElement("video");
mediaPlayer.src = `media/${event.video}`;
mediaPlayer.controls = true; // let the browser render native playback controls
mediaContainer.appendChild(mediaPlayer);
}
If controls is left false (the default), you would need to build your own playback UI with HTML, CSS, and DOM events, exactly as was done for the custom audio player above. HTMLMediaElement events remain available regardless — for example, listening for waiting lets you show a custom “buffering” indicator:
mediaPlayer.addEventListener("waiting", () => {
// update UI to reflect that the video is buffering
});
Managing Media Playback During Navigation
A remaining bug: if the user starts playing audio or video and then navigates to a different page, playback keeps going in the background — and navigating back creates a brand-new player rather than resuming/stopping the original one.
The fix reuses the routerender custom event (dispatched on every route change, introduced in Module 2) to pause and release whatever media player is currently active:
document.addEventListener("routerender", () => {
if (mediaPlayer && !mediaPlayer.paused) {
mediaPlayer.pause();
mediaPlayer.src = undefined; // release the current source
mediaPlayer.load(); // unload it from memory, freeing resources for the rest of the app
}
});
After pause(), setting src to undefined and calling load() tells the browser to unload the current media and attempt to load “nothing,” freeing memory rather than leaving a stale player retained during the rest of the navigation session. With this in place, playing audio and then switching routes correctly stops playback automatically.
Module 5 Summary
This final module covered working with media and JavaScript — audio or video. It covered how the shared playback API works for both HTMLVideoElement and HTMLAudioElement, how to build a visible custom audio player (play/pause button plus a seek slider) driven entirely by the DOM API and media events, and how the native <video controls> element lets you get a fully-featured player with minimal code while still exposing the same events for custom UI needs. It also covered cleaning up media playback when navigating away from a page, to avoid audio/video continuing to play in the background.
Summary
Across five modules, this course built a complete single-page application (GloboTicket) entirely with native browser APIs — no framework was used — progressively layering in:
- The DOM API — selecting elements (
getElementById,querySelector[All],getElementsBy*), creating and injecting content (createElement,appendChild,innerHTML,textContent), reading/writing attributes and inline styles, and building SPA-style routing on top of the History API (pushState,popstate). - Event-driven interactivity — the
addEventListenermodel (preferred overoneventproperties for supporting multiple handlers), native form events (change,submit) and validation (checkValidity/reportValidity), keyboard events (keydown/keyup, modifier keys,event.codevsevent.key), and the mouse/touch/pointer event families for hover effects and multi-touch gestures. - Custom events — using
new Event(name)plusdispatchEvent/addEventListeneras an internal publish/subscribe system to decouple modules (cartadd,cartupdate,routerender,eventsload,eventrender), a pattern that recurs in nearly every module of the course. - Drag and drop — making elements draggable, defining drop zones, transferring structured data through
dataTransfer, and even exporting application data as downloadable files to the operating system. - Animation — reacting to CSS Transition/Animation lifecycle events, and using the imperative, high-performance Web Animations API (
element.animate(keyframes, options)) with full playback control (pause,play,cancel,playState) to orchestrate sequenced or parallel animations. - Media playback — the shared
HTMLMediaElementAPI and event lifecycle for both<audio>and<video>, building custom players, and correctly releasing media resources during navigation.
Quick-reference: DOM & Event APIs Used in This Course
| Area | Key APIs |
|---|---|
| Selecting elements | getElementById, querySelector, querySelectorAll, getElementsByTagName, getElementsByClassName, getElementsByName |
| Creating/updating content | createElement, appendChild, innerHTML, textContent, dataset |
| Styling | element.style.* (camelCase), classList.add/remove |
| Navigation (SPA) | history.pushState, popstate, location.pathname |
| Event binding | addEventListener/removeEventListener, onevent properties, { once: true } |
| Custom events | new Event(name), custom properties on the event object, dispatchEvent |
| Forms | change, submit, checkValidity, reportValidity, form.elements |
| Keyboard | keydown, keyup, event.key, event.code, modifier flags |
| Mouse/Touch/Pointer | mouseover/out, touchstart/event.touches, pointerdown/up/pointerType |
| Drag and drop | draggable, dragstart/over/leave/drop/end, dataTransfer.setData/getData, DownloadURL |
| Animation | CSS transition/@keyframes events, element.animate(), Animation.playState, pause/play/cancel |
| Media | HTMLMediaElement (play, pause, currentTime, duration, paused), loadedmetadata, canplay, timeupdate, ended |
Checklist for Building Interactive HTML Experiences
- Wait for
DOMContentLoadedbefore querying or manipulating the DOM. - Prefer
addEventListeneroveroneventproperties so multiple handlers can coexist. - Always guard against
null/empty results from element-selection functions. - Clear a container before re-rendering into it whenever a render function can run more than once (tables, cart, media player controls) — a recurring source of bugs throughout this course.
- Use custom events (
new Event()+dispatchEvent) to decouple independent scripts/modules instead of calling functions across files directly. - Remember CSS property names become camelCase in JavaScript, and numeric style values need explicit units as strings (e.g.,
"5px"). - Choose the right pointer API deliberately: mouse events for simple single-pointer interaction, touch events for native multitouch on touchscreens, or pointer events for a single unified API across device types — but don’t mix touch and pointer events for the same gesture.
- Always call
event.preventDefault()indragover(to allow dropping) and typically indrop(to avoid default browser behavior like navigating to a dropped file). - Retain the object returned by
element.animate()if you need to query or control (pause,cancel,playState) that animation later. - Pause and release media players (
pause(), clearsrc,load()) when navigating away, to avoid background playback and memory leaks in a single-page application.
Search Terms
html · apis · interactive · experiences · css · web · fundamentals · frontend · development · dom · events · event · playback · animations · audio · content · drag · drop · elements · animation · between · binding · data · details