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'