Intermediate

HTML APIs: Creating Interactive Experiences

html · apis · interactive · experiences · css · web · fundamentals · frontend · development · dom · events · event · playback · animations · audio · content · drag · drop · elements · ani...

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

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 window global object, which represents the current page navigation shown in the browser.
  • The document object, 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.json file 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).

FunctionReturnsNotes
document.getElementById(id)One element or nullFastest single-element lookup
document.querySelector(selector)First matching element or nullAccepts any CSS selector
document.getElementsByTagName(tag)Live collectionE.g., all <button> or <p> elements
document.getElementsByClassName(class)Live collection
document.querySelectorAll(selector)Static NodeListAccepts any CSS selector, can be iterated with forEach
document.getElementsByName(name)Live collectionUseful 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

TechniquePurpose
element.textContentRead/write plain text only (no HTML parsing)
element.innerHTMLRead/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:

  1. Wait for DOMContentLoaded.
  2. fetch("data.json"), await the response, and parse it with response.json().
  3. Store the parsed array in a global events variable.
  4. Loop through events and, for each one, build a table row and append it to the tbody.
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"]

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:

  1. Remove/inject: remove the previous page’s element from the DOM and inject the new page’s element.
  2. Hide/show: keep all pages in the DOM permanently, and toggle which one is visible (via the hidden property, 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:

  1. Enhances existing links (those with the class spa-link) so clicks are intercepted.
  2. Pushes new history entries using the History API whenever the URL changes.
  3. Listens for popstate (browser Back/Forward buttons) to re-render the correct page.
  4. 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 addEventListener supports multiple listeners per event/object combination, router.js can register its own DOMContentLoaded handler independently from app.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 a switch on document.location.pathname.
  • The popstate event fires whenever the user uses the browser’s Back/Forward navigation buttons, so renderRoute() 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, and querySelectorAll.
  • Creating new elements with document.createElement and injecting them with appendChild.
  • Changing text and HTML content, and managing attribute values.
  • Basic event handling, including listening for DOMContentLoaded on the window object.
  • 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:

CategoryExamples
Generalload, click, dblclick
Form control value changeschange
Keyboardkeyup, keydown, keypress
Mousemouseover, mouseout, and more
Pointer/touchCovered later in this module
Special/globalDOMContentLoaded, popstate (on window)

To bind functions to events, there are two options:

TechniqueSyntaxLimitation
onevent propertyelement.onclick = functionOnly one handler per event/object combination — assigning a second one replaces the first
addEventListenerelement.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 the onevent property style (deliberately, to show the technique) — note that assigning onclick again later would replace this handler.
  • parseInt(...) converts the string value of the <select id="quantity-dropdown"> element into a number.
  • A custom event named cartadd is created with new Event("cartadd"). Because JavaScript event objects are just objects, an arbitrary item property 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-button div) 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:

  1. Duplicated “Add to Cart” buttons. Because renderEventDetails creates 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 with container.innerHTML = "" or by iterating and removing each children node.
  2. Duplicated cart rows. The cart table was accumulating rows on every cartupdate because the table body was never cleared before appending new rows. Fix: set tbody.innerHTML = "" before the forEach that 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.elements is a “form controls” NodeList (inputs, <select>, <textarea> — but not plain <div>s or <p>s inside the form). Looping over it with for...of and resetting value = "" 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-colorborderTopColor). 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.

EventFires whenAvailable on
keydownKey is pressed down (not yet released); repeats while heldAll keys, including modifiers (Ctrl, Alt, Meta/Cmd)
keyupKey is releasedAll keys, including modifiers
keypressSimilar 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 familyDevicesMultitouch?Extra metadata
Mouse eventsMouse, trackpad, touch, pen/stylus (legacy, since the early web)No — single pointer onlyCoordinates (clientX/clientY), keyboard modifiers
Touch eventsTouch screens onlyYes — native, always an array of touch pointsForce/pressure on some devices
Pointer eventsMouse, trackpad, touch, pen/stylus (modern, unified)Yes — one event execution per pointerPointer 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 &lt;img&gt;)"] -- "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.dataTransfer is 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 with JSON.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 DOM id is stashed via dataset.id when the row is created (img.dataset.id = event.id), then read back with image.dataset.id inside dragstart, and matched against the in-memory events array with Array.prototype.find.
  • dragend resets the opacity applied during dragstart, 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
    });
});
  • dragover fires repeatedly while an element is dragged over the drop zone. Calling event.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.
  • dragleave resets the visual feedback when the user drags out of the zone without dropping.
  • As an optional refinement, dragover can inspect event.dataTransfer.types to 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 that setData used during dragstart.
  • Wrapping the parse in try/catch protects 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 cartadd custom event used by the “Add to Cart” button (Module 2), with a fixed quantity of 1 — reusing the existing cart logic rather than duplicating it.
  • Because dragleave never fires on a successful drop (the pointer is released inside the zone), the background color reset must also happen explicitly inside the drop handler — 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:

TechniqueDefined inStyle
CSS TransitionsStylesheet (transition property)Automatic interpolation between a property’s old and new value
CSS AnimationsStylesheet (@keyframes + animation property)Explicit keyframes defined in CSS
Web Animations APIJavaScriptExplicit 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.

TechniqueEvents available
CSS Transitionstransitionrun, transitionstart, transitionend
CSS Animationsanimationstart, 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 a playbackRate you can change at any time.
  • Two native events: finish and cancel, plus a Promise-based finished property (usable with .then() or async/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, not background-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 from window.innerWidth so it works regardless of viewport width, rather than a hard-coded pixel value.
  • The offset property (0.5 in 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 finish event 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

MemberTypePurpose
load()Method(Re)loads the current source
play() / pause()MethodStart/pause playback
fastSeek(time)MethodSeek quickly within the media
canPlayType(mimeType)MethodChecks whether a given format/codec is likely playable on this device
volumePropertyPlayback volume
playbackRatePropertyPlayback speed (1 = normal, 2 = double speed)
currentTimePropertyCurrent playback position
durationPropertyTotal media duration
seekablePropertyWhether the media supports seeking to arbitrary positions (not guaranteed for every codec)
pausedPropertyBoolean — 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)"]
EventMeaning
loadstartThe browser has begun loading the media file
loadedmetadataMetadata (duration, video dimensions, etc.) is now available, but playback may not be ready yet
canplayPlayback can start, but the browser estimates buffering may still be needed soon after
canplaythroughThe browser estimates the media can likely play through to the end without buffering interruptions
playPlayback has started (or resumed)
pausePlayback was paused (by the user or by script)
waitingPlayback has stalled because the media is buffering
endedPlayback 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 is domEvent.data — both are conceptually “an event,” which is why the distinction matters.
  • new Audio(url) constructs an HTMLAudioElement in 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:

  1. 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).
  2. Event-driven interactivity — the addEventListener model (preferred over onevent properties for supporting multiple handlers), native form events (change, submit) and validation (checkValidity/reportValidity), keyboard events (keydown/keyup, modifier keys, event.code vs event.key), and the mouse/touch/pointer event families for hover effects and multi-touch gestures.
  3. Custom events — using new Event(name) plus dispatchEvent/addEventListener as an internal publish/subscribe system to decouple modules (cartadd, cartupdate, routerender, eventsload, eventrender), a pattern that recurs in nearly every module of the course.
  4. 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.
  5. 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.
  6. Media playback — the shared HTMLMediaElement API 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

AreaKey APIs
Selecting elementsgetElementById, querySelector, querySelectorAll, getElementsByTagName, getElementsByClassName, getElementsByName
Creating/updating contentcreateElement, appendChild, innerHTML, textContent, dataset
Stylingelement.style.* (camelCase), classList.add/remove
Navigation (SPA)history.pushState, popstate, location.pathname
Event bindingaddEventListener/removeEventListener, onevent properties, { once: true }
Custom eventsnew Event(name), custom properties on the event object, dispatchEvent
Formschange, submit, checkValidity, reportValidity, form.elements
Keyboardkeydown, keyup, event.key, event.code, modifier flags
Mouse/Touch/Pointermouseover/out, touchstart/event.touches, pointerdown/up/pointerType
Drag and dropdraggable, dragstart/over/leave/drop/end, dataTransfer.setData/getData, DownloadURL
AnimationCSS transition/@keyframes events, element.animate(), Animation.playState, pause/play/cancel
MediaHTMLMediaElement (play, pause, currentTime, duration, paused), loadedmetadata, canplay, timeupdate, ended

Checklist for Building Interactive HTML Experiences

  • Wait for DOMContentLoaded before querying or manipulating the DOM.
  • Prefer addEventListener over onevent properties 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() in dragover (to allow dropping) and typically in drop (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(), clear src, 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

Interested in this course?

Contact us to book it or get a custom training plan for your team.