Progressive enhancement is a design philosophy that emphasizes core HTML functionality first, then layering on CSS for presentation and JavaScript for interactivity. This approach ensures your site works for everyone, regardless of capability or connection speed.
What you’ll learn:
Philosophy behind progressive enhancement
Three-layer architecture: HTML, CSS, JavaScript
Feature detection techniques
Graceful degradation vs progressive enhancement
Building forms that work without JavaScript
Strategies for JavaScript-disabled scenarios
CSS feature queries (@supports)
Practical progressive enhancement patterns
Prerequisites:
Understanding of semantic HTML
Basic familiarity with CSS and JavaScript
Progressive enhancement means building websites in layers:
Layer 1: HTML — Core content and functionality
Layer 2: CSS — Visual presentation
Layer 3: JavaScript — Enhanced interactivity
Each layer should be independent. If a layer fails, the site still works.
Benefits:
Resilience — Works even if JavaScript fails to load or execute
Performance — Users get content immediately, JavaScript enhances later
Accessibility — Keyboard navigation and screen readers work by default
SEO — Search engines see real content in HTML
User experience — Fast initial render, progressive improvement
HTML provides core functionality. Build this layer first.
Interactive code playground requires JavaScript. Here's the code:
<!-- Pure HTML — works everywhere -->
<article>
<h1>Getting Started with Web Development</h1>
<section>
<h2>Why Learn Web Development?</h2>
<p>Web development is one of the most in-demand skills...</p>
</section>
<section>
<h2>Choose Your Path</h2>
<nav>
<ul>
<li><a href="/frontend">Frontend Development</a></li>
<li><a href="/backend">Backend Development</a></li>
<li><a href="/fullstack">Full-Stack Development</a></li>
</ul>
</nav>
</section>
<section>
<h2>Get Started Today</h2>
<form method="post" action="/signup">
<label for="email">Email:</label>
<input type="email" id="email" name="email" required>
<label for="path">Learning Path:</label>
<select id="path" name="path">
<option value="">Choose one...</option>
<option value="frontend">Frontend</option>
<option value="backend">Backend</option>
</select>
<button type="submit">Sign Up</button>
</form>
</section>
</article>
Notice: Even without CSS or JavaScript, this is fully functional. Users can:
Read all content
Click links
Fill forms
Navigate
Interactive code playground requires JavaScript. Here's the code:
<!-- Semantic HTML enables core functionality -->
<!-- Native form controls work without JavaScript -->
<form>
<!-- Validation built-in -->
<input type="email" required>
<input type="number" min="0" max="100">
<input type="date">
<!-- Browser provides UX -->
<select>
<option>Option 1</option>
<option>Option 2</option>
</select>
<!-- Native buttons work -->
<button type="submit">Submit</button>
<button type="reset">Reset</button>
</form>
<!-- Details/summary provides disclosure widget -->
<details>
<summary>Click to expand</summary>
<p>Hidden content revealed by browser</p>
</details>
<!-- Links and navigation work natively -->
<nav>
<ul>
<li><a href="/page1">Page 1</a></li>
<li><a href="/page2">Page 2</a></li>
</ul>
</nav>
Avoid JavaScript-Only Interactions
Don’t build critical functionality that requires JavaScript to work. Example: a button that only has an onclick handler and no form submission fallback.
CSS enhances appearance without affecting functionality.
Interactive code playground requires JavaScript. Here's the code:
<!-- HTML -->
<form class="contact-form">
<div class="form-group">
<label for="name">Name:</label>
<input type="text" id="name" name="name" required>
</div>
<div class="form-group">
<label for="email">Email:</label>
<input type="email" id="email" name="email" required>
</div>
<button type="submit" class="btn-primary">Send</button>
</form>
<!-- CSS — enhances, doesn't disable -->
<style>
.contact-form {
max-width: 400px;
margin: 20px auto;
}
.form-group {
margin-bottom: 16px;
}
label {
display: block;
margin-bottom: 4px;
font-weight: bold;
color: #333;
}
input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}
.btn-primary {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
</style>
Use @supports to apply styles only when browsers support certain features.
Interactive code playground requires JavaScript. Here's the code:
<!-- HTML -->
<div class="grid-layout">
<div class="item">Item 1</div>
<div class="item">Item 2</div>
<div class="item">Item 3</div>
</div>
<!-- CSS with feature queries -->
<style>
/* Fallback for older browsers */
.grid-layout {
display: flex;
flex-wrap: wrap;
}
.item {
flex: 1 1 calc(33.333% - 10px);
margin: 5px;
padding: 20px;
background: #f5f5f5;
border-radius: 8px;
}
/* Use modern CSS Grid if supported -->
@supports (display: grid) {
.grid-layout {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.item {
margin: 0;
padding: 20px;
}
}
/* Use newer features if available -->
@supports (gap: 1rem) {
.grid-layout {
gap: 16px;
}
}
</style>
JavaScript adds interactivity and improves user experience, but isn’t required for core functionality.
Interactive code playground requires JavaScript. Here's the code:
<!-- HTML: Functional without JavaScript -->
<form id="search-form" action="/search" method="get">
<input type="text" name="q" placeholder="Search...">
<button type="submit">Search</button>
</form>
<!-- CSS: Looks good -->
<style>
#search-form {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
#search-form input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
}
#search-form button {
padding: 8px 16px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.search-results {
display: none;
}
.search-results.visible {
display: block;
}
</style>
<!-- JavaScript: Adds live search (optional enhancement) -->
<div id="search-results" class="search-results"></div>
<script>
const form = document.getElementById('search-form');
const input = form.querySelector('input');
const resultsDiv = document.getElementById('search-results');
// Live search while typing (enhancement)
input.addEventListener('input', async (e) => {
const query = e.target.value;
if (query.length < 2) {
resultsDiv.classList.remove('visible');
return;
}
try {
// Fetch live results
const response = await fetch(`/api/search?q=${query}`);
const results = await response.json();
// Display results
resultsDiv.innerHTML = results
.map(r => `<div><a href="${r.url}">${r.title}</a></div>`)
.join('');
resultsDiv.classList.add('visible');
} catch (error) {
// If JavaScript fails, form still works via server submission
console.error('Live search failed, form submission will handle search');
}
});
// Prevent form submission if live results were selected
form.addEventListener('submit', (e) => {
const selected = resultsDiv.querySelector('a[data-selected]');
if (selected) {
e.preventDefault();
window.location.href = selected.href;
}
});
</script>
Build forms that work with HTML and CSS alone, then enhance with JavaScript.
Interactive code playground requires JavaScript. Here's the code:
<!-- Pure HTML form — works everywhere -->
<form method="post" action="/contact" class="contact-form">
<h2>Contact Us</h2>
<div class="form-group">
<label for="name">Name: <span aria-label="required">*</span></label>
<input
type="text"
id="name"
name="name"
required
minlength="2"
maxlength="100">
</div>
<div class="form-group">
<label for="email">Email: <span aria-label="required">*</span></label>
<input
type="email"
id="email"
name="email"
required>
</div>
<div class="form-group">
<label for="subject">Subject: <span aria-label="required">*</span></label>
<input
type="text"
id="subject"
name="subject"
required>
</div>
<div class="form-group">
<label for="message">Message: <span aria-label="required">*</span></label>
<textarea
id="message"
name="message"
rows="6"
required
minlength="10"></textarea>
</div>
<button type="submit" class="btn-submit">Send Message</button>
<button type="reset" class="btn-reset">Clear</button>
</form>
<style>
.contact-form {
max-width: 500px;
margin: 20px auto;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
}
.form-group {
margin-bottom: 16px;
}
label {
display: block;
margin-bottom: 4px;
font-weight: 500;
}
input, textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
font-family: inherit;
}
input:invalid {
border-color: #dc3545;
}
button {
padding: 10px 20px;
margin-right: 8px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-submit {
background: #28a745;
color: white;
}
.btn-reset {
background: #6c757d;
color: white;
}
</style>
Interactive code playground requires JavaScript. Here's the code:
<!-- HTML form (same as above) -->
<form id="contact-form" method="post" action="/contact" class="contact-form">
<h2>Contact Us</h2>
<div class="form-group">
<label for="name">Name:</label>
<input type="text" id="name" name="name" required>
<span class="error" id="name-error"></span>
</div>
<div class="form-group">
<label for="email">Email:</label>
<input type="email" id="email" name="email" required>
<span class="error" id="email-error"></span>
</div>
<button type="submit" class="btn-submit">Send</button>
<div id="success-message" class="success" style="display: none;">
Message sent successfully!
</div>
</form>
<style>
.contact-form {
max-width: 500px;
margin: 20px auto;
}
.error {
color: #dc3545;
font-size: 12px;
display: block;
margin-top: 4px;
}
.success {
color: #28a745;
padding: 12px;
background: #d4edda;
border-radius: 4px;
margin-top: 16px;
}
</style>
<!-- JavaScript enhancement -->
<script>
const form = document.getElementById('contact-form');
// Override form submission to send via AJAX
form.addEventListener('submit', async (e) => {
e.preventDefault();
// Validate on client
const formData = new FormData(form);
const email = formData.get('email');
const name = formData.get('name');
// Show errors
const emailError = document.getElementById('email-error');
const nameError = document.getElementById('name-error');
emailError.textContent = '';
nameError.textContent = '';
let hasError = false;
if (name.length < 2) {
nameError.textContent = 'Name must be at least 2 characters';
hasError = true;
}
if (!email.includes('@')) {
emailError.textContent = 'Please enter a valid email';
hasError = true;
}
if (hasError) return;
// Send via AJAX
try {
const response = await fetch('/contact', {
method: 'POST',
body: formData
});
if (response.ok) {
document.getElementById('success-message').style.display = 'block';
form.reset();
}
} catch (error) {
// If JavaScript fails, form submission still works
alert('Error sending message');
}
});
</script>
Provide alternatives for JavaScript-disabled scenarios.
Interactive code playground requires JavaScript. Here's the code:
<!-- Default JavaScript experience -->
<div id="js-only" style="display: none;">
<h2>Advanced Features</h2>
<button onclick="advancedFilter()">Filter Results</button>
</div>
<!-- Fallback without JavaScript -->
<noscript>
<div class="no-js-warning">
<p>JavaScript is disabled. Some features may not work. Here's the
<a href="/results">basic results page</a>.</p>
</div>
</noscript>
<style>
.no-js-warning {
background: #fff3cd;
border: 1px solid #ffc107;
padding: 12px;
border-radius: 4px;
margin-bottom: 16px;
}
</style>
<script>
// Show JavaScript-dependent features
document.getElementById('js-only').style.display = 'block';
function advancedFilter() {
alert('Filter applied!');
}
</script>
Check for features before using them.
Interactive code playground requires JavaScript. Here's the code:
<!-- Detect feature availability -->
<div id="app">
<p>Loading...</p>
</div>
<script>
// Feature detection pattern
const features = {
hasLocalStorage: typeof localStorage !== 'undefined',
hasFetch: typeof fetch !== 'undefined',
hasIntersectionObserver: 'IntersectionObserver' in window,
hasCryptoAPI: 'crypto' in window
};
console.log('Available features:', features);
// Use feature detection to decide behavior
if (features.hasLocalStorage) {
// Safe to use localStorage
localStorage.setItem('preference', 'dark');
} else {
// Fallback to cookies or server storage
console.log('localStorage not available, use alternative');
}
if (features.hasFetch) {
// Use modern fetch API
fetch('/api/data').then(r => r.json());
} else {
// Fall back to XMLHttpRequest
console.log('fetch not available, use XMLHttpRequest');
}
</script>
Both improve user experience, but with different approaches.
Interactive code playground requires JavaScript. Here's the code:
<!-- Start with advanced features -->
<input
type="text"
id="search"
list="suggestions"
autocomplete="off">
<datalist id="suggestions">
<option value="HTML">
<option value="CSS">
<option value="JavaScript">
</datalist>
<!-- Works without datalist support, input still functional -->
<script>
// Works on modern browsers
if ('datalist' in document) {
console.log('Datalist supported, using native implementation');
} else {
console.log('Datalist not supported, using fallback');
// Implement custom suggestion list
}
</script>
Interactive code playground requires JavaScript. Here's the code:
<!-- Start with simple HTML -->
<div id="gallery">
<ul>
<li><a href="image1.jpg">Image 1</a></li>
<li><a href="image2.jpg">Image 2</a></li>
<li><a href="image3.jpg">Image 3</a></li>
</ul>
</div>
<!-- CSS enhances appearance -->
<style>
#gallery ul {
list-style: none;
padding: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
#gallery a {
display: block;
aspect-ratio: 1;
overflow: hidden;
border-radius: 8px;
}
#gallery img {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
<!-- JavaScript progressively enhances with lightbox -->
<script>
const gallery = document.getElementById('gallery');
const links = gallery.querySelectorAll('a');
// Add images as they load
links.forEach(link => {
const img = document.createElement('img');
img.src = link.href;
img.alt = link.textContent;
link.appendChild(img);
// Add lightbox functionality
link.addEventListener('click', (e) => {
e.preventDefault();
// Show lightbox...
alert('Lightbox: ' + link.href);
});
});
</script>
Interactive code playground requires JavaScript. Here's the code:
<!-- HTML: Default pagination works everywhere -->
<div id="articles">
<article>Article 1</article>
<article>Article 2</article>
<article>Article 3</article>
</div>
<nav id="pagination" class="pagination">
<a href="?page=2" rel="next">Next Page</a>
</nav>
<style>
#articles {
display: grid;
gap: 16px;
margin-bottom: 20px;
}
article {
padding: 16px;
border: 1px solid #ddd;
border-radius: 8px;
background: #fafafa;
}
.pagination {
text-align: center;
}
.pagination a {
padding: 8px 16px;
background: #007bff;
color: white;
text-decoration: none;
border-radius: 4px;
}
</style>
<!-- JavaScript: Enhancement with infinite scroll -->
<script>
const pagination = document.getElementById('pagination');
const articles = document.getElementById('articles');
const nextLink = pagination.querySelector('a[rel="next"]');
if (nextLink && 'IntersectionObserver' in window) {
// Use infinite scroll if available
let isLoading = false;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !isLoading) {
isLoading = true;
loadMore();
}
});
});
observer.observe(nextLink);
async function loadMore() {
try {
const response = await fetch(nextLink.href);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Add articles from next page
const newArticles = doc.querySelectorAll('article');
newArticles.forEach(article => {
articles.appendChild(article.cloneNode(true));
});
// Update next link
const newNextLink = doc.querySelector('a[rel="next"]');
if (newNextLink) {
nextLink.href = newNextLink.href;
} else {
pagination.remove();
}
isLoading = false;
} catch (error) {
isLoading = false;
console.error('Failed to load more articles');
}
}
}
// If IntersectionObserver not available, pagination link still works
</script>
Interactive code playground requires JavaScript. Here's the code:
<!-- HTML: Built-in validation works everywhere -->
<form id="signup" method="post" action="/signup">
<div>
<label for="email">Email:</label>
<input
type="email"
id="email"
name="email"
required
pattern="[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$">
<span class="error"></span>
</div>
<div>
<label for="password">Password:</label>
<input
type="password"
id="password"
name="password"
required
minlength="8">
<span class="strength"></span>
</div>
<button type="submit">Sign Up</button>
</form>
<style>
.error { color: red; font-size: 12px; }
.strength { font-size: 12px; margin-top: 4px; display: block; }
</style>
<script>
const form = document.getElementById('signup');
const emailInput = form.querySelector('#email');
const passwordInput = form.querySelector('#password');
// Real-time validation feedback
emailInput.addEventListener('input', (e) => {
const error = emailInput.nextElementSibling;
if (emailInput.validity.typeMismatch) {
error.textContent = 'Invalid email format';
} else {
error.textContent = '';
}
});
// Password strength indicator
passwordInput.addEventListener('input', (e) => {
const strength = passwordInput.nextElementSibling;
const value = e.target.value;
let level = 'Weak';
if (value.length >= 8 && /[A-Z]/.test(value) && /[0-9]/.test(value)) {
level = 'Strong';
} else if (value.length >= 8) {
level = 'Medium';
}
strength.textContent = 'Strength: ' + level;
});
</script>
When building features:
Progressive enhancement builds in layers: HTML → CSS → JavaScript
Content first : Use semantic HTML for all core functionality
CSS enhances : Styling improves appearance without breaking functionality
JavaScript improves : Adds interactivity and better UX
Feature detection : Check before using APIs
Forms work without JavaScript : Use HTML validation and server-side processing
@supports queries : Use newer CSS features safely
noscript fallbacks : Provide alternatives for JavaScript-disabled users
Test without JavaScript : Use DevTools to disable JS and verify functionality
Back to Accessibility
Make your progressive enhancements accessible to everyone.
Continue →
Explore Resources
Check out recommended tools and specifications.
Continue →