StackPractices
intermediate By Mathias Paulenko

Progressive Enhancement

Build a functional HTML baseline and add CSS and JavaScript layers on top. Covers feature detection, graceful degradation, and no-JS fallbacks.

Topics: frontend

Overview

Progressive enhancement is a delivery strategy: you ship a page whose core function works with plain HTML, then layer CSS for presentation and JavaScript for interactivity on top. The term was coined by Steven Champeon in 2003, and the idea survives every framework cycle because it’s about failure modes, not technology.

The baseline handles the essentials: forms submit, links navigate, content is readable. If a script fails to download on a flaky connection, is blocked by a corporate proxy, or throws on an API the browser lacks, the user still gets a working page. JavaScript then improves that working page with inline validation, async submission, and richer interactions instead of being a prerequisite for it.

The payoff is threefold. Search engines and screen readers receive complete content without executing a byte of JavaScript. Real users on constrained devices and networks get something usable immediately instead of a blank screen. And your codebase gets a natural resilience boundary: when an enhancement breaks, it degrades to the baseline rather than to nothing.

When to Use

  • Public-facing websites where SEO and accessibility matter
  • Forms that must work even when JavaScript fails
  • Content sites where the primary value is reading
  • Applications targeting users on slow connections or older browsers
  • Any site where robustness is more important than flashy interactions

A contact form, a checkout flow, a government service, a documentation site: these are the classic fits. If losing JavaScript means losing the user’s ability to complete a task, progressive enhancement is the right default. The GOV.UK service manual has mandated this approach for years precisely because public services can’t exclude users whose browsers or networks fail.

When NOT to Use

  • Highly interactive applications (editors, games, chat) where HTML alone can’t provide value
  • Internal tools where you control the browser and network conditions
  • Real-time dashboards that require JavaScript to function
  • Applications where the entire value proposition is JavaScript-based interactivity

Be honest about the boundary. A collaborative document editor, a canvas-based design tool, or a WebGL dashboard has no meaningful HTML baseline to fall back to. Progressive enhancement is for pages and flows, not for applications whose value is the runtime itself.

Solution

Layer 1: Functional HTML baseline

The baseline is a real form: action, method, labels, and required attributes. It posts to the server and works in Lynx, in a crawler, and on a 2009 Blackberry.

<!-- contact-form.html — works without JavaScript -->
<form action="/api/contact" method="POST" class="contact-form">
  <fieldset>
    <legend>Contact Us</legend>

    <div class="field">
      <label for="name">Name</label>
      <input type="text" id="name" name="name" required maxlength="100">
    </div>

    <div class="field">
      <label for="email">Email</label>
      <input type="email" id="email" name="email" required>
    </div>

    <div class="field">
      <label for="message">Message</label>
      <textarea id="message" name="message" required rows="5" maxlength="1000"></textarea>
    </div>

    <button type="submit">Send Message</button>
  </fieldset>
</form>

Two details carry the weight here. The action and method attributes mean the form submits somewhere real, and the native required and type="email" attributes give you free browser validation before any JavaScript runs.

Layer 2: CSS presentation

CSS improves the baseline without changing its behavior. The only subtlety is the .js prefix: styles for JavaScript-only states (like inline error messages) are scoped under .js, so they never render in the no-JS baseline where nothing populates them.

/* contact-form.css — enhance the baseline */
.contact-form {
  max-width: 500px;
  margin: 2rem auto;
  padding: 2rem;
  background: white;
  border-radius: 0.5rem;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.contact-form fieldset {
  border: none;
  padding: 0;
}

.contact-form legend {
  font-size: 1.5rem;
  font-weight: 700;
  margin-bottom: 1rem;
}

.field {
  margin-bottom: 1rem;
}

.field label {
  display: block;
  font-weight: 500;
  margin-bottom: 0.25rem;
}

.field input,
.field textarea {
  width: 100%;
  padding: 0.5rem 0.75rem;
  border: 1px solid #cbd5e1;
  border-radius: 0.375rem;
  font-size: 1rem;
}

.field input:focus,
.field textarea:focus {
  outline: none;
  border-color: #3b82f6;
  box-shadow: 0 0 0 2px #dbeafe;
}

/* Only show JS-enhanced states when JS is available */
.js .field input.invalid {
  border-color: #ef4444;
}

.js .field .error-message {
  display: none;
  color: #ef4444;
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

.js .field.invalid .error-message {
  display: block;
}

.contact-form button {
  width: 100%;
  padding: 0.625rem 1rem;
  background: #3b82f6;
  color: white;
  border: none;
  border-radius: 0.375rem;
  font-weight: 500;
  cursor: pointer;
}

.contact-form button:hover {
  background: #2563eb;
}

See the CSS architecture pattern for how to organize these layer-scoped selectors at scale.

Layer 3: JavaScript enhancement

The script’s first act is adding the js class to <html>, which flips on all the .js-scoped CSS. Then it intercepts the submit event, runs friendlier inline validation, and swaps the full page reload for a fetch call. If any of that fails, the form still submits the old way.

// contact-form.js — enhance the form with validation and async submit
document.documentElement.classList.add('js');

const form = document.querySelector('.contact-form');

if (form) {
  // Client-side validation
  const inputs = form.querySelectorAll('input, textarea');

  inputs.forEach(input => {
    input.addEventListener('blur', () => validateField(input));
    input.addEventListener('input', () => {
      if (input.classList.contains('invalid')) {
        validateField(input);
      }
    });
  });

  function validateField(input) {
    const field = input.closest('.field');
    let isValid = true;
    let message = '';

    if (input.required && !input.value.trim()) {
      isValid = false;
      message = 'This field is required.';
    } else if (input.type === 'email' && input.value) {
      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      if (!emailRegex.test(input.value)) {
        isValid = false;
        message = 'Please enter a valid email address.';
      }
    } else if (input.maxLength && input.value.length > input.maxLength) {
      isValid = false;
      message = `Maximum ${input.maxLength} characters.`;
    }

    field.classList.toggle('invalid', !isValid);

    let errorEl = field.querySelector('.error-message');
    if (!errorEl) {
      errorEl = document.createElement('span');
      errorEl.className = 'error-message';
      field.appendChild(errorEl);
    }
    errorEl.textContent = message;

    return isValid;
  }

  // Async submission — only if JS is available
  form.addEventListener('submit', async (e) => {
    e.preventDefault();

    // Validate all fields
    let allValid = true;
    inputs.forEach(input => {
      if (!validateField(input)) {
        allValid = false;
      }
    });

    if (!allValid) return;

    const submitButton = form.querySelector('button[type="submit"]');
    submitButton.disabled = true;
    submitButton.textContent = 'Sending...';

    try {
      const formData = new FormData(form);
      const response = await fetch(form.action, {
        method: form.method,
        body: formData,
      });

      if (response.ok) {
        form.innerHTML = `
          <div class="ql">
            <h2>Thank you!</h2>
            <p>Your message has been sent. We'll get back to you soon.</p>
          </div>
        `;
      } else {
        throw new Error('Server error');
      }
    } catch (error) {
      submitButton.disabled = false;
      submitButton.textContent = 'Send Message';

      const errorDiv = document.createElement('div');
      errorDiv.className = 'form-error';
      errorDiv.textContent = 'Failed to send. Please try again.';
      form.insertBefore(errorDiv, form.firstChild);
    }
  });
}

Notice the server still receives a normal POST either way. Client-side validation is a courtesy; server-side validation is the requirement. Never treat inline validation as a security boundary.

Feature detection before enhancement

Enhancement is conditional on capability, not on browser identity. Check the API you actually need, and provide a fallback path when it’s missing.

// feature-detection.js — check capabilities before enhancing
const features = {
  fetch: typeof fetch !== 'undefined',
  intersectionObserver: 'IntersectionObserver' in window,
  customElements: 'customElements' in window,
  webp: document.createElement('canvas').toDataURL('image/webp').indexOf('data:image/webp') === 0,
  serviceWorker: 'serviceWorker' in navigator,
};

// Only enhance if features are available
if (features.fetch) {
  enhanceFormSubmission();
}

if (features.intersectionObserver) {
  lazyLoadImages();
} else {
  // Fallback: load all images immediately
  loadAllImages();
}

if (features.serviceWorker) {
  navigator.serviceWorker.register('/sw.js').catch(() => {
    // Service worker failed — site still works without it
  });
}

function enhanceFormSubmission() {
  document.querySelectorAll('form[data-enhance]').forEach(form => {
    form.addEventListener('submit', async (e) => {
      e.preventDefault();
      const response = await fetch(form.action, {
        method: form.method,
        body: new FormData(form),
      });
      if (response.ok) {
        form.dispatchEvent(new CustomEvent('form:success', { detail: await response.json() }));
      }
    });
  });
}

function lazyLoadImages() {
  const images = document.querySelectorAll('img[data-src]');
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        img.removeAttribute('data-src');
        observer.unobserve(img);
      }
    });
  });
  images.forEach(img => observer.observe(img));
}

function loadAllImages() {
  document.querySelectorAll('img[data-src]').forEach(img => {
    img.src = img.dataset.src;
    img.removeAttribute('data-src');
  });
}

Each if is a fallback decision, not a gate: IntersectionObserver missing means images load eagerly, which is slower but correct. Feature detection beats navigator.userAgent sniffing because it tests the capability directly and keeps working as browsers change.

Links remain real links. The enhancement intercepts same-origin clicks and swaps the main content, with a hard fallback to normal navigation if anything goes wrong.

<!-- nav.html — works as regular links, enhanced to SPA-style navigation -->
<nav class="main-nav">
  <a href="/" data-link>Home</a>
  <a href="/about" data-link>About</a>
  <a href="/products" data-link>Products</a>
  <a href="/contact" data-link>Contact</a>
</nav>
// nav-enhance.js — enhance links for SPA-style navigation
if (features.fetch && features.customElements) {
  document.addEventListener('click', (e) => {
    const link = e.target.closest('a[data-link]');
    if (!link) return;

    // Only handle same-origin links
    if (link.origin !== window.location.origin) return;

    e.preventDefault();

    loadPage(link.href);
  });

  async function loadPage(url) {
    const main = document.querySelector('main');

    // Show loading state
    main.style.opacity = '0.5';

    try {
      const response = await fetch(url);
      const html = await response.text();
      const parser = new DOMParser();
      const doc = parser.parseFromString(html, 'text/html');

      // Update content
      main.innerHTML = doc.querySelector('main').innerHTML;
      main.style.opacity = '1';

      // Update URL and title
      window.history.pushState({}, '', url);
      document.title = doc.title;

      // Scroll to top
      window.scrollTo(0, 0);
    } catch {
      // Fallback: navigate normally
      window.location.href = url;
    }
  }

  // Handle back/forward
  window.addEventListener('popstate', () => {
    loadPage(window.location.href);
  });
}

Three details make this safe: external links pass through untouched, the catch falls back to window.location.href, and pushState keeps deep links shareable. This is the same idea that powers Turbo, htmx, and Next.js client transitions, just without the framework.

Table sorting enhancement

A <table> with real data works for everyone. The enhancement adds click-to-sort on top, with keyboard support so the interaction isn’t mouse-only.

<!-- data-table.html — functional table, enhanced with sorting -->
<table class="data-table" data-enhance="sort">
  <thead>
    <tr>
      <th data-sort="name">Name</th>
      <th data-sort="email">Email</th>
      <th data-sort="date">Date</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Alice</td>
      <td>alice@example.com</td>
      <td>2026-01-15</td>
    </tr>
    <tr>
      <td>Bob</td>
      <td>bob@example.com</td>
      <td>2026-03-20</td>
    </tr>
  </tbody>
</table>
// table-sort.js — enhance table with click-to-sort
document.querySelectorAll('table[data-enhance="sort"]').forEach(table => {
  const headers = table.querySelectorAll('th[data-sort]');

  headers.forEach(header => {
    header.style.cursor = 'pointer';
    header.setAttribute('role', 'button');
    header.setAttribute('tabindex', '0');

    header.addEventListener('click', () => sortTable(table, header));
    header.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        sortTable(table, header);
      }
    });
  });

  function sortTable(table, header) {
    const tbody = table.querySelector('tbody');
    const rows = Array.from(tbody.querySelectorAll('tr'));
    const currentDir = header.dataset.dir || 'asc';
    const newDir = currentDir === 'asc' ? 'desc' : 'asc';

    rows.sort((a, b) => {
      const aVal = a.querySelector(`td:nth-child(${header.cellIndex + 1})`).textContent;
      const bVal = b.querySelector(`td:nth-child(${header.cellIndex + 1})`).textContent;
      return newDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
    });

    header.dataset.dir = newDir;
    rows.forEach(row => tbody.appendChild(row));
  }
});

How It Works

Every enhancement is a bet you can lose gracefully. The pattern structures the bet as an ordered set of layers with a fallback at each step.

Mermaid flowchart LR diagram

Three consequences follow. First, the baseline is a product, not a wireframe: it must ship, be tested, and be reachable at a URL. Second, every enhancement needs an explicit fallback decision before it merges; “it works in Chrome” isn’t a fallback. Third, server-side handling is the floor everything stands on, since the no-JS path posts directly to it.

Progressive enhancement is often confused with graceful degradation, and the difference is where you start:

Progressive enhancementGraceful degradationJS-required
Starting pointWorking HTML baselineFull-featured appJavaScript app
If JS failsBaseline still worksDepends on fallbacks added laterNothing works
Testing focusBaseline first, then layersBreakpoints in the full versionJS environment only
Typical resultBaseline ships by defaultBaseline often never shipsBlank screen on failure

The practical edge cases:

  • Partial JS failure is common. A CDN timeout can deliver HTML and CSS but no JS, or a transpilation error can kill one bundle while others survive. Layered design isolates the failure to the layer that broke.
  • CSS can fail too. Blocked stylesheets happen in restrictive proxies. Semantic HTML keeps the page navigable even unstyled.
  • Screen readers aren’t the only assistive layer. Crawlers, reader modes, link unfurlers, and print views all consume the baseline.
  • Enhancement can enhance the wrong thing. Feature-detect the API you use ('fetch' in window), not the API you assume correlates with it.

Variants

Universal rendering with enhancement

Server-rendered HTML with selective hydration is progressive enhancement implemented by a framework rather than by hand. The server sends a complete page; the client attaches behavior only where needed.

// server-rendered HTML that hydrates progressively
// Server sends full HTML, client enhances specific parts
document.addEventListener('DOMContentLoaded', () => {
  // Only enhance if browser supports the needed APIs
  if (!('fetch' in window) || !('IntersectionObserver' in window)) {
    return; // Baseline HTML works fine
  }

  // Enhance search
  const searchInput = document.querySelector('#search-input');
  if (searchInput) {
    enhanceSearch(searchInput);
  }

  // Enhance cart
  const cartButton = document.querySelector('#add-to-cart');
  if (cartButton) {
    enhanceCart(cartButton);
  }
});

The islands architecture pattern formalizes this: static by default, with interactive islands hydrated independently so a failure in one island doesn’t take the page down.

No-JS fallback with noscript

For functionality that genuinely can’t degrade (a live chart, a WYSIWYG editor), <noscript> gives an honest fallback message instead of a broken widget.

<!-- Provide fallback for JavaScript-dependent features -->
<noscript>
  <style>
    .js-only { display: none !important; }
    .no-js-only { display: block !important; }
  </style>
  <div class="no-js-only warning">
    This feature requires JavaScript. Some functionality may be limited.
  </div>
</noscript>

<style>
  .no-js-only { display: none; }
</style>

Best Practices

Accessibility is the strongest reason to take this seriously: screen readers, keyboard users, and low-powered devices all benefit from a semantic baseline. For the full compliance picture, see the complete guide to web accessibility.

  • Start with HTML that works — forms submit, links navigate, content is readable
  • Use feature detection, not browser sniffing — check if ('fetch' in window) not if (isChrome)
  • Add the js class to <html> — use .js .component selectors so enhanced styles only apply when JS is available
  • Enhance, don’t replace — JavaScript should improve the HTML baseline, not replace it with something that breaks without JS
  • Test with JavaScript disabled — disable JS in DevTools and verify the page still works
  • Keep server-side validation — client-side validation is enhancement; server-side is required
  • Use data-enhance attributes — mark elements for enhancement so the JS layer knows what to improve
  • Provide loading states for async operations — users need feedback when JS takes over
  • Ship the baseline first — if it cannot deploy alone, you do not have a baseline

Common Mistakes

  • JavaScript-only forms: form has no action attribute and relies entirely on JS. Without JS, the form does nothing. Fix: give every form a real action and method.
  • No server-side validation: trusting client-side validation. Bypass JS and submit invalid data directly to the server. Fix: validate on the server regardless; treat client checks as UX only.
  • Replacing instead of enhancing: JS removes the HTML table and rebuilds it as a React component. Without JS, no table. Fix: keep the rendered markup and attach behavior to it.
  • Not testing without JS: the site works in development (JS always loads) but breaks for users with JS disabled or slow connections. Fix: add a no-JS pass to manual QA and CI.
  • CSS dependent on JS: styles that only work when JS adds classes. The un-JS version looks broken even though it functions. Fix: scope JS-only styles under a .js class on <html>.
  • Over-enhancing the baseline: the enhanced version adds capabilities the baseline never had, so no-JS users get a different product. Fix: keep the task list identical across layers; only the interaction quality should differ.

Further Reading

Frequently Asked Questions

What is progressive enhancement?

A layered delivery strategy: HTML provides the functional baseline, CSS adds presentation, and JavaScript adds interactivity on top. Each layer builds on the one below, so a failure in a higher layer degrades to a working page instead of a blank screen.

How is this different from graceful degradation?

Progressive enhancement starts simple and layers on functionality; graceful degradation starts full-featured and patches failure paths afterward. In practice the baseline-only fallback is much more likely to actually ship when it's the starting point rather than an afterthought.

Should every site use progressive enhancement?

Public-facing sites should, because SEO, accessibility, and unpredictable user conditions all reward a working baseline. Internal tools and genuinely interactive applications may not need it when you control the environment.

Can I use progressive enhancement with React or Next.js?

Yes, through server-side rendering or static generation plus selective hydration: the server ships complete HTML and the client attaches behavior where needed. What breaks the pattern is client-side-only rendering where the HTML shell is empty.

Does progressive enhancement mean no JavaScript?

No. JavaScript is an enhancement layer, not a requirement. You still use it for inline validation, async submission, animations, and interactivity, but the page remains usable without it.

Why does progressive enhancement matter for SEO?

Crawlers index the HTML they receive, and a working baseline guarantees complete content without executing JavaScript. It also improves Core Web Vitals since content renders without waiting for bundles.

How do I test a page without JavaScript?

In Chrome DevTools, open the Command Palette and run "Disable JavaScript", then reload and verify that forms submit, links navigate, and content is readable. You can also block script files in the Network panel to simulate a partial failure.

Can I detect features with a library instead of hand-rolled checks?

Yes. Modernizr automates feature detection and adds capability classes to <html>, which pairs naturally with the .js-scoped CSS technique shown above. For a handful of checks, inline in operator tests are usually enough.