Progressive Enhancement
Building from a solid foundation: HTML first, then CSS, then JavaScript
Progressive Enhancement is the cornerstone methodology of BiModal Design. It's a development philosophy that starts with functional HTML, enhances it with CSS for presentation, and adds JavaScript for interactivity—in that exact order. Each layer enhances the experience without requiring the previous layer to work.
For BiModal Design, Progressive Enhancement isn't optional—it's essential. Most AI agents can't execute JavaScript, so if your site requires JS to function, it's invisible to them. By building with Progressive Enhancement, you create a site that works for everyone: humans with modern browsers, agents without JS, users on slow connections, and assistive technologies.
Why Progressive Enhancement Matters
For Agents
- • Most agents can't execute JavaScript
- • They see only server-rendered HTML
- • Client-side content is invisible
- • Forms must work without JS
For Humans
- • Works on slow connections
- • Resilient to JS failures
- • Faster initial page loads
- • Better accessibility
For Developers
- • Simpler debugging
- • Better testing surface
- • More maintainable code
- • Future-proof architecture
The Three Layers
Progressive Enhancement builds in three distinct layers, each independent of the next:
HTML Layer: Content & Structure
The foundation. All content must be accessible, all forms must submit, all navigation must work using only semantic HTML. This is what agents see.
Requirements:
- ✓ All content visible in HTML source
- ✓ Forms submit with standard POST/GET
- ✓ Navigation uses real
<a>tags - ✓ No content rendered only by JavaScript
- ✓ Server-side rendering (SSR) or static generation
<!-- This works without CSS or JavaScript -->
<form action="/subscribe" method="POST">
<label for="email">Email</label>
<input
type="email"
id="email"
name="email"
required
/>
<button type="submit">Subscribe</button>
</form>CSS Layer: Presentation
Visual design and layout. Makes the content beautiful for humans without changing functionality. If CSS fails to load, the site still works—it just doesn't look as good.
Enhancements:
- → Visual styling and branding
- → Layout and responsive design
- → Animations and transitions
- → Dark mode and themes
- → Print styles
/* Enhance the form visually */
form {
max-width: 400px;
padding: 2rem;
border-radius: 8px;
background: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
button {
background: #10b981;
color: white;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
button:hover {
background: #059669;
}JavaScript Layer: Interactivity
Enhanced user experience. Adds client-side validation, dynamic updates, smooth transitions, and rich interactions. The site works without it, but it's better with it—for users whose browsers support it.
Enhancements:
- → Instant client-side validation
- → AJAX form submission
- → Single-page app routing
- → Real-time updates
- → Interactive widgets
- → Optimistic UI updates
// Enhance the form with JavaScript
const form = document.querySelector('form');
const button = form.querySelector('button');
form.addEventListener('submit', async (e) => {
e.preventDefault(); // Intercept normal submission
// Client-side validation
const email = form.querySelector('input[type="email"]').value;
if (!email.includes('@')) {
alert('Please enter a valid email');
return;
}
// Show loading state
button.textContent = 'Subscribing...';
button.disabled = true;
// Submit via AJAX
try {
const response = await fetch('/subscribe', {
method: 'POST',
body: new FormData(form)
});
if (response.ok) {
form.innerHTML = '<p>✓ Subscribed successfully!</p>';
}
} catch (error) {
// Fallback to normal submission if JS fails
form.submit();
}
});The Key Principle
"Each layer enhances the experience without requiring the next layer to function."
HTML works without CSS. HTML + CSS work without JavaScript. If JavaScript fails to load or execute, users can still complete their tasks. If CSS fails, the site is ugly but functional. This resilience is what makes Progressive Enhancement powerful.
Real-World Progressive Enhancement Patterns
Pattern 1: Form Enhancement
<form action="/contact" method="POST">
<div>
<label for="name">Name</label>
<input
type="text"
id="name"
name="name"
required
aria-required="true"
/>
</div>
<div>
<label for="email">Email</label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
/>
<span id="email-error" role="alert" aria-live="polite"></span>
</div>
<div>
<label for="message">Message</label>
<textarea
id="message"
name="message"
required
aria-required="true"
></textarea>
</div>
<button type="submit">Send Message</button>
<!-- Server-side validation errors display here -->
<?php if (isset($errors)): ?>
<div role="alert">
<?php foreach ($errors as $error): ?>
<p><?php echo $error; ?></p>
<?php endforeach; ?>
</div>
<?php endif; ?>
</form>// Only runs if JavaScript is available
const form = document.querySelector('form');
const emailInput = form.querySelector('#email');
const emailError = document.querySelector('#email-error');
// Real-time validation
emailInput.addEventListener('blur', () => {
if (!emailInput.value.includes('@')) {
emailError.textContent = 'Please enter a valid email';
emailInput.setAttribute('aria-invalid', 'true');
} else {
emailError.textContent = '';
emailInput.setAttribute('aria-invalid', 'false');
}
});
// AJAX submission
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const button = form.querySelector('button');
button.textContent = 'Sending...';
button.disabled = true;
try {
const response = await fetch('/contact', {
method: 'POST',
body: formData
});
if (response.ok) {
form.innerHTML = '<p role="status">✓ Message sent!</p>';
} else {
throw new Error('Submission failed');
}
} catch (error) {
// Fallback to normal form submission
button.textContent = 'Send Message';
button.disabled = false;
form.submit();
}
});Pattern 2: Navigation Enhancement
<!-- Standard links that always work -->
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/products">Products</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>// Enhance with client-side routing (Next.js example)
import Link from 'next/link';
export function Navigation() {
return (
<nav aria-label="Main navigation">
<ul>
<li><Link href="/">Home</Link></li>
<li><Link href="/products">Products</Link></li>
<li><Link href="/about">About</Link></li>
<li><Link href="/contact">Contact</Link></li>
</ul>
</nav>
);
}
// Next.js Link components:
// - Render as <a> tags (work without JS)
// - Add client-side routing (faster with JS)
// - Prefetch pages on hover
// - Progressive enhancement built-in!Pattern 3: Content Loading
<!-- Content is in the HTML (visible to agents) -->
<section>
<h2>Latest Products</h2>
<div id="products">
<article>
<h3>Product 1</h3>
<p>$29.99</p>
</article>
<article>
<h3>Product 2</h3>
<p>$39.99</p>
</article>
<!-- More products -->
</div>
<a href="/products?page=2">Next Page</a>
</section>// Add infinite scroll for modern browsers
const productsContainer = document.querySelector('#products');
const nextLink = document.querySelector('a[href*="page=2"]');
if (nextLink && 'IntersectionObserver' in window) {
// Hide the next page link (we'll load automatically)
nextLink.style.display = 'none';
const observer = new IntersectionObserver(async (entries) => {
if (entries[0].isIntersecting) {
const response = await fetch(nextLink.href);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newProducts = doc.querySelector('#products').children;
Array.from(newProducts).forEach(product => {
productsContainer.appendChild(product);
});
// Update next link
const newNextLink = doc.querySelector('a[href*="page="]');
if (newNextLink) {
nextLink.href = newNextLink.href;
} else {
observer.disconnect();
}
}
});
// Observe the last product
observer.observe(productsContainer.lastElementChild);
}Pattern 4: Dropdown Menu
<!-- Works as a list of links without JS -->
<nav>
<h2>Products</h2>
<ul id="products-menu">
<li><a href="/products/electronics">Electronics</a></li>
<li><a href="/products/clothing">Clothing</a></li>
<li><a href="/products/books">Books</a></li>
</ul>
</nav>/* Hide by default, show on hover */
#products-menu {
display: none;
position: absolute;
background: white;
border: 1px solid #ccc;
}
nav:hover #products-menu,
nav:focus-within #products-menu {
display: block;
}
/* Works with keyboard navigation too! */// Add proper ARIA for screen readers
const nav = document.querySelector('nav');
const heading = nav.querySelector('h2');
const menu = document.querySelector('#products-menu');
// Convert heading to button
const button = document.createElement('button');
button.textContent = heading.textContent;
button.setAttribute('aria-expanded', 'false');
button.setAttribute('aria-controls', 'products-menu');
heading.replaceWith(button);
// Add menu role
menu.setAttribute('role', 'menu');
menu.hidden = true;
// Toggle on click
button.addEventListener('click', () => {
const isExpanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', !isExpanded);
menu.hidden = isExpanded;
});Testing Without JavaScript
To verify your Progressive Enhancement implementation, test with JavaScript disabled:
Browser Settings
Chrome/Edge:
- Open DevTools (F12)
- Open Command Palette (Ctrl/Cmd + Shift + P)
- Type "Disable JavaScript"
- Select the option
- Reload the page
Firefox:
- Type
about:configin address bar - Search for
javascript.enabled - Toggle to false
The Curl Test
Test like an agent sees your site:
curl -s https://yoursite.com | grep "form\|button\|nav"Verify that forms, buttons, and navigation appear in the HTML source.
Critical Questions
- ✓ Can I read all content?
- ✓ Can I navigate to all pages?
- ✓ Can I submit all forms?
- ✓ Do links work?
- ✓ Is the site usable?
If you answer "no" to any of these, your Progressive Enhancement needs work.
Automated Testing
// Playwright test with JS disabled
test('works without JavaScript', async ({ browser }) => {
const context = await browser.newContext({
javaScriptEnabled: false
});
const page = await context.newPage();
await page.goto('https://yoursite.com');
// Test core functionality
await expect(page.locator('form')).toBeVisible();
await page.fill('input[name="email"]', 'test@example.com');
await page.click('button[type="submit"]');
// Verify form submitted
await expect(page).toHaveURL(/success/);
});Framework Implementation
Modern frameworks can help or hinder Progressive Enhancement. Choose the right rendering strategy:
Next.js (Recommended)
// app/subscribe/page.tsx
export default function SubscribePage() {
async function subscribe(formData: FormData) {
'use server'; // Server Action
const email = formData.get('email');
// Process subscription server-side
await saveSubscription(email);
redirect('/success');
}
return (
<form action={subscribe}>
<input type="email" name="email" required />
<button type="submit">Subscribe</button>
</form>
);
}
// This form:
// ✓ Works without JavaScript (server action)
// ✓ Gets enhanced with JS (no page reload)
// ✓ Perfect Progressive Enhancement!Remix
// app/routes/subscribe.tsx
import { Form } from "@remix-run/react";
export async function action({ request }: ActionArgs) {
const formData = await request.formData();
const email = formData.get('email');
await saveSubscription(email);
return redirect('/success');
}
export default function Subscribe() {
return (
<Form method="post">
<input type="email" name="email" required />
<button type="submit">Subscribe</button>
</Form>
);
}
// Remix Form component:
// ✓ Regular form submission without JS
// ✓ AJAX submission with JS
// ✓ Built for Progressive EnhancementSvelteKit
<!-- src/routes/subscribe/+page.svelte -->
<script>
import { enhance } from '$app/forms';
</script>
<form method="POST" use:enhance>
<input type="email" name="email" required />
<button type="submit">Subscribe</button>
</form>
<!-- +page.server.ts -->
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
const email = data.get('email');
await saveSubscription(email);
return { success: true };
}
};
// use:enhance provides:
// ✓ Server-side form handling (no JS needed)
// ✓ Client-side enhancement (with JS)
// ✓ Progressive Enhancement patternCommon Progressive Enhancement Mistakes
1. JavaScript-Only Forms
❌ Wrong
<div onClick={handleSubmit}>
<input />
<span onClick={submit}>Submit</span>
</div>
// No action, no method
// Doesn't work without JS✅ Correct
<form action="/api/submit" method="POST">
<input name="data" />
<button type="submit">Submit</button>
</form>
// Works without JS
// Enhanced with JS2. Client-Only Content Rendering
❌ Wrong
// Empty HTML, content loads with JS
<div id="root"></div>
<script>
fetch('/api/data')
.then(r => r.json())
.then(data => render(data));
</script>
// Agents see nothing✅ Correct
// Server-rendered HTML
<div id="root">
<article>
<h1>Content Here</h1>
<p>Visible immediately</p>
</article>
</div>
// JS enhances, doesn't replace3. Fake Buttons and Links
❌ Wrong
<div onClick={navigate}>
Products
</div>
// Not a real link
// Doesn't work without JS✅ Correct
<a href="/products">
Products
</a>
// Real link
// Works everywhereProgressive Enhancement vs Graceful Degradation
Progressive Enhancement
Build the baseline first, then add enhancements on top.
Development order:
- 1. HTML (works for everyone)
- 2. Add CSS (prettier)
- 3. Add JS (interactive)
✓ Guaranteed baseline functionality
Graceful Degradation
Build the full experience, then add fallbacks for older browsers.
Development order:
- 1. Full JS app (modern only)
- 2. Add polyfills (compatibility)
- 3. Add fallbacks (maybe)
✗ Baseline often broken
For BiModal Design: Always use Progressive Enhancement. Graceful Degradation puts the enhanced version first, making it easy to break the baseline. Progressive Enhancement ensures the baseline always works.
Next: Performance Optimization
Progressive Enhancement sets you up for excellent performance. Learn how to optimize loading, rendering, and interactivity for both humans and agents.
Continue to Performance Optimization →