Frontend

Progressive Enhancement for Web Applications

A practical guide to progressive enhancement covering semantic HTML foundations, CSS layers, JavaScript behavior, feature detection, and building resilient web applications.

Progressive Enhancement for Web Applications

Overview

Progressive enhancement is not a technique. It is a philosophy — one that treats the web as an inherently unpredictable platform and builds accordingly. The core idea is straightforward: start with content and functionality that works everywhere, then layer on enhancements for browsers and devices that support them.

I have watched teams spend weeks building elaborate single-page applications that collapse entirely when a CDN goes down, a corporate proxy strips JavaScript, or a user visits on a device nobody tested. Progressive enhancement eliminates that entire class of failure. Your application works at every layer, and each layer makes it better.

This article covers the full stack of progressive enhancement: semantic HTML as the foundation, CSS as the presentation layer, JavaScript as the behavior layer, and the patterns that tie them together. We will build a real contact form that works without any JavaScript at all, then enhance it into a polished, modern experience.

Prerequisites

  • Solid understanding of HTML, CSS, and JavaScript fundamentals
  • Familiarity with server-side form handling (any backend language)
  • Basic knowledge of HTTP methods (GET, POST)
  • Experience with DOM manipulation

Progressive Enhancement vs. Graceful Degradation

These two terms get used interchangeably, but they represent opposite approaches.

Graceful degradation starts with the full experience and tries to handle failure. You build for the best-case scenario, then add fallbacks when things break. The problem is that you rarely catch every failure mode.

Progressive enhancement starts with the baseline and adds capabilities. You build for the worst-case scenario first, then improve the experience when features are available.

<!-- Graceful Degradation: Assumes JS, falls back when missing -->
<button onclick="submitForm()">Submit</button>
<noscript>Please enable JavaScript to use this form.</noscript>

<!-- Progressive Enhancement: Works without JS, enhanced with it -->
<form method="POST" action="/contact">
  <button type="submit">Submit</button>
</form>
<!-- JS intercepts the submit event and enhances behavior -->

The graceful degradation approach puts the burden on the developer to anticipate every failure. Progressive enhancement puts the burden on the browser to declare its capabilities. That is a fundamentally more reliable architecture.

Building on a Semantic HTML Foundation

HTML is the only layer that is guaranteed to work. A user might have CSS disabled, JavaScript blocked, or be using a screen reader that ignores visual presentation entirely. Your HTML must convey meaning and function on its own.

<!-- Poor foundation: divs with no meaning -->
<div class="nav">
  <div class="nav-item" onclick="navigate('/about')">About</div>
  <div class="nav-item" onclick="navigate('/contact')">Contact</div>
</div>

<!-- Strong foundation: semantic elements -->
<nav aria-label="Main navigation">
  <ul>
    <li><a href="/about">About</a></li>
    <li><a href="/contact">Contact</a></li>
  </ul>
</nav>

The semantic version works without CSS (the links are still navigable), without JavaScript (the links still function), and with screen readers (the nav landmark and list structure communicate hierarchy). The div-based version is dead without JavaScript.

Key semantic elements to use as your foundation:

  • <header>, <nav>, <main>, <footer> for page structure
  • <article>, <section>, <aside> for content grouping
  • <h1> through <h6> for document outline
  • <form>, <fieldset>, <legend>, <label> for forms
  • <table>, <thead>, <tbody>, <caption> for tabular data
  • <details>, <summary> for expandable content (no JS needed)
<!-- Accordion without JavaScript -->
<details>
  <summary>How does progressive enhancement work?</summary>
  <p>You start with HTML that delivers core content and functionality,
     then add CSS for presentation and JavaScript for behavior.</p>
</details>

That accordion works in every modern browser with zero JavaScript. You can then enhance it with smooth animations using CSS transitions or JavaScript.

CSS as the Enhancement Layer

CSS is your first enhancement layer. Use it for visual presentation, layout, and basic interactivity — but never for content delivery.

/* Base: content is readable without any CSS */
/* Enhancement: improve the experience with CSS */

/* Feature query: only apply grid if supported */
.article-grid {
  display: block; /* fallback */
}

@supports (display: grid) {
  .article-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
    gap: 1.5rem;
  }
}

The @supports rule is CSS-level feature detection. Older browsers that do not understand grid will render a simple stacked layout. Modern browsers get the enhanced grid.

CSS custom properties (variables) follow the same pattern:

/* Fallback for browsers without custom property support */
.button {
  background-color: #2563eb;
  background-color: var(--primary-color, #2563eb);
}

CSS-Only Interactive Enhancements

Many interactions that developers reach for JavaScript to build can be handled with CSS alone:

/* Hover states that gracefully do nothing on touch devices */
.card {
  transition: transform 0.2s ease;
}

.card:hover {
  transform: translateY(-2px);
}

/* Focus-visible: only show focus rings for keyboard users */
.button:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 2px;
}

/* Dark mode as enhancement */
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #1a1a2e;
    --text: #e0e0e0;
  }
}

/* Reduced motion as respectful enhancement */
@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

JavaScript as the Behavior Layer

JavaScript is the top layer. It should enhance existing functionality, never replace it. Every piece of JavaScript you write should answer the question: "What happens when this does not run?"

Feature Detection with Native APIs

Modernizr was the standard for years, but native feature detection has largely replaced it:

// Native feature detection
var supportsIntersectionObserver = 'IntersectionObserver' in window;
var supportsServiceWorker = 'serviceWorker' in navigator;
var supportsFetch = 'fetch' in window;
var supportsCustomElements = 'customElements' in window;

// Detect input types
function supportsInputType(type) {
  var input = document.createElement('input');
  input.setAttribute('type', type);
  return input.type === type;
}

var supportsDateInput = supportsInputType('date');
var supportsColorInput = supportsInputType('color');

// Detect CSS support from JavaScript
var supportsGrid = CSS.supports && CSS.supports('display', 'grid');

If you still use Modernizr, use the custom build tool to include only the detections you need. The full library is unnecessary overhead:

<!-- Only include what you test for -->
<script src="/js/modernizr-custom.js"></script>
<script>
if (Modernizr.flexbox && Modernizr.cssgrid) {
  document.documentElement.classList.add('enhanced-layout');
}
</script>

The "Cutting the Mustard" Pattern

The BBC popularized this pattern: test for a baseline set of features, and only load enhanced JavaScript for browsers that pass. Everything else gets the core HTML/CSS experience.

// Define your baseline
function cutsTheMustard() {
  return (
    'querySelector' in document &&
    'addEventListener' in window &&
    'classList' in document.documentElement &&
    'fetch' in window
  );
}

if (cutsTheMustard()) {
  // Load enhanced JavaScript
  var script = document.createElement('script');
  script.src = '/js/enhanced-app.js';
  script.async = true;
  document.head.appendChild(script);

  document.documentElement.classList.add('js-enhanced');
} else {
  // Basic experience — HTML and CSS handle everything
  document.documentElement.classList.add('js-basic');
}

This pattern is powerful because it is binary. You either get the full enhanced experience or you get the baseline. No half-broken intermediate states.

Progressive Form Enhancement

Forms are the clearest example of progressive enhancement. HTML forms have worked since the early 1990s. They submit data to a server without any JavaScript. Start there.

<form id="contactForm" method="POST" action="/api/contact">
  <fieldset>
    <legend>Contact Us</legend>

    <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"
             autocomplete="name">
    </div>

    <div class="form-group">
      <label for="email">Email <span aria-label="required">*</span></label>
      <input type="email" id="email" name="email" required
             autocomplete="email">
    </div>

    <div class="form-group">
      <label for="message">Message <span aria-label="required">*</span></label>
      <textarea id="message" name="message" required
                minlength="10" maxlength="2000"
                rows="5"></textarea>
    </div>

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

This form works without JavaScript. The required, type="email", minlength, and maxlength attributes provide native browser validation. The form submits via POST. The server processes it and returns a response page.

Image Loading Strategies

Images are heavy, and progressive enhancement gives you tools to load them efficiently:

<!-- Basic: works everywhere -->
<img src="photo-800.jpg" alt="Mountain landscape at sunset">

<!-- Enhanced: responsive images -->
<img src="photo-800.jpg"
     srcset="photo-400.jpg 400w,
             photo-800.jpg 800w,
             photo-1200.jpg 1200w"
     sizes="(max-width: 600px) 100vw,
            (max-width: 1200px) 50vw,
            33vw"
     alt="Mountain landscape at sunset"
     loading="lazy"
     decoding="async">

<!-- Art direction with picture element -->
<picture>
  <source media="(min-width: 1024px)" srcset="hero-wide.webp" type="image/webp">
  <source media="(min-width: 1024px)" srcset="hero-wide.jpg">
  <source srcset="hero-square.webp" type="image/webp">
  <img src="hero-square.jpg" alt="Hero image">
</picture>

The loading="lazy" attribute is native lazy loading — no JavaScript library required. For browsers that do not support it, images load normally, which is the correct fallback.

For JavaScript-enhanced lazy loading with more control:

function initLazyLoading() {
  if (!('IntersectionObserver' in window)) {
    // Fallback: load all images immediately
    var images = document.querySelectorAll('img[data-src]');
    for (var i = 0; i < images.length; i++) {
      images[i].src = images[i].getAttribute('data-src');
    }
    return;
  }

  var observer = new IntersectionObserver(function(entries) {
    for (var j = 0; j < entries.length; j++) {
      if (entries[j].isIntersecting) {
        var img = entries[j].target;
        img.src = img.getAttribute('data-src');
        img.removeAttribute('data-src');
        observer.unobserve(img);
      }
    }
  }, { rootMargin: '200px' });

  var lazyImages = document.querySelectorAll('img[data-src]');
  for (var k = 0; k < lazyImages.length; k++) {
    observer.observe(lazyImages[k]);
  }
}

Progressive Navigation

Server-rendered pages with standard links are the baseline. You can enhance navigation to feel more app-like without breaking the fallback:

function enhanceNavigation() {
  if (!('fetch' in window) || !('pushState' in history)) {
    return; // Let standard navigation handle it
  }

  var mainContent = document.getElementById('main-content');
  var navLinks = document.querySelectorAll('a[data-enhance-nav]');

  for (var i = 0; i < navLinks.length; i++) {
    navLinks[i].addEventListener('click', function(e) {
      var url = this.href;

      // Let modifier keys work normally (open in new tab, etc.)
      if (e.metaKey || e.ctrlKey || e.shiftKey) return;

      e.preventDefault();
      mainContent.classList.add('loading');

      fetch(url, { headers: { 'X-Requested-With': 'fetch' } })
        .then(function(response) { return response.text(); })
        .then(function(html) {
          var parser = new DOMParser();
          var doc = parser.parseFromString(html, 'text/html');
          var newContent = doc.getElementById('main-content');
          mainContent.innerHTML = newContent.innerHTML;
          mainContent.classList.remove('loading');
          history.pushState({}, doc.title, url);
          document.title = doc.title;
        })
        .catch(function() {
          // Enhancement failed — do a normal navigation
          window.location.href = url;
        });
    });
  }
}

The key detail here is the .catch() handler. If the fetch fails for any reason, we fall back to standard navigation. The link always works.

Touch and Pointer Events as Enhancement

Touch interactions are an enhancement over click events, not a replacement:

function enhanceInteractions(element) {
  // Pointer Events API unifies mouse, touch, and pen
  if ('PointerEvent' in window) {
    element.addEventListener('pointerdown', handleStart);
    element.addEventListener('pointermove', handleMove);
    element.addEventListener('pointerup', handleEnd);
  } else if ('ontouchstart' in window) {
    element.addEventListener('touchstart', handleStart);
    element.addEventListener('touchmove', handleMove);
    element.addEventListener('touchend', handleEnd);
  }
  // Mouse click always works as baseline
  element.addEventListener('click', handleClick);
}

Web Fonts as Enhancement (FOIT/FOUT)

Web fonts are a visual enhancement. Your content must be readable without them:

/* System font stack as baseline */
body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
               Roboto, Oxygen, Ubuntu, sans-serif;
}

/* Enhanced: swap in custom font when loaded */
.fonts-loaded body {
  font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
}
// Use Font Loading API to control the swap
if ('fonts' in document) {
  var interFont = new FontFace('Inter',
    'url(/fonts/inter-var.woff2) format("woff2")'
  );

  interFont.load().then(function(loaded) {
    document.fonts.add(loaded);
    document.documentElement.classList.add('fonts-loaded');
  }).catch(function() {
    // Font failed to load — system fonts are fine
  });
}

Use font-display: swap in your @font-face rules to prevent FOIT (Flash of Invisible Text). FOUT (Flash of Unstyled Text) is acceptable — invisible text is not.

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-display: swap;
}

API Availability Checks

When your application depends on browser APIs, check for them before use:

// Geolocation as enhancement
function enhanceWithLocation(callback) {
  if (!('geolocation' in navigator)) {
    // Show manual location input instead
    showManualLocationField();
    return;
  }

  navigator.geolocation.getCurrentPosition(
    function(position) {
      callback(position.coords.latitude, position.coords.longitude);
    },
    function() {
      // Permission denied or error — fall back gracefully
      showManualLocationField();
    }
  );
}

// Service Worker as enhancement
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js').catch(function(err) {
    // Offline support is just an enhancement
    console.log('SW registration failed:', err);
  });
}

// Web Share API as enhancement
function setupShareButton(button, shareData) {
  if (navigator.share) {
    button.style.display = 'inline-block';
    button.addEventListener('click', function() {
      navigator.share(shareData).catch(function() {});
    });
  }
  // No share API? Button stays hidden. Copy link is the fallback.
}

Progressive Enhancement for Performance

Performance itself is an enhancement. Serve a fast baseline, then optimize:

<!-- Preload critical resources -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font"
      type="font/woff2" crossorigin>

<!-- Prefetch likely next pages -->
<link rel="prefetch" href="/articles">

<!-- Module/nomodule pattern for JS -->
<script type="module" src="/js/modern.js"></script>
<script nomodule src="/js/legacy.js"></script>

The module/nomodule pattern is an elegant cut: modern browsers load the smaller, modern bundle. Legacy browsers load the transpiled version. Both work.

Enhancing Tables for Mobile

Tables are notoriously difficult on small screens. Progressive enhancement helps:

<div class="table-wrapper">
  <table>
    <caption>Q4 Sales Results</caption>
    <thead>
      <tr>
        <th>Region</th>
        <th>Revenue</th>
        <th>Growth</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td data-label="Region">North America</td>
        <td data-label="Revenue">$2.4M</td>
        <td data-label="Growth">+12%</td>
      </tr>
    </tbody>
  </table>
</div>
/* Base: scrollable table */
.table-wrapper {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

/* Enhancement: stacked layout on mobile */
@media (max-width: 600px) {
  table thead { display: none; }

  table tr {
    display: block;
    margin-bottom: 1rem;
    border: 1px solid #ddd;
    border-radius: 4px;
  }

  table td {
    display: flex;
    justify-content: space-between;
    padding: 0.5rem;
    border-bottom: 1px solid #eee;
  }

  table td::before {
    content: attr(data-label);
    font-weight: 600;
  }
}

Real-World Examples of PE in Production

gov.uk is the gold standard. Every page works without JavaScript. Forms submit, navigation functions, content is accessible. JavaScript adds autocomplete, live validation, and smoother interactions.

GitHub renders full pages server-side. Their comment forms work without JavaScript (standard POST). JS enhances with inline editing, reactions, and real-time updates.

Basecamp / HEY built their entire email product using this approach. Server-rendered HTML enhanced with Turbo (formerly Turbolinks) for faster navigation. Forms work without JS. Progressive enhancement is their entire architecture.

Complete Working Example: Enhanced Contact Form

Here is a contact form that works in three layers. The HTML form submits via POST to the server. CSS makes it look polished. JavaScript adds client-side validation, AJAX submission, loading states, and animations.

Layer 1: HTML (The Foundation)

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Contact Us</title>
  <link rel="stylesheet" href="/css/contact.css">
</head>
<body>
  <main>
    <h1>Contact Us</h1>

    <form id="contactForm" method="POST" action="/api/contact" novalidate>
      <fieldset>
        <legend>Send us a message</legend>

        <div class="form-group">
          <label for="name">Full Name <span class="required">*</span></label>
          <input type="text" id="name" name="name"
                 required minlength="2" maxlength="100"
                 autocomplete="name"
                 aria-describedby="name-error">
          <span id="name-error" class="error-message" role="alert"></span>
        </div>

        <div class="form-group">
          <label for="email">Email Address <span class="required">*</span></label>
          <input type="email" id="email" name="email"
                 required autocomplete="email"
                 aria-describedby="email-error">
          <span id="email-error" class="error-message" role="alert"></span>
        </div>

        <div class="form-group">
          <label for="subject">Subject</label>
          <select id="subject" name="subject">
            <option value="">Choose a topic...</option>
            <option value="general">General Inquiry</option>
            <option value="support">Technical Support</option>
            <option value="feedback">Feedback</option>
            <option value="partnership">Partnership</option>
          </select>
        </div>

        <div class="form-group">
          <label for="message">Message <span class="required">*</span></label>
          <textarea id="message" name="message"
                    required minlength="10" maxlength="2000"
                    rows="6"
                    aria-describedby="message-error message-count"></textarea>
          <span id="message-error" class="error-message" role="alert"></span>
          <span id="message-count" class="char-count" aria-live="polite"></span>
        </div>

        <div class="form-actions">
          <button type="submit" id="submitBtn">Send Message</button>
        </div>
      </fieldset>
    </form>

    <div id="successMessage" class="success-message" hidden>
      <h2>Message Sent</h2>
      <p>Thank you for reaching out. We will get back to you within 24 hours.</p>
      <a href="/contact">Send another message</a>
    </div>
  </main>

  <script src="/js/contact-enhance.js"></script>
</body>
</html>

Layer 2: CSS (Presentation Enhancement)

/* Base styles */
* { box-sizing: border-box; margin: 0; padding: 0; }

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  line-height: 1.6;
  color: #1a1a2e;
  background: #f8f9fa;
}

main {
  max-width: 640px;
  margin: 2rem auto;
  padding: 0 1rem;
}

fieldset {
  border: none;
  padding: 0;
}

legend {
  font-size: 1.25rem;
  font-weight: 600;
  margin-bottom: 1.5rem;
}

.form-group {
  margin-bottom: 1.25rem;
}

label {
  display: block;
  font-weight: 500;
  margin-bottom: 0.375rem;
}

.required { color: #dc2626; }

input, textarea, select {
  width: 100%;
  padding: 0.625rem 0.75rem;
  border: 2px solid #d1d5db;
  border-radius: 6px;
  font-size: 1rem;
  font-family: inherit;
  transition: border-color 0.15s ease;
}

input:focus, textarea:focus, select:focus {
  outline: none;
  border-color: #2563eb;
  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}

/* Validation states — enhanced with JS class toggling */
.form-group.has-error input,
.form-group.has-error textarea {
  border-color: #dc2626;
}

.form-group.has-success input,
.form-group.has-success textarea {
  border-color: #16a34a;
}

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

.form-group.has-error .error-message {
  display: block;
}

.char-count {
  display: block;
  text-align: right;
  font-size: 0.8rem;
  color: #6b7280;
  margin-top: 0.25rem;
}

button[type="submit"] {
  background: #2563eb;
  color: white;
  border: none;
  padding: 0.75rem 2rem;
  font-size: 1rem;
  font-weight: 600;
  border-radius: 6px;
  cursor: pointer;
  transition: background-color 0.15s ease, transform 0.1s ease;
  position: relative;
}

button[type="submit"]:hover { background: #1d4ed8; }
button[type="submit"]:active { transform: scale(0.98); }

/* Loading state */
button[type="submit"].loading {
  color: transparent;
  pointer-events: none;
}

button[type="submit"].loading::after {
  content: '';
  position: absolute;
  width: 20px;
  height: 20px;
  top: 50%;
  left: 50%;
  margin: -10px 0 0 -10px;
  border: 3px solid rgba(255,255,255,0.3);
  border-top-color: white;
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

/* Success message */
.success-message {
  text-align: center;
  padding: 2rem;
  background: #f0fdf4;
  border: 2px solid #16a34a;
  border-radius: 8px;
}

.success-message h2 {
  color: #16a34a;
  margin-bottom: 0.5rem;
}

/* Success animation */
.success-message.animate {
  animation: fadeInUp 0.4s ease;
}

@keyframes fadeInUp {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

Layer 3: JavaScript (Behavior Enhancement)

(function() {
  'use strict';

  // Feature detection — bail out if basics are missing
  if (!('querySelector' in document) ||
      !('addEventListener' in window) ||
      !('classList' in document.documentElement)) {
    return; // Form still works via standard POST
  }

  var form = document.getElementById('contactForm');
  var submitBtn = document.getElementById('submitBtn');
  var successMessage = document.getElementById('successMessage');

  if (!form) return;

  // Mark document as JS-enhanced
  document.documentElement.classList.add('js-enhanced');

  // --- Character Counter ---
  var messageField = document.getElementById('message');
  var charCount = document.getElementById('message-count');

  if (messageField && charCount) {
    var maxLength = parseInt(messageField.getAttribute('maxlength'), 10) || 2000;

    messageField.addEventListener('input', function() {
      var remaining = maxLength - this.value.length;
      charCount.textContent = remaining + ' characters remaining';

      if (remaining < 50) {
        charCount.style.color = '#dc2626';
      } else {
        charCount.style.color = '#6b7280';
      }
    });
  }

  // --- Validation ---
  var validators = {
    name: function(value) {
      if (!value.trim()) return 'Name is required.';
      if (value.trim().length < 2) return 'Name must be at least 2 characters.';
      return '';
    },
    email: function(value) {
      if (!value.trim()) return 'Email is required.';
      var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      if (!emailRegex.test(value)) return 'Please enter a valid email address.';
      return '';
    },
    message: function(value) {
      if (!value.trim()) return 'Message is required.';
      if (value.trim().length < 10) return 'Message must be at least 10 characters.';
      return '';
    }
  };

  function validateField(field) {
    var name = field.getAttribute('name');
    var validator = validators[name];
    if (!validator) return true;

    var error = validator(field.value);
    var group = field.closest('.form-group');
    var errorSpan = document.getElementById(name + '-error');

    if (error) {
      group.classList.add('has-error');
      group.classList.remove('has-success');
      if (errorSpan) errorSpan.textContent = error;
      return false;
    } else {
      group.classList.remove('has-error');
      group.classList.add('has-success');
      if (errorSpan) errorSpan.textContent = '';
      return true;
    }
  }

  // Real-time validation on blur
  var fields = form.querySelectorAll('input, textarea');
  for (var i = 0; i < fields.length; i++) {
    fields[i].addEventListener('blur', function() {
      validateField(this);
    });
  }

  // --- Form Submission ---
  form.addEventListener('submit', function(e) {
    // Validate all fields
    var allValid = true;
    var firstInvalid = null;

    for (var j = 0; j < fields.length; j++) {
      if (!validateField(fields[j])) {
        allValid = false;
        if (!firstInvalid) firstInvalid = fields[j];
      }
    }

    if (!allValid) {
      e.preventDefault();
      firstInvalid.focus();
      return;
    }

    // If fetch is not available, let the form submit normally
    if (!('fetch' in window)) return;

    // Prevent default and use AJAX
    e.preventDefault();
    submitBtn.classList.add('loading');
    submitBtn.disabled = true;

    var formData = new FormData(form);
    var data = {};
    formData.forEach(function(value, key) {
      data[key] = value;
    });

    fetch(form.action, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Requested-With': 'fetch'
      },
      body: JSON.stringify(data)
    })
    .then(function(response) {
      if (!response.ok) {
        throw new Error('Server error: ' + response.status);
      }
      return response.json();
    })
    .then(function() {
      // Show success message with animation
      form.hidden = true;
      successMessage.hidden = false;
      successMessage.classList.add('animate');
      successMessage.focus();
    })
    .catch(function(err) {
      submitBtn.classList.remove('loading');
      submitBtn.disabled = false;

      // Show error inline
      var errorDiv = document.createElement('div');
      errorDiv.className = 'form-error-banner';
      errorDiv.setAttribute('role', 'alert');
      errorDiv.textContent = 'Something went wrong. Please try again.';

      var existing = form.querySelector('.form-error-banner');
      if (existing) existing.remove();

      form.insertBefore(errorDiv, form.firstChild);
      errorDiv.focus();

      console.error('Form submission error:', err);
    });
  });
})();

This form works at every layer:

  1. HTML only: Form submits via POST, browser validates required fields and email format
  2. HTML + CSS: Validation states display visually, loading spinner exists in CSS
  3. HTML + CSS + JS: Real-time validation on blur, character counting, AJAX submission with loading state, success animation, and rich error display

If JavaScript fails to load, the user still contacts you. That is the entire point.

Common Issues and Troubleshooting

Problem: JavaScript-dependent elements flash before enhancement Apply a no-js class to <html>, remove it with JavaScript, and use CSS to hide enhancement-only elements until JS is confirmed available. Keep the duration of the flash minimal by placing the class-swap script in <head>.

Problem: AJAX form submission loses server-side error messages Design your API to return structured error objects. Parse them in JavaScript and map errors to the appropriate fields. The server should also handle rendering errors for the non-JS case by returning a full page with inline error messages.

Problem: Enhanced navigation breaks browser back button Always use history.pushState and listen for the popstate event. Restore page state from the URL, not from in-memory state. Test extensively — history management is a common source of bugs.

Problem: Feature detection gives false positives Some browsers report API support but have buggy implementations. For critical features, test actual behavior rather than just property existence. For example, test that fetch actually returns a Promise, not just that the property exists.

Problem: Third-party scripts assume JavaScript availability Wrap third-party integrations (analytics, chat widgets, A/B testing tools) in feature checks and load them asynchronously. Never let a third-party script block your core experience.

Best Practices

  1. Start without JavaScript. Build every feature as if JavaScript does not exist. Get it working with HTML and CSS. Only then add JavaScript to enhance the experience.

  2. Use semantic HTML elements. They provide built-in accessibility, keyboard navigation, and functionality. A <button> is always better than a <div onclick>.

  3. Feature-detect, never browser-detect. User agent strings lie. Feature detection tells you what the browser can actually do. Use 'fetch' in window, not navigator.userAgent.indexOf('Chrome').

  4. Make every enhancement fail silently. Wrap JavaScript enhancements in try/catch or conditional checks. An uncaught error in one enhancement should never break another.

  5. Test without JavaScript regularly. Disable JavaScript in your browser and navigate your application. Every critical path should still work: reading content, navigating, submitting forms, completing purchases.

  6. Treat performance as progressive enhancement. Serve the smallest viable payload first. Load heavier assets (fonts, images, scripts) progressively based on connection speed and device capability using the Network Information API when available.

  7. Document your enhancement layers. For each component, be explicit about what works at each layer. This helps the team understand which behaviors are baseline requirements and which are enhancements.

  8. Use the <noscript> element sparingly. It is a crutch. If you need <noscript>, you probably have a JavaScript dependency that should be eliminated. The exception is providing alternative content for truly JS-dependent features like interactive maps.

References

Powered by Contentful