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:

1

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>
2

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;
}
3

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

HTML Foundation (works for everyone)
<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>
JavaScript Enhancement (progressive improvement)
// 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();
  }
});
HTML Foundation
<!-- 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>
JavaScript Enhancement (client-side routing)
// 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

Server-Side Rendered HTML
<!-- 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>
JavaScript Enhancement (infinite scroll)
// 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);
}
HTML Foundation (always accessible)
<!-- 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>
CSS Enhancement (visual dropdown)
/* 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! */
JavaScript Enhancement (ARIA states)
// 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:

  1. Open DevTools (F12)
  2. Open Command Palette (Ctrl/Cmd + Shift + P)
  3. Type "Disable JavaScript"
  4. Select the option
  5. Reload the page

Firefox:

  1. Type about:config in address bar
  2. Search for javascript.enabled
  3. 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 Enhancement

SvelteKit

<!-- 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 pattern

Common 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 JS

2. 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 replace

3. 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 everywhere

Progressive Enhancement vs Graceful Degradation

Progressive Enhancement

Build the baseline first, then add enhancements on top.

Development order:

  1. 1. HTML (works for everyone)
  2. 2. Add CSS (prettier)
  3. 3. Add JS (interactive)

✓ Guaranteed baseline functionality

Graceful Degradation

Build the full experience, then add fallbacks for older browsers.

Development order:

  1. 1. Full JS app (modern only)
  2. 2. Add polyfills (compatibility)
  3. 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 →