Progressive Enhancement
Build a functional HTML baseline and add CSS and JavaScript layers on top. Covers feature detection, graceful degradation, and no-JS fallbacks.
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.
Navigation enhancement
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.
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 enhancement | Graceful degradation | JS-required | |
|---|---|---|---|
| Starting point | Working HTML baseline | Full-featured app | JavaScript app |
| If JS fails | Baseline still works | Depends on fallbacks added later | Nothing works |
| Testing focus | Baseline first, then layers | Breakpoints in the full version | JS environment only |
| Typical result | Baseline ships by default | Baseline often never ships | Blank 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)notif (isChrome) - Add the
jsclass to<html>— use.js .componentselectors 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-enhanceattributes — 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
actionattribute and relies entirely on JS. Without JS, the form does nothing. Fix: give every form a realactionandmethod. - 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
.jsclass 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.
Related Resources
Islands Architecture: Partial Hydration for Faster Pages
Server-render the page as static HTML and hydrate only the interactive islands. Covers Astro client directives, partial hydration, shared state, and when islands beat a SPA.
PatternCSS Architecture: Utility-First with Component-Scoped Layers
How to organize CSS with utility-first classes and component-scoped layers. Covers Tailwind CSS, CSS layers, BEM, CSS modules, and design tokens.
PatternContainer-Presenter: Separate Data Logic from Rendering
How to separate data-fetching logic from rendering in React using the container-presenter pattern. Covers hooks migration, testing benefits, and trade-offs.