Progressive Enhancement Checklist

Feature detection methods, fallback strategies, and copy-ready code for modern web APIs

Feature Detection Methods 6 items

Detection code
// Property-based feature detection const hasServiceWorker = 'serviceWorker' in navigator; const hasIntersectionObserver = 'IntersectionObserver' in window; const hasWebGL = (function() { try { return !!document.createElement('canvas').getContext('webgl2'); } catch(e) { return false; } })(); const hasWebAudio = 'AudioContext' in window || 'webkitAudioContext' in window;
Detection code
/* CSS @supports */ @supports (display: grid) { .layout { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); } } @supports not (display: grid) { .layout { display: flex; flex-wrap: wrap; } .layout > * { width: calc(50% - 1rem); margin: 0.5rem; } } // JavaScript CSS.supports() const hasGrid = CSS.supports('display', 'grid'); const hasSubgrid = CSS.supports('grid-template-columns', 'subgrid'); const hasContainerQueries = CSS.supports('container-type', 'inline-size'); const hasNesting = CSS.supports('selector(&)');
Detection code
// Try/catch detection for APIs that throw function hasWebShare() { try { return !!navigator.share; } catch(e) { return false; } } function hasNotifications() { try { return 'Notification' in window; } catch(e) { return false; } } function hasFullscreen() { try { return !!(document.documentElement.requestFullscreen); } catch(e) { return false; } } function hasWebBluetooth() { try { return !!navigator.bluetooth; } catch(e) { return false; } } // Safe API usage wrapper async function safeShare(data) { try { if (navigator.share) { await navigator.share(data); return true; } } catch(e) { /* user cancelled or not supported */ } // Fallback: copy to clipboard try { await navigator.clipboard.writeText(data.url); return true; } catch(e) { return false; } }
Anti-pattern vs correct pattern
// ❌ DON'T: UA sniffing if (navigator.userAgent.indexOf('Chrome') > -1) { /* ... */ } // ✅ DO: Feature detection if ('serviceWorker' in navigator) { /* ... */ } // ✅ DO: If you truly need client hints (Chromium 90+) if (navigator.userAgentData) { const brands = navigator.userAgentData.brands; const isChrome = brands.some(b => b.brand === 'Google Chrome'); } // ✅ DO: Feature detection for CSS if (CSS.supports('backdrop-filter', 'blur(10px)')) { element.style.backdropFilter = 'blur(10px)'; }
Detection code
// Flexbox gap detection (not detectable via @supports alone) function hasFlexGap() { const test = document.createElement('div'); test.style.display = 'flex'; test.style.gap = '1px'; test.style.position = 'absolute'; test.style.visibility = 'hidden'; test.innerHTML = '
'; document.body.appendChild(test); const hasGap = test.scrollHeight > 0; document.body.removeChild(test); return hasGap; } // Touch detection (combined check) const hasTouchSupport = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || (window.matchMedia('(pointer: coarse)').matches); // Passive event listener support let supportsPassive = false; try { const opts = Object.defineProperty({}, 'passive', { get() { supportsPassive = true; } }); window.addEventListener('test', null, opts); } catch(e) {}
Example
<!-- Base: works without JS/CSS --> <nav> <a href="/about">About</a> <a href="/contact">Contact</a> </nav> <!-- Enhanced: JS adds dynamic behavior --> <script> // Only enhance if DOM and History API available if ('fetch' in window && 'history' in window) { document.querySelectorAll('nav a').forEach(link => { link.addEventListener('click', async (e) => { e.preventDefault(); const html = await fetch(link.href).then(r => r.text()); // Update page without full reload history.pushState(null, '', link.href); }); }); } </script>

Fallback Strategies 8 items

Polyfills
Detection code
// Conditional polyfill loading async function loadPolyfills() { const polyfills = []; if (!('IntersectionObserver' in window)) { polyfills.push(import('intersection-observer')); } if (!('smoothscroll' in document.documentElement.style)) { polyfills.push(import('smoothscroll-polyfill')); } if (!window.fetch) { polyfills.push(import('whatwg-fetch')); } if (!('Promise' in window)) { polyfills.push(import('core-js/es/promise')); } if (polyfills.length) { await Promise.all(polyfills); } } // Init after polyfills loadPolyfills().then(() => { // App is safe to start initApp(); });
Detection code
<!-- Polyfill service (self-hosted preferred) --> <script src="/polyfills.js?features=IntersectionObserver,fetch,Promise"></script> // Self-hosted polyfill bundle builder // build-polyfills.js const features = [ 'IntersectionObserver', 'ResizeObserver', 'smoothscroll', 'fetch', 'Promise', 'URLSearchParams', ]; function buildPolyfillBundle() { const needed = features.filter(f => { // Generate per-feature detection switch(f) { case 'IntersectionObserver': return !('IntersectionObserver' in window); case 'ResizeObserver': return !('ResizeObserver' in window); case 'fetch': return !window.fetch; case 'Promise': return !('Promise' in window); default: return false; } }); return needed; }
Graceful Degradation
Detection code
/* Glass morphism with fallback */ .card { background: rgba(17, 24, 39, 0.95); /* solid fallback */ border: 1px solid #1f2937; border-radius: 8px; } @supports (backdrop-filter: blur(10px)) { .card { background: rgba(17, 24, 39, 0.7); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); } } /* Container queries with fallback */ .widget { width: 100%; } @supports (container-type: inline-size) { .widget { container-type: inline-size; } @container (min-width: 400px) { .widget-inner { display: grid; grid-template-columns: 1fr 1fr; } } }
Detection code
<!-- Form: works without JS --> <form action="/api/submit" method="POST" novalidate> <input type="email" name="email" required pattern="[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$" title="Enter a valid email address"> <button type="submit">Submit</button> </form> <script> // Enhance with JS validation (only if JS available) const form = document.querySelector('form'); if (form && 'reportValidity' in form) { form.addEventListener('submit', async (e) => { e.preventDefault(); if (!form.reportValidity()) return; const data = new FormData(form); try { const res = await fetch(form.action, { method: form.method, body: data, }); if (res.ok) showSuccess(); } catch(err) { showError('Network error — try again.'); } }); } </script>
Cut the Mustard
Detection code
// Cut the mustard — modern baseline const cutsTheMustard = ( 'querySelector' in document && 'addEventListener' in window && 'classList' in document.documentElement && 'Promise' in window && 'fetch' in window ); if (cutsTheMustard) { document.documentElement.classList.add('enhanced'); // Load enhanced JS bundle import('./app.js'); } else { document.documentElement.classList.add('basic'); // Core HTML/CSS only — already server-rendered } // CSS driven by mustard class // html.enanced .no-enhance { display: none; } // html.basic .enhance-only { display: none; }
Detection code
<!-- Core HTML for everyone --> <div class="content">...</div> <!-- Core styles for everyone --> <link rel="stylesheet" href="core.css"> <script> // Cut the mustard test if ('IntersectionObserver' in window && 'fetch' in window && 'Promise' in window) { // Load enhanced stylesheet const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = 'enhanced.css'; document.head.appendChild(link); // Load enhanced JS const script = document.createElement('script'); script.type = 'module'; script.src = 'enhanced.js'; document.body.appendChild(script); } </script>
Pattern
// Enhancement layers pattern const layers = { // Layer 1: HTML (always available) html: true, // Layer 2: CSS features css: CSS.supports('display', 'flex') && CSS.supports('display', 'grid'), // Layer 3: JS features js: 'Promise' in window && 'fetch' in window && 'IntersectionObserver' in window, // Layer 4: Advanced APIs api: 'serviceWorker' in navigator && 'Notification' in window, }; function initApp() { // Core content already rendered via HTML if (layers.css) { document.documentElement.classList.add('css-enhanced'); } if (layers.js) { // Lazy-load images, enable SPA navigation initLazyLoading(); initRouting(); } if (layers.api) { // Enable push notifications, offline support initServiceWorker(); initNotifications(); } }

Common Feature Checks 6 items

Detection + fallback code
// IntersectionObserver with fallback function initLazyLoading() { const images = document.querySelectorAll('img[data-src]'); if ('IntersectionObserver' in window) { 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); } }); }, { rootMargin: '200px' }); images.forEach(img => observer.observe(img)); } else { // Fallback: load all images immediately images.forEach(img => { img.src = img.dataset.src; img.removeAttribute('data-src'); }); } }
Detection + fallback code
/* CSS Grid with Flexbox fallback */ .grid-layout { display: flex; flex-wrap: wrap; gap: 1rem; } .grid-layout > * { flex: 1 1 300px; min-width: 0; } @supports (display: grid) { .grid-layout { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; } .grid-layout > * { flex: none; /* reset flex for grid items */ } } // JS detection const hasGrid = CSS.supports('display', 'grid'); const hasSubgrid = CSS.supports('grid-template-columns', 'subgrid'); const hasMasonry = CSS.supports('grid-template-rows', 'masonry');
Detection + fallback code
// Service Worker registration with fallback if ('serviceWorker' in navigator) { window.addEventListener('load', async () => { try { const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/', }); console.log('SW registered:', reg.scope); // Check for updates reg.addEventListener('updatefound', () => { const newWorker = reg.installing; newWorker.addEventListener('statechange', () => { if (newWorker.state === 'activated') { // New content available — optionally notify user } }); }); } catch(err) { console.warn('SW registration failed:', err); // Fallback: rely on HTTP cache headers // Ensure server sends proper Cache-Control headers } }); } // Background Sync detection const hasSync = 'SyncManager' in window; // Push API detection const hasPush = 'PushManager' in window;
Detection + fallback code
// Web Components detection const hasCustomElements = 'customElements' in window; const hasShadowDOM = !!HTMLElement.prototype.attachShadow; const hasTemplates = 'content' in document.createElement('template'); // Conditional polyfill loading if (!hasCustomElements || !hasShadowDOM) { // Load @webcomponents/webcomponentsjs polyfill const script = document.createElement('script'); script.src = 'https://unpkg.com/@webcomponents/webcomponentsjs/webcomponents-bundle.js'; script.onload = () => defineComponents(); document.head.appendChild(script); } else { defineComponents(); } function defineComponents() { customElements.define('my-widget', class extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({ mode: 'open' }); shadow.innerHTML = \` <style>:host { display: block; padding: 1rem; }</style> <slot></slot> \`; } }); } // Declarative Shadow DOM detection (SSR web components) const hasDeclarativeShadowDOM = HTMLTemplateElement.prototype.hasOwnProperty('shadowRoot');
Detection + fallback code
// View Transitions API with fallback async function navigateTo(url) { // Fetch new content const html = await fetch(url).then(r => r.text()); if (document.startViewTransition) { // Modern: animated transition const transition = document.startViewTransition(() => { updatePageContent(html); }); await transition.finished; } else { // Fallback: instant swap updatePageContent(html); } } // CSS for view transitions // ::view-transition-old(root) { // animation: fade-out 150ms ease-out; // } // ::view-transition-new(root) { // animation: fade-in 150ms ease-in; // } // Cross-document (MPA) view transitions (Chrome 121+) // Already works with CSS @supports: // @supports (view-transition-name: none) { // /* view transition styles */ // }
Detection + fallback code
// Clipboard API with execCommand fallback async function copyText(text) { // Modern Clipboard API if (navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text); return true; } catch(e) { /* permission denied — fall through */ } } // Legacy fallback const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.cssText = 'position:fixed;opacity:0'; document.body.appendChild(textarea); textarea.select(); try { document.execCommand('copy'); return true; } catch(e) { return false; } finally { document.body.removeChild(textarea); } } // File System Access API detection const hasFileSystemAccess = 'showOpenFilePicker' in window; const hasShowSavePicker = 'showSaveFilePicker' in window;

Browser Support Strategy 5 items

Example .browserslistrc
# .browserslistrc # Tier 1: Full support last 2 Chrome versions last 2 Firefox versions last 2 Safari versions last 2 Edge versions # Tier 2: Functional (build targets) > 0.5% and last 2 years not dead # Tier 3: Baseline (must render content) not IE 11
Config example
// babel.config.js module.exports = { presets: [ ['@babel/preset-env', { useBuiltIns: 'usage', corejs: 3, targets: '> 0.5%, last 2 versions, not dead', }], ], }; // postcss.config.js module.exports = { plugins: [ require('autoprefixer')({ grid: true }), require('postcss-preset-env')({ stage: 3, features: { 'nesting-rules': true, 'custom-media-queries': true, }, }), ], };
Detection code
<!-- Legacy browsers --> <script nomodule src="bundle.legacy.js"></script> <!-- Modern browsers (ES modules) --> <script type="module" src="bundle.modern.js"></script> <!-- Feature detection via module support --> <script> // If modules aren't supported, this won't run // Use for inline feature enhancement </script> <script type="module"> // Modern browsers only const supportsImportMaps = HTMLScriptElement.supports?.('importmap'); if (supportsImportMaps) { // Use import maps for dependency resolution } </script>

Testing Approach 5 items

Config example
// playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ projects: [ { name: 'chromium', use: { browserName: 'chromium' } }, { name: 'firefox', use: { browserName: 'firefox' } }, { name: 'webkit', use: { browserName: 'webkit' } }, ], testDir: './tests', }); // Test progressive enhancement specifically test('form submits without JS', async ({ page, browser }) => { // Test with JS disabled const context = await browser.newContext({ javaScriptEnabled: false }); const noJsPage = await context.newPage(); await noJsPage.goto('/form'); await noJsPage.fill('input[name="email"]', 'test@example.com'); await noJsPage.click('button[type="submit"]'); // Verify server-side processing works });
Test code
// Test fallback when IntersectionObserver is missing describe('Lazy loading fallback', () => { it('loads all images when IO unavailable', () => { // Remove IntersectionObserver const IO = window.IntersectionObserver; delete window.IntersectionObserver; // Set up test images document.body.innerHTML = ` <img data-src="a.jpg" alt="A"> <img data-src="b.jpg" alt="B"> `; initLazyLoading(); // All images should have src set immediately const imgs = document.querySelectorAll('img'); expect(imgs[0].src).toContain('a.jpg'); expect(imgs[1].src).toContain('b.jpg'); // Restore window.IntersectionObserver = IO; }); });
Config example
// .eslintrc.json { "extends": ["plugin:compat/recommended"], "settings": { "polyfills": ["IntersectionObserver", "fetch", "Promise"] } } // package.json browserslist { "browserslist": [ "> 0.5%", "last 2 versions", "not dead", "not IE 11" ] } // CI: add compat check step // npx eslint --ext .js,.ts src/ --rule 'compat/compat: error'