Skip to main content
UX Design Engineering
Theme
Text size
100%
Typeface

Chapter 8

Browser APIs

The browser APIs that handle common interface problems (observers, matchMedia, storage, the URL and history, dialog, popover, and Web Components), ending in an interactive documentation page.

A long documentation page needs a table of contents that shows where you are. The usual first attempt listens for scroll, measures every section on every scroll event, and works out which one is on screen. It runs dozens of times a second, it does layout work each time, and it still gets the edges wrong. The page also forgets the reader’s theme when they reload, loses their filter when they share a link, and builds its modal out of div elements that a keyboard user can tab straight out of.

The browser already has tools for each of these problems. This chapter covers the ones interface builders use most: observers that report visibility and size, matchMedia for user preferences, storage and the URL for remembering state, native <dialog> and popover for overlays, and Web Components for packaging behavior. Each one plugs into the DOM, events, state, and rendering code you already know.

The mental model

Most of these APIs change how you answer question 2, what events can change state? Instead of asking the browser over and over (“is the section visible yet? how about now?”), you describe what you care about once and the browser calls you when it changes. An observer is a subscription. You create it with a callback, tell it which elements to watch, and it runs the callback with a list of entries when something relevant happens. Your callback updates state and renders, exactly as an event listener would.

The storage and URL APIs extend question 1, what does the interface need to remember?, with a second question: for how long, and who should see it?

Where state livesHow long it lastsGood for
A JavaScript variableUntil the page reloadsWhether a menu is open, the current request
sessionStorageUntil the tab closesA half-written draft, a dismissed banner for this visit
localStorageUntil the user clears itTheme and other preferences
The URLAs long as the link existsFilters, selected tab, search terms someone might share or bookmark

Choosing the right place is a design decision. A filter in localStorage surprises someone who opens a colleague’s link and sees their own old filter instead.

IntersectionObserver

Use IntersectionObserver when you need to know whether an element is in the viewport. It covers the sticky table of contents, lazy-loaded card reveals, and “load more when the user reaches the end” lists.

const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (entry.isIntersecting) {
      console.log(`${entry.target.id} is on screen`);
    }
  }
}, { rootMargin: '0px 0px -70% 0px' });

document.querySelectorAll('[data-doc-section]').forEach((section) => observer.observe(section));

Each entry describes one element: entry.target is the element, and entry.isIntersecting says whether it overlaps the viewport. The rootMargin option adjusts the area being watched, using the same order as CSS margins. A bottom margin of -70% shrinks it to the top 30% of the viewport, so a section counts as current when it reaches the upper part of the screen, which is where people are reading. Without it, a section would count as current the moment its first pixel appeared at the bottom.

The browser does the geometry itself and only calls you when something crosses the edge, so none of your code runs on every scroll and the page stays smooth. When you no longer need to watch an element, such as a card that has already been revealed, call observer.unobserve(element).

ResizeObserver

Use ResizeObserver when you need to know an element’s size, as opposed to the window’s size. A component in a sidebar and the same component in the main column live in the same window but have very different widths.

For styling, reach for CSS container queries first, because they need no JavaScript. Use ResizeObserver when the behavior has to change, such as moving toolbar buttons into an overflow menu when they no longer fit, or when you need the number itself:

const frame = document.querySelector('[data-demo-frame]');
const sizeLabel = document.querySelector('[data-demo-size]');

const resizeObserver = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const width = Math.round(entry.contentRect.width);
    sizeLabel.textContent = `${width}px wide`;
    entry.target.dataset.size = width < 420 ? 'compact' : 'wide';
  }
});

resizeObserver.observe(frame);

Be careful not to change the observed element’s own size inside the callback. If the change triggers another resize, the browser stops and logs an error that says something like “ResizeObserver loop completed with undelivered notifications.” Here the label sits outside the frame, and data-size only changes the layout of the frame’s contents.

MutationObserver belongs to the same family. It reports when elements or attributes in a part of the DOM change. You rarely need it for your own code, because you already know when you render. It’s for reacting to markup you don’t control, such as content injected by a third-party widget.

matchMedia and user preferences

matchMedia runs a CSS media query from JavaScript. Most preference work belongs in CSS, as with @media (prefers-reduced-motion: reduce) around your transitions. JavaScript needs the answer when it does the animating itself:

const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');

function scrollToSection(section) {
  section.scrollIntoView({ behavior: reducedMotion.matches ? 'auto' : 'smooth' });
}

reducedMotion.addEventListener('change', () => {
  console.log('Reduced motion is now', reducedMotion.matches);
});

matches gives the current answer, and the change event fires if the user changes the setting while the page is open. The same works for (prefers-color-scheme: dark) and for width queries like (min-width: 48rem) when a script has to behave differently at a breakpoint.

localStorage and sessionStorage

Both storage objects hold strings under string keys, and they have the same methods: getItem, setItem, and removeItem. Anything that isn’t a string has to be converted, and both can throw. Some browsers block storage in private modes or under strict privacy settings, and a full quota makes setItem fail. Wrap every access in one small module so the rest of your code never has to think about it:

// storage.js
export function readSetting(key, fallback) {
  try {
    const saved = localStorage.getItem(key);
    return saved === null ? fallback : JSON.parse(saved);
  } catch {
    return fallback;
  }
}

export function writeSetting(key, value) {
  try {
    localStorage.setItem(key, JSON.stringify(value));
  } catch {
    // Storage is blocked or full. The setting lasts until the page reloads.
  }
}

JSON.stringify and JSON.parse turn booleans, numbers, arrays, and objects into strings and back. The catch also covers a stored value that isn’t valid JSON. If storage fails, the interface still works and simply forgets the preference.

Treat what you read back as untrusted, because someone may have edited it or an old version may have saved a value you no longer support. Check it against the values you expect.

URL state with URLSearchParams and history

Filters belong in the URL when someone might want to share or bookmark the filtered view. URLSearchParams reads and writes the query string, and the History API changes the address bar without reloading the page:

const allowedLevels = ['all', 'beginner', 'advanced'];

function readLevelFromUrl() {
  const level = new URLSearchParams(location.search).get('level');
  return allowedLevels.includes(level) ? level : 'all';
}

function writeLevelToUrl(level) {
  const url = new URL(location.href);
  if (level === 'all') {
    url.searchParams.delete('level');
  } else {
    url.searchParams.set('level', level);
  }
  history.replaceState(null, '', url);
}

Read from the URL once when the page loads to set the initial state, and write to it whenever the filter changes. readLevelFromUrl checks the value against the allowed list for the same reason storage values need checking: anyone can type anything into a URL.

history.replaceState updates the current history entry, and history.pushState adds a new one. Use replaceState for filters and search terms, so the Back button leaves the page as people expect instead of stepping through every filter they tried. Use pushState when a change feels like going somewhere new, such as opening a different article within the same page. If you push entries, listen for popstate, which fires when the user moves back or forward, and render from the URL again:

window.addEventListener('popstate', () => {
  state.level = readLevelFromUrl();
  render();
});

popstate doesn’t fire when you call pushState yourself, only when the user navigates through history.

Native dialog

A modal dialog has a lot of required behavior: focus moves into it, Tab can’t reach the page behind it, Escape closes it, focus goes back to the button that opened it, and screen readers treat the rest of the page as unavailable. <dialog> with showModal() does all of that for you.

<button type="button" data-open-support>Browser support details</button>

<dialog aria-labelledby="support-title" data-support-dialog>
  <h2 id="support-title">Browser support</h2>
  <p>This component works in the current versions of all major browsers.</p>
  <form method="dialog">
    <button type="submit">Close</button>
  </form>
</dialog>
const openButton = document.querySelector('[data-open-support]');
const dialog = document.querySelector('[data-support-dialog]');

openButton.addEventListener('click', () => dialog.showModal());

showModal() places the dialog in the top layer, above everything else regardless of z-index, and makes the rest of the page inert, so it can’t be clicked, focused, or read by a screen reader. Focus moves into the dialog: to the element with autofocus if there is one, otherwise to the first focusable element inside it, or to the dialog itself if it contains none. Escape fires a cancel event and closes it. A form with method="dialog" closes the dialog when submitted, so the Close button needs no JavaScript. When it closes, current browsers return focus to the element that had it before, and you can listen for the close event if you want to restore focus explicitly or read dialog.returnValue.

You don’t need to write a focus trap or add aria-modal="true", because a modal <dialog> already behaves and is exposed that way. Style the dimmed page behind it with the ::backdrop pseudo-element. Clicking the backdrop doesn’t close the dialog by default. A newer closedby attribute can turn that on, but check support before relying on it.

The popover attribute

For non-modal overlays such as a help panel, a keyboard shortcut list, or a “more info” bubble, the popover attribute gives you a lot without any JavaScript:

<button type="button" popovertarget="shortcut-help">Keyboard shortcuts</button>

<div id="shortcut-help" popover>
  <h2>Keyboard shortcuts</h2>
  <p>Press <kbd>/</kbd> to focus the search field.</p>
</div>

The button toggles the popover. The popover appears in the top layer, and it closes when the user presses Escape or clicks outside it, which is called light dismiss. Unlike a modal dialog, the rest of the page stays usable. Use popover="manual" if you don’t want light dismiss. The popover API is newer than <dialog>, and it has worked in all major browsers since January 2025, though older browser versions still in use won’t have it.

A popover is a layer, and it brings no meaning with it. Putting a list of links inside one doesn’t make it a menu. role="menu" tells screen reader users to expect arrow-key navigation, and if you add that role you have to build that behavior yourself, which chapter 9 does. For site navigation, a disclosure (a button with aria-expanded that shows and hides a list of links) is usually the better pattern. By default the browser centers a popover on screen. Positioning it next to its button takes CSS anchor positioning, which is newer still, or a little script.

Web Components

Web Components let you define your own HTML elements with behavior built in. Chapter 11 shows how to wrap a component module in one, and this section covers enough to read one and write a simple one.

Custom elements

A custom element is a class that extends HTMLElement, registered under a name that contains a hyphen. Custom elements are the main place interface code needs class syntax, and you can treat it as a fill-in template:

// copy-code.js
class CopyCode extends HTMLElement {
  connectedCallback() {
    if (this.querySelector('[data-copy-button]')) return;

    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = 'Copy code';
    button.dataset.copyButton = '';

    const status = document.createElement('span');
    status.setAttribute('role', 'status');

    button.addEventListener('click', async () => {
      const code = this.querySelector('code');
      try {
        await navigator.clipboard.writeText(code.textContent);
        status.textContent = 'Copied';
      } catch {
        status.textContent = 'Copy failed. Select the code and copy it manually.';
      }
      setTimeout(() => { status.textContent = ''; }, 3000);
    });

    this.append(button, status);
  }
}

customElements.define('copy-code', CopyCode);
<copy-code>
  <pre><code>widget.open();</code></pre>
</copy-code>

The browser calls connectedCallback when the element is added to the page, including when the element is already in the HTML at the moment define runs. The guard on the first line stops it adding a second button if the element is moved. If the script fails, <copy-code> behaves like an unknown inline element and the code is still readable, so the enhancement fails safely. navigator.clipboard.writeText returns a promise and can reject, for example when the page isn’t served over HTTPS or localhost, so the failure message tells the user what to do instead.

Shadow DOM and slots

A custom element can attach a shadow root, which is a separate DOM tree whose styles don’t leak out and whose internals page styles can’t reach. This is why component libraries use it: a .label class on your page can’t accidentally restyle the label inside their component.

// doc-note.js
class DocNote extends HTMLElement {
  connectedCallback() {
    if (this.shadowRoot) return;
    const shadow = this.attachShadow({ mode: 'open' });

    const style = document.createElement('style');
    style.textContent = `
      :host { display: block; padding: 1rem; border-inline-start: 4px solid var(--note-accent, currentColor); }
      .label { margin: 0 0 0.5rem; font-weight: 700; }
    `;

    const label = document.createElement('p');
    label.className = 'label';
    label.textContent = this.dataset.label ?? 'Note';

    shadow.append(style, label, document.createElement('slot'));
  }
}

customElements.define('doc-note', DocNote);
<doc-note data-label="Tip" style="--note-accent: seagreen">
  <p>Observers are cheaper than scroll listeners.</p>
</doc-note>

The <slot> is where the element’s own children appear. Those children stay in the regular page DOM, so your page styles still apply to the paragraph. The encapsulation has three limits worth knowing. Inherited properties such as color and font-family still flow into the shadow root from the page. CSS custom properties pass through too, which is how --note-accent themes the border from outside. And ID references don’t cross the boundary, so an aria-labelledby or <label for> on one side can’t point to an id on the other. That last one causes real accessibility bugs in component libraries.

ElementInternals, briefly

Custom elements don’t take part in forms on their own. A custom rating control inside a <form> won’t appear in FormData or block submission when it’s invalid. ElementInternals closes that gap:

class RatingInput extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this.internals = this.attachInternals();
  }

  setRating(value) {
    this.internals.setFormValue(String(value));
  }
}

customElements.define('rating-input', RatingInput);

static formAssociated = true tells the browser the element belongs in forms, and setFormValue sets what the form submits. Internals also offers setValidity for the constraint validation API from chapter 6, and a way to give the element a default role and ARIA states. Treat this as a preview. These APIs have worked in all major browsers since 2023, but a form-associated element is more work than wrapping a native <input>, so reach for it only when a native control can’t do the job.

The smallest working version

This version highlights the current section in a sticky table of contents. Put these three files in one folder.

<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Widget docs</title>
  <link rel="stylesheet" href="styles.css">
  <script type="module" src="app.js"></script>
</head>
<body>
  <div class="layout">
    <nav class="toc" aria-label="On this page">
      <ul>
        <li><a href="#install" data-toc-link>Install</a></li>
        <li><a href="#usage" data-toc-link>Usage</a></li>
        <li><a href="#options" data-toc-link>Options</a></li>
      </ul>
    </nav>
    <main>
      <section id="install" data-doc-section><h2>Install</h2><p>Add the script to your page.</p></section>
      <section id="usage" data-doc-section><h2>Usage</h2><p>Wrap your content in the element.</p></section>
      <section id="options" data-doc-section><h2>Options</h2><p>Configure it with data attributes.</p></section>
    </main>
  </div>
</body>
</html>
/* styles.css */
.layout { display: grid; grid-template-columns: 12rem 1fr; gap: 2rem; }
.toc { position: sticky; top: 1rem; align-self: start; }
.toc a[aria-current] { font-weight: 700; }
[data-doc-section] { min-height: 90vh; }
// app.js
const links = [...document.querySelectorAll('[data-toc-link]')];
const sections = [...document.querySelectorAll('[data-doc-section]')];
const state = { currentId: sections[0]?.id ?? null };

function render() {
  for (const link of links) {
    if (link.hash === `#${state.currentId}`) {
      link.setAttribute('aria-current', 'true');
    } else {
      link.removeAttribute('aria-current');
    }
  }
}

const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (entry.isIntersecting) state.currentId = entry.target.id;
  }
  render();
}, { rootMargin: '0px 0px -70% 0px' });

sections.forEach((section) => observer.observe(section));
render();

The current section ID lives in state, the observer changes it, and render updates the links. The style hook is aria-current, so the visual highlight and what a screen reader hears can’t disagree. The links work without JavaScript, because they’re ordinary same-page anchors.

Accessibility check

Test the details dialog with a keyboard and a screen reader. Tab to the button, press Enter, and check four things: focus lands inside the dialog, Tab never reaches the page behind it, Escape closes it, and focus returns to the button. With a screen reader, the dialog should be announced with its title, and the page behind it should not be reachable with the screen reader’s reading keys.

Here’s a version that fails. A common shortcut is to open the dialog by setting dialog.open = true or adding the open attribute. The dialog appears, and it looks identical. But focus stays on the button behind it, Tab walks through the page underneath, Escape does nothing, and a screen reader user may not know anything opened. Opening it that way shows a non-modal dialog, which has none of the modal behavior. Change the call to dialog.showModal() and all four checks pass.

Two more checks: a screen reader should announce the highlighted table of contents link as current, and the copy button’s result should be announced from its role="status" element, which is created in connectedCallback before any message goes into it.

Debugging challenge: the theme that wouldn’t stay light

Here’s a theme toggle written without the storage helper:

// theme.js (broken)
const darkToggle = document.querySelector('[data-dark-toggle]');

darkToggle.checked = Boolean(localStorage.getItem('dark-mode'));
document.documentElement.dataset.theme = darkToggle.checked ? 'dark' : 'light';

darkToggle.addEventListener('change', () => {
  localStorage.setItem('dark-mode', darkToggle.checked);
  document.documentElement.dataset.theme = darkToggle.checked ? 'dark' : 'light';
});

The symptom: turning dark mode on survives a reload, but turning it off doesn’t. Every reload after the first time comes back dark.

Start by checking what was saved. In Chrome, open the Application panel and choose Local Storage (in Firefox, it’s under Storage). The dark-mode key holds "false", which looks right. Then check how it’s read by typing Boolean(localStorage.getItem('dark-mode')) in the console. It returns true.

That’s the bug. setItem converted the boolean false to the string "false", and any non-empty string is truthy, so Boolean("false") is true. The page has been reading “there’s something saved” as “dark mode is on.”

The fix is to stop relying on truthiness. Store a value you compare exactly, and go through the helper so JSON handles the conversion and a storage failure can’t break the page:

// theme.js (fixed)
import { readSetting, writeSetting } from './storage.js';

const darkToggle = document.querySelector('[data-dark-toggle]');

darkToggle.checked = readSetting('theme', 'light') === 'dark';
document.documentElement.dataset.theme = darkToggle.checked ? 'dark' : 'light';

darkToggle.addEventListener('change', () => {
  const theme = darkToggle.checked ? 'dark' : 'light';
  writeSetting('theme', theme);
  document.documentElement.dataset.theme = theme;
});

This works because…

This works because each of these APIs connects to the state-and-render loop you already use. Observers are event sources: the browser watches visibility or size and calls a callback, which updates state and renders. Storage and the URL are places to keep state beyond the current page load, chosen by how long it should last and whether it should be shareable, and the code reads from them once at startup and writes to them when state changes. <dialog> and popover move the hardest overlay behavior, such as focus, Escape, the top layer, and inertness, into the browser, so the script only has to say when to open. Custom elements package that behavior into a tag that upgrades itself wherever it appears.

Project: interactive documentation page

Build a documentation page for an imaginary component. It should feel like a page from a real design system.

It needs:

  • a sticky table of contents that highlights the current section;
  • a resizable example frame that reports its width and switches its layout with ResizeObserver;
  • a Light, Dark, and System theme choice saved to localStorage;
  • code examples with copy buttons, built with the <copy-code> custom element;
  • a native <dialog> with extra details, such as browser support;
  • a filter for the examples list (All, Beginner, Advanced), kept in the URL;
  • at least one <doc-note> that uses Shadow DOM and a slot.

Starter HTML

<!-- index.html (inside <body>) -->
<header class="site-header">
  <p class="site-name">Widget docs</p>
  <fieldset class="theme-picker" data-theme-picker>
    <legend>Theme</legend>
    <label><input type="radio" name="theme" value="light"> Light</label>
    <label><input type="radio" name="theme" value="dark"> Dark</label>
    <label><input type="radio" name="theme" value="system"> System</label>
  </fieldset>
</header>

<div class="layout">
  <nav class="toc" aria-label="On this page"><!-- links as in the smallest version --></nav>

  <main>
    <section id="overview" data-doc-section>
      <h2>Overview</h2>
      <doc-note data-label="Tip"><p>Every example on this page works with a keyboard.</p></doc-note>
      <button type="button" data-open-support>Browser support details</button>
    </section>

    <section id="responsive" data-doc-section>
      <h2>Responsive behavior</h2>
      <p>Drag the corner of the frame to resize it. <span data-demo-size></span></p>
      <div class="demo-frame" data-demo-frame><!-- example toolbar --></div>
    </section>

    <section id="examples" data-doc-section>
      <h2>Examples</h2>
      <label for="level">Show</label>
      <select id="level" data-level-filter>
        <option value="all">All examples</option>
        <option value="beginner">Beginner</option>
        <option value="advanced">Advanced</option>
      </select>
      <ul data-example-list>
        <li data-level="beginner"><copy-code><pre><code>&lt;widget-card&gt;&lt;/widget-card&gt;</code></pre></copy-code></li>
        <li data-level="advanced"><copy-code><pre><code>&lt;widget-card data-variant="compact"&gt;&lt;/widget-card&gt;</code></pre></copy-code></li>
      </ul>
    </section>
  </main>
</div>

<dialog aria-labelledby="support-title" data-support-dialog><!-- as shown earlier --></dialog>

Give .demo-frame resize: horizontal and overflow: auto in CSS so readers can drag it, plus a fixed height, so a layout change inside the frame can’t change the size you’re observing.

Key JavaScript

Theme preference, with system following the operating system through CSS:

// theme.js
import { readSetting, writeSetting } from './storage.js';

const themes = ['light', 'dark', 'system'];
const picker = document.querySelector('[data-theme-picker]');

const saved = readSetting('theme', 'system');
const state = { theme: themes.includes(saved) ? saved : 'system' };

function render() {
  document.documentElement.dataset.theme = state.theme;
  picker.querySelector(`[value="${state.theme}"]`).checked = true;
}

picker.addEventListener('change', (event) => {
  state.theme = event.target.value;
  writeSetting('theme', state.theme);
  render();
});

render();
/* styles.css (excerpt) */
:root { --bg: #fff; --text: #1a1a1a; }
:root[data-theme="dark"] { --bg: #16161a; --text: #f2f2f2; }
@media (prefers-color-scheme: dark) {
  :root[data-theme="system"] { --bg: #16161a; --text: #f2f2f2; }
}
body { background: var(--bg); color: var(--text); }

Module scripts run after the page is parsed, so a reader with a saved dark theme may see a light flash on load. You can fix that later with a tiny inline script in the <head>. Leave it for now and note it in your write-up.

Example filter, read from and written to the URL:

// filters.js
const levelSelect = document.querySelector('[data-level-filter]');
const items = [...document.querySelectorAll('[data-example-list] [data-level]')];
const state = { level: readLevelFromUrl() };

function render() {
  levelSelect.value = state.level;
  for (const item of items) {
    item.hidden = state.level !== 'all' && item.dataset.level !== state.level;
  }
}

levelSelect.addEventListener('change', () => {
  state.level = levelSelect.value;
  writeLevelToUrl(state.level);
  render();
});

render();

Put allowedLevels, readLevelFromUrl, and writeLevelToUrl from the URL section at the top of this file. Import copy-code.js, doc-note.js, theme.js, and filters.js from app.js along with the table of contents and resize code, so the page still has one <script type="module"> entry point.

Done means

  • The table of contents highlights the current section with aria-current, and no code listens for scroll.
  • Resizing the example frame updates the width label and switches between compact and wide layouts, with no ResizeObserver loop error in the console.
  • The theme survives a reload, System follows the operating system setting, and the page still works when storage is blocked (test by temporarily making readSetting and writeSetting throw).
  • Choosing a filter updates the URL, reloading keeps it, a copied link opens with the same filter, and an invalid ?level= value falls back to All.
  • Copy buttons announce “Copied” or a useful failure message through a live region.
  • The dialog opens with showModal(), has an accessible name from its heading, closes with Escape and the Close button, and returns focus to its trigger.
  • The <doc-note> accent color can be changed from the page with --note-accent, and you’ve confirmed in devtools that page styles don’t reach its label.
  • Any smooth scrolling respects prefers-reduced-motion.
  • You’ve written a “This works because…” paragraph explaining why each piece of state lives where it does: in memory, in storage, or in the URL.

In the next chapter, you’ll build the components that still need you to write the keyboard and focus behavior yourself, and document the accessibility contract each one promises.

Found an error? Report it.