Ch. 5 · HTML & CSS

HTML Progressive Enhancement and Reliable Navigation

A product filter built as a plain GET form first, then enhanced with fetch and pushState without breaking shared links or the back button.

~6 min readadvancedupdated Oct 7, 2026

Progressive enhancement is often described as “make it work without JavaScript”, and that framing gets it dismissed: “nobody turns JavaScript off”. The real argument is about failure. Every page spends some time with its HTML rendered and its JavaScript not yet running: a 2 MB bundle on a slow 4G connection, a script blocked by a corporate proxy or an extension, a runtime error in a third-party tag that stops your code, a CDN hiccup. During that time, a page built on plain links and forms still works. A page whose buttons only do things through event handlers doesn’t, and the user clicks “Apply filters” and nothing happens.

The approach: build the feature from HTML that completes the task, then use CSS and JavaScript to make it faster and nicer, without taking away what the HTML already provides.

The baseline: a filter that’s just a form

A product listing with filters. The HTML-only version:

<form action="/shoes" method="get" class="filters">
  <fieldset>
    <legend>Terrain</legend>
    <label><input type="checkbox" name="terrain" value="trail"> Trail</label>
    <label><input type="checkbox" name="terrain" value="road"> Road</label>
  </fieldset>

  <label for="max-price">Maximum price (USD)</label>
  <select id="max-price" name="max">
    <option value="">Any</option>
    <option value="100">100</option>
    <option value="150">150</option>
  </select>

  <button type="submit">Apply filters</button>
</form>

<section id="results" aria-live="polite">
  <!-- server-rendered product cards for the current filters -->
</section>

<nav aria-label="Pagination">
  <a href="/shoes?terrain=trail&max=150&page=2">Next page</a>
</nav>
HTML

Ticking “Trail”, choosing 150 and pressing the button submits a GET request to /shoes?terrain=trail&max=150. The server renders the matching products. This has properties people later spend effort recreating in JavaScript:

  • The URL describes the state, so it can be bookmarked, shared in Slack, opened in a new tab and indexed.
  • The back button goes back to the previous filter set, because each submission is a real navigation.
  • It works before any script has loaded and keeps working if scripts fail.
  • Pagination is a link, so “open page 2 in a new tab” works.

Using GET rather than POST is the important decision here. Filtering doesn’t change anything on the server, so it’s safe and repeatable, which is what GET means. A POST would give you “Confirm form resubmission” dialogs on back and no shareable URL.

The enhancement: update results without a page load

Now JavaScript makes it smoother. The goal is the same behaviour, faster: results update without a full reload, and the URL and history still behave like the HTML version.

const form = document.querySelector('.filters');
const results = document.querySelector('#results');

async function load(url, { push }) {
  results.setAttribute('aria-busy', 'true');
  try {
    const response = await fetch(url, { headers: { Accept: 'text/html' } });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const html = await response.text();
    const doc = new DOMParser().parseFromString(html, 'text/html');
    results.replaceChildren(...doc.querySelector('#results').childNodes);
    if (push) history.pushState(null, '', url);
  } catch {
    location.assign(url); // fall back to the normal navigation
  } finally {
    results.removeAttribute('aria-busy');
  }
}

form.addEventListener('submit', (event) => {
  event.preventDefault();
  const params = new URLSearchParams(new FormData(form));
  load(`${form.action}?${params}`, { push: true });
});

window.addEventListener('popstate', () => {
  load(location.href, { push: false });
});
JavaScript

Each decision, in order:

The script takes over the form’s existing behaviour. It listens for submit, not for clicks on the button, so pressing Enter in a field still works. It builds the URL from FormData exactly as the browser would, so for the example above params.toString() is terrain=trail&max=150, and the URL the script fetches is the same one the plain form would load. The server needs no separate API.

It fetches HTML from the same endpoint. Reusing the server’s rendering keeps one source of truth. You could fetch JSON instead, but then the product card markup exists twice, in the server template and in client code, and they’ll drift.

It pushes the new URL. Without history.pushState, the address bar would still say /shoes after filtering, so sharing or refreshing would lose the filters. This is the most common way an enhancement makes things worse than the baseline.

It handles popstate. When the user presses back, the browser changes the URL and fires popstate, but doesn’t change the content (because those history entries were created by script). The handler reloads the results for the restored URL. Without it, the URL changes and the products don’t.

It fails back to navigation. If the fetch fails or the response is broken, location.assign(url) does the full page load the form would have done. The enhancement can only make things better.

It tells assistive technology what happened. The results container has aria-live="polite" so a screen reader user hears that results changed, and aria-busy while loading suppresses announcements of half-updated content. In the baseline, the page load itself told them.

For extra polish, you could submit automatically when a checkbox changes (listen for change and call form.requestSubmit(), which runs validation and fires submit), and keep the button for users without JavaScript, perhaps visually hidden only once the script is running.

CSS is part of this too

Progressive enhancement applies to styling too, and CSS makes it easy because unsupported features fail quietly:

.product-card h3 {
  text-wrap: balance; /* ignored where unsupported */
}

.filters label:has(input:checked) {
  font-weight: 600;
}
CSS

A browser that doesn’t recognise a property or value drops that one declaration and keeps the rest of the rule, so the headings simply wrap normally. Selectors are stricter: a browser that doesn’t understand :has() drops the whole rule, so the checked label isn’t bolded but the checkbox still shows its state. That’s fine for an extra, but never put a must-have declaration in a rule whose selector might be unsupported (or in a selector list alongside one, since one unknown selector invalidates the entire list). For bigger changes, gate the enhanced version with @supports and write the plain version first. The page looks a bit plainer in older browsers instead of breaking.

Where it’s expensive, and where it isn’t

Not every feature has a sensible HTML baseline. A collaborative whiteboard or a photo editor genuinely needs JavaScript, and nobody expects otherwise. The useful question is narrower: does the core task (reading content, following a link, submitting a form, filtering a list, signing in, checking out) work while scripts are loading or broken?

For server-rendered and hybrid frameworks this is cheaper than it used to be. Remix and React Router’s forms, SvelteKit’s form actions, Astro’s server endpoints and Next.js server actions with a <form action> all start from a real form submission and enhance it. For client-only SPAs, the baseline is harder, but you can still avoid the worst failures: use real <a href> links so new tabs work, use <form> with submit handling rather than click handlers on divs, and render meaningful content on the server where possible.

Interview practice

An e-commerce SPA’s “Sort by” control is a custom dropdown built from <div>s, and selecting an option re-sorts results in memory without changing the URL. Users report that the sort resets when they open a product and press back, and that sorted links they share show the default order. How would you rebuild it?

Answer

Make the sort part of the URL and build it on a form. Replace the custom dropdown with a <select name="sort"> inside the existing filter form (or its own GET form) so the HTML version submits to /shoes?sort=price-asc and the server renders sorted results; a native <select> is also accessible and works with the keyboard without extra code. Then enhance: intercept submit (or change followed by requestSubmit()), fetch the results for the new URL, swap them in, and call history.pushState with that URL. Handle popstate by reloading results for location.href, so pressing back from a product page restores the sorted list from the URL rather than from memory. Shared links now carry ?sort=price-asc, so recipients see the same order, and if JavaScript fails, the form still submits normally. If the app restores scroll position, do it after the restored results render, or the browser’s automatic scroll restoration will jump before the content is there.

Keep going

More in HTML & CSS

read ✓HTML & CSS · easy

HTML Form Labels and Accessible Names

How label, for/id, aria-labelledby and aria-describedby give each field a name and a hint, and why placeholder text can't stand in for a label.

~6 min readread →
read ✓HTML & CSS · hard

HTML Data Tables and Header Relationships

How th, scope, caption and headers/id tell a screen reader which headers describe a cell, with a two-level pricing table worked through.

~7 min readread →
esc