Cross-Browser Compatibility Strategies
A practical guide to cross-browser compatibility covering feature detection, polyfills, CSS vendor prefixes, testing strategies, and handling browser-specific quirks.
Cross-Browser Compatibility Strategies
Overview
Cross-browser compatibility is one of those problems that never fully goes away. The landscape has improved dramatically since the IE6 era, but if you think modern browsers all behave the same way, you have not shipped enough production code. Safari still has its own interpretation of specs. Firefox handles certain CSS properties differently than Chromium-based browsers. Mobile browsers introduce an entirely separate set of headaches.
This article covers practical strategies for building web applications that work reliably across Chrome, Firefox, Safari, and Edge. We will walk through feature detection, polyfills, CSS tooling, testing workflows, and a complete working example of a cross-browser component with proper fallbacks. No hand-waving — just the patterns and tools that actually hold up in production.
Prerequisites
- Intermediate knowledge of HTML, CSS, and JavaScript
- Familiarity with npm and build tools (webpack, Vite, or similar)
- Basic understanding of how browsers render pages
- A development machine with multiple browsers installed (or access to BrowserStack / Playwright)
The Modern Browser Landscape
The big four browsers in 2025-2026 are Chrome, Firefox, Safari, and Edge. Edge switched to the Chromium rendering engine in 2020, which means Chrome and Edge share the same core (Blink + V8). Firefox uses Gecko, and Safari uses WebKit.
This matters because bugs and rendering differences cluster along engine lines:
- Chromium (Chrome, Edge, Opera, Brave): Largest market share. Generally the first to implement new specs. Your site will likely work here first.
- Firefox (Gecko): Solid standards compliance. Occasionally interprets specs differently than Chromium, especially around flexbox edge cases and font rendering.
- Safari (WebKit): The most common source of cross-browser issues in modern development. WebKit tends to lag behind on newer APIs, and Safari on iOS is the only browser engine allowed on iPhones, so you cannot avoid it.
The practical takeaway: test in Chrome, then Safari, then Firefox. If something breaks, it will almost always be Safari.
Defining Browser Support Targets
Before writing a single line of compatibility code, define your support matrix. This is a business decision as much as a technical one. Check your analytics. If 2% of your traffic comes from IE11, that is a different conversation than if 15% does.
A sensible starting point for most projects in 2026:
- Chrome (last 2 versions)
- Firefox (last 2 versions)
- Safari 15.4+
- Edge (last 2 versions)
- iOS Safari 15.4+
- Chrome for Android (last 2 versions)
Encode this in a .browserslistrc file at your project root:
last 2 Chrome versions
last 2 Firefox versions
Safari >= 15.4
last 2 Edge versions
iOS >= 15.4
last 2 ChromeAndroid versions
This file is consumed by Autoprefixer, Babel, PostCSS, and other tools to determine what transformations are necessary. It is the single source of truth for your support targets.
Can I Use and MDN Compatibility Tables
Before reaching for a polyfill or workaround, check whether the feature you need is actually unsupported. Two resources are indispensable:
- caniuse.com: Search for any CSS property, JavaScript API, or HTML feature and see a grid of browser support. Green means supported, red means not, yellow means partial.
- MDN Web Docs: Every API reference page includes a browser compatibility table at the bottom. MDN also documents known quirks and partial implementations.
Make checking these a habit before you write workarounds. I have seen teams spend hours polyfilling features that every browser in their support matrix already handles natively.
CSS Vendor Prefixes and Autoprefixer
Vendor prefixes (-webkit-, -moz-, -ms-) were designed to let browsers ship experimental CSS features before specs stabilized. The problem is that developers had to write the same property three or four times:
.container {
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
Writing prefixes by hand is error-prone and tedious. Use Autoprefixer instead. It reads your .browserslistrc and automatically adds the necessary prefixes at build time.
Install it as a PostCSS plugin:
npm install autoprefixer postcss --save-dev
Add it to your PostCSS config:
// postcss.config.js
module.exports = {
plugins: [
require('autoprefixer')
]
};
Now you write standard CSS, and Autoprefixer handles the rest. This is not optional — it is table stakes for any production project.
CSS Feature Queries with @supports
The @supports rule lets you conditionally apply CSS based on whether the browser actually supports a property. This is the CSS equivalent of feature detection in JavaScript:
/* Base layout that works everywhere */
.grid-container {
display: block;
}
.grid-container > .item {
margin-bottom: 16px;
}
/* Enhanced layout for browsers that support grid */
@supports (display: grid) {
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.grid-container > .item {
margin-bottom: 0;
}
}
/* Backdrop filter with fallback */
.modal-overlay {
background: rgba(0, 0, 0, 0.7);
}
@supports (backdrop-filter: blur(10px)) {
.modal-overlay {
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
}
}
The key principle: always define a working baseline first, then layer enhancements with @supports. The page should be functional without the enhanced styles.
JavaScript Feature Detection vs User Agent Sniffing
User agent sniffing is checking navigator.userAgent to identify the browser and conditionally running code. It is brittle, unreliable, and a maintenance nightmare. Browsers lie in their user agent strings. Chrome on iOS identifies itself as Safari. Edge used to pretend to be Chrome pretending to be Safari pretending to be Mozilla.
Feature detection checks whether a specific API exists before using it:
// Bad: user agent sniffing
if (navigator.userAgent.indexOf('Safari') !== -1) {
// This matches Chrome too, because Chrome's UA contains "Safari"
}
// Good: feature detection
if ('IntersectionObserver' in window) {
var observer = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
loadImage(entry.target);
}
});
});
observer.observe(document.querySelector('.lazy-image'));
} else {
// Fallback: load all images immediately
loadAllImages();
}
For CSS feature detection in JavaScript, use CSS.supports():
if (window.CSS && CSS.supports('display', 'grid')) {
document.body.classList.add('grid-supported');
} else {
document.body.classList.add('no-grid');
}
The only legitimate use case for user agent sniffing is analytics. For functionality, always use feature detection.
Polyfills and When to Use Them
A polyfill is a piece of code that implements a missing browser API so your application code can use the API without checking for support everywhere. The most widely used polyfill library is core-js:
npm install core-js
Import only what you need:
// Polyfill specific features
require('core-js/stable/promise');
require('core-js/stable/array/from');
require('core-js/stable/object/assign');
Or import everything (larger bundle, but simpler):
require('core-js/stable');
When to use polyfills:
- The API is standardized but missing in browsers within your support matrix
- The fallback behavior is too complex to implement inline
- The polyfill is well-maintained and spec-compliant
When NOT to use polyfills:
- The feature can be handled with a simple
ifcheck and fallback - The polyfill is large and the feature is used in one place
- You can restructure your code to avoid the API entirely
A word on polyfill.io (now polyfill.io alternatives): the original polyfill.io service was sold in 2024 and started serving malicious code. Do not use it. Self-host your polyfills or use core-js directly.
Transpilation with Babel
Babel transforms modern JavaScript syntax into code that older browsers can execute. Combined with your .browserslistrc, it only transforms what is necessary:
npm install @babel/core @babel/preset-env --save-dev
// babel.config.js
module.exports = {
presets: [
['@babel/preset-env', {
useBuiltIns: 'usage',
corejs: 3,
targets: '> 0.25%, not dead'
}]
]
};
With useBuiltIns: 'usage', Babel automatically imports only the core-js polyfills your code actually needs, based on your browserslist targets. This keeps bundle size manageable.
CSS Reset vs Normalize.css
Browsers apply default styles to HTML elements, and these defaults vary. Two approaches exist to handle this:
- CSS Reset (e.g., Eric Meyer's Reset): strips all default styles to zero. Every element starts with no margin, no padding, no font size. You rebuild everything from scratch.
- Normalize.css: preserves useful defaults while fixing inconsistencies between browsers. Headings still look like headings. Lists still have bullets.
I recommend Normalize.css for most projects. A full reset means you have to explicitly style everything, which leads to bloated CSS. Normalize gives you a consistent baseline without throwing away sensible defaults:
npm install normalize.css
@import 'normalize.css';
/* Your styles build on a consistent foundation */
Flexbox and Grid Cross-Browser Gotchas
Flexbox and Grid are well-supported in modern browsers, but edge cases still bite:
Flexbox issues:
gapin flexbox: Not supported in Safari below 14.1. Use margins as a fallback.min-width: autoon flex items: Chromium and Firefox differ on whether flex items shrink below their content size. Explicitly setmin-width: 0on flex items that contain text or images.flex-basisandbox-sizing: IE11 and old Edge did not respectbox-sizing: border-boxwithflex-basis. If you still support them, usewidthinstead offlex-basis.
.flex-item {
flex: 1 1 0%;
min-width: 0; /* Prevent overflow in all browsers */
}
Grid issues:
auto-fillvsauto-fit: Behaves consistently now, but older Safari versions had bugs. Test your specific pattern.- Subgrid: Only supported in Firefox and Safari as of early 2026. Chrome is still implementing it. Do not rely on subgrid without a fallback.
Form Element Styling Differences
Form elements are the most inconsistent UI components across browsers. Each browser has its own native rendering for <select>, <input type="date">, <input type="range">, checkboxes, and radio buttons.
Two strategies:
- Accept the differences. Native form elements are accessible and familiar. Let each OS render them its way.
- Reset and restyle. Use
appearance: noneto strip native styling, then rebuild:
.custom-select {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background: white url('data:image/svg+xml,...') no-repeat right 12px center;
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px 36px 8px 12px;
font-size: 16px; /* Prevents iOS zoom on focus */
}
Note the font-size: 16px — Safari on iOS zooms into form fields with font sizes smaller than 16px. This is one of those quirks you learn the hard way.
Font Rendering Differences
Fonts render differently across operating systems, not just browsers:
- macOS: Subpixel antialiasing (smoother, slightly heavier text)
- Windows: ClearType rendering (sharper, thinner text)
- Linux: Varies by distribution and font config
You cannot fully control this, but you can mitigate it:
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
Use system font stacks when possible to get the best rendering on each platform:
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
}
JavaScript API Differences
Some APIs behave differently across browsers even when technically supported:
Safari quirks:
Dateparsing:new Date('2024-01-15')works everywhere, butnew Date('2024/01/15')fails in Safari. Always use ISO 8601 format with dashes.fetchabort: Safari was late to supportAbortController. Verify your minimum Safari version.scrollTowithbehavior: 'smooth': Not supported in Safari below 15.4. Use a polyfill or JavaScript animation.
Firefox behaviors:
structuredClone: Firefox implemented this before other browsers. If targeting older Chrome, check support.crypto.randomUUID(): Shipped in Firefox before Chrome. Polyfill if needed.
// Safe date parsing across all browsers
function parseDate(dateString) {
var parts = dateString.split(/[-\/]/);
return new Date(
parseInt(parts[0], 10),
parseInt(parts[1], 10) - 1,
parseInt(parts[2], 10)
);
}
// Safe smooth scrolling
function smoothScrollTo(element) {
if ('scrollBehavior' in document.documentElement.style) {
element.scrollIntoView({ behavior: 'smooth' });
} else {
// Manual smooth scroll fallback
var targetPosition = element.getBoundingClientRect().top + window.pageYOffset;
window.scrollTo(0, targetPosition);
}
}
Testing Strategies
Cross-browser testing requires a structured approach. Random spot-checking is not enough.
Automated testing with Playwright:
Playwright supports Chromium, Firefox, and WebKit (Safari's engine) out of the box:
// playwright.config.js
module.exports = {
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } }
]
};
// tests/accordion.spec.js
var { test, expect } = require('@playwright/test');
test('accordion expands and collapses', function() {
return async function(page) {
// Playwright handles the async internally
};
});
BrowserStack / Sauce Labs: For testing on real devices and browsers you do not have locally. Essential for iOS Safari testing on non-Mac machines.
Manual testing matrix: Create a spreadsheet with critical user flows as rows and browser/device combinations as columns. Test each cell before every major release.
| Flow | Chrome (Win) | Firefox (Win) | Safari (Mac) | iOS Safari | Chrome (Android) |
|---|---|---|---|---|---|
| Signup form | Pass | Pass | Pass | Pass | Pass |
| Checkout | Pass | Pass | - | - | - |
| Dashboard | Pass | - | - | - | - |
Progressive Enhancement as Compatibility Strategy
Progressive enhancement means building a functional baseline that works everywhere, then adding enhanced experiences for more capable browsers. This is the most resilient compatibility strategy:
- Start with semantic HTML that works without CSS or JavaScript
- Add CSS for layout and visual design
- Layer JavaScript for interactivity
- Use
@supportsand feature detection to add advanced features
This is not about supporting ancient browsers. It is about building software that degrades gracefully when something unexpected happens — a CDN fails, a script errors out, a CSS property is not yet supported.
PostCSS for CSS Compatibility
PostCSS is a tool for transforming CSS with JavaScript plugins. Autoprefixer is a PostCSS plugin, but there are others useful for compatibility:
npm install postcss postcss-preset-env --save-dev
// postcss.config.js
module.exports = {
plugins: [
require('postcss-preset-env')({
stage: 2,
features: {
'nesting-rules': true,
'custom-media-queries': true
},
browsers: 'last 2 versions'
})
]
};
postcss-preset-env converts modern CSS features into compatible CSS based on your browser targets, similar to what Babel does for JavaScript.
Handling Safari-Specific Issues
Safari deserves its own section because it is the most frequent source of cross-browser bugs in modern development.
The 100vh problem:
On iOS Safari, 100vh includes the area behind the browser's address bar, causing content to be hidden. The fix:
.full-height {
height: 100vh;
height: 100dvh; /* Dynamic viewport height - accounts for browser chrome */
}
@supports not (height: 100dvh) {
.full-height {
height: -webkit-fill-available;
}
}
Date inputs:
Safari's native <input type="date"> implementation is inconsistent. If you need reliable date input across browsers, use a JavaScript date picker library or a set of three <select> elements.
Smooth scrolling:
// Safari smooth scroll polyfill
function polyfillSmoothScroll() {
if (!('scrollBehavior' in document.documentElement.style)) {
var script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/smoothscroll.min.js';
script.onload = function() {
window.__forceSmoothScrollPolyfill__ = true;
smoothscroll.polyfill();
};
document.head.appendChild(script);
}
}
:focus-visible support:
Older Safari versions do not support :focus-visible. Use a fallback:
/* Fallback for browsers without :focus-visible */
.button:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
/* Remove outline for mouse users in supporting browsers */
.button:focus:not(:focus-visible) {
outline: none;
}
.button:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
Debugging Cross-Browser Issues Systematically
When a bug appears in one browser but not another:
- Isolate the problem. Create a minimal reproduction — strip away unrelated code until you find the smallest case that triggers the bug.
- Check the spec. Often the browser you think is correct is actually the one deviating. Read the MDN docs and the CSS/HTML spec.
- Search for known bugs. Chromium, Firefox, and WebKit all have public bug trackers. Search them with the relevant CSS property or JavaScript API.
- Use browser-specific dev tools. Safari's Web Inspector, Firefox Developer Tools, and Chrome DevTools all have unique debugging capabilities. Safari's Responsive Design Mode is essential for iOS testing.
- Test the fix in all browsers. A fix for one browser should not break another. Run your full testing matrix after every cross-browser fix.
Complete Working Example: Cross-Browser Accordion
Here is a complete accordion component that works across Chrome, Firefox, Safari, and Edge with proper fallbacks:
HTML
<div class="accordion" role="tablist">
<div class="accordion-item">
<button class="accordion-trigger"
role="tab"
aria-expanded="false"
aria-controls="panel-1"
id="trigger-1">
<span class="accordion-title">What is cross-browser compatibility?</span>
<span class="accordion-icon" aria-hidden="true"></span>
</button>
<div class="accordion-panel"
role="tabpanel"
id="panel-1"
aria-labelledby="trigger-1"
hidden>
<div class="accordion-content">
<p>Cross-browser compatibility ensures your website works correctly
across different web browsers and their versions.</p>
</div>
</div>
</div>
<div class="accordion-item">
<button class="accordion-trigger"
role="tab"
aria-expanded="false"
aria-controls="panel-2"
id="trigger-2">
<span class="accordion-title">Why does Safari cause so many issues?</span>
<span class="accordion-icon" aria-hidden="true"></span>
</button>
<div class="accordion-panel"
role="tabpanel"
id="panel-2"
aria-labelledby="trigger-2"
hidden>
<div class="accordion-content">
<p>Safari uses the WebKit engine and often lags behind Chromium
and Firefox in implementing newer web standards.</p>
</div>
</div>
</div>
</div>
CSS
/* === Base styles — work in all browsers === */
.accordion {
border: 1px solid #e0e0e0;
border-radius: 4px;
overflow: hidden;
max-width: 640px;
margin: 0 auto;
}
.accordion-item + .accordion-item {
border-top: 1px solid #e0e0e0;
}
.accordion-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 16px 20px;
border: none;
background: #fafafa;
cursor: pointer;
font-size: 16px;
font-family: inherit;
text-align: left;
color: #333;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
.accordion-trigger:hover {
background: #f0f0f0;
}
/* Focus styles with :focus-visible fallback */
.accordion-trigger:focus {
outline: 2px solid #0066cc;
outline-offset: -2px;
}
.accordion-trigger:focus:not(:focus-visible) {
outline: none;
}
.accordion-trigger:focus-visible {
outline: 2px solid #0066cc;
outline-offset: -2px;
}
/* Icon rotation */
.accordion-icon {
display: inline-block;
width: 12px;
height: 12px;
border-right: 2px solid #666;
border-bottom: 2px solid #666;
transform: rotate(45deg);
transition: transform 0.2s ease;
flex-shrink: 0;
margin-left: 16px;
}
.accordion-trigger[aria-expanded="true"] .accordion-icon {
transform: rotate(-135deg);
}
/* Panel — hidden by default via HTML hidden attribute */
.accordion-panel {
overflow: hidden;
}
.accordion-content {
padding: 16px 20px;
}
/* === Enhanced animation for browsers that support it === */
@supports (transition: max-height 0.3s ease) {
.accordion-panel {
max-height: 0;
transition: max-height 0.3s ease;
}
.accordion-panel[aria-hidden="false"] {
max-height: 500px; /* Adjust based on expected content height */
}
}
/* === Reduced motion preference === */
@media (prefers-reduced-motion: reduce) {
.accordion-icon {
transition: none;
}
.accordion-panel {
transition: none;
}
}
/* === Gap fallback for older flexbox implementations === */
@supports not (gap: 1px) {
.accordion-trigger > *:not(:last-child) {
margin-right: 16px;
}
}
JavaScript
(function() {
'use strict';
// Feature detection
var supportsTransition = (function() {
var el = document.createElement('div');
return 'transition' in el.style ||
'WebkitTransition' in el.style ||
'MozTransition' in el.style;
})();
var supportsHidden = 'hidden' in document.createElement('div');
function Accordion(container) {
this.container = container;
this.triggers = container.querySelectorAll('.accordion-trigger');
this.init();
}
Accordion.prototype.init = function() {
var self = this;
// Convert NodeList to array for older browsers
var triggers = Array.prototype.slice.call(this.triggers);
triggers.forEach(function(trigger) {
trigger.addEventListener('click', function(event) {
self.toggle(trigger);
});
// Keyboard support
trigger.addEventListener('keydown', function(event) {
self.handleKeydown(event, trigger);
});
});
// If the browser does not support the hidden attribute, hide panels manually
if (!supportsHidden) {
var panels = container.querySelectorAll('.accordion-panel');
Array.prototype.slice.call(panels).forEach(function(panel) {
panel.style.display = 'none';
});
}
};
Accordion.prototype.toggle = function(trigger) {
var isExpanded = trigger.getAttribute('aria-expanded') === 'true';
var panelId = trigger.getAttribute('aria-controls');
var panel = document.getElementById(panelId);
if (!panel) return;
if (isExpanded) {
this.closePanel(trigger, panel);
} else {
this.openPanel(trigger, panel);
}
};
Accordion.prototype.openPanel = function(trigger, panel) {
trigger.setAttribute('aria-expanded', 'true');
if (supportsTransition) {
panel.removeAttribute('hidden');
panel.setAttribute('aria-hidden', 'false');
// Force reflow before adding max-height for transition
panel.offsetHeight; // eslint-disable-line no-unused-expressions
panel.style.maxHeight = panel.scrollHeight + 'px';
} else {
// Fallback: no animation
panel.removeAttribute('hidden');
panel.style.display = 'block';
panel.setAttribute('aria-hidden', 'false');
}
};
Accordion.prototype.closePanel = function(trigger, panel) {
trigger.setAttribute('aria-expanded', 'false');
if (supportsTransition) {
panel.style.maxHeight = '0';
panel.setAttribute('aria-hidden', 'true');
// Wait for transition to complete before adding hidden attribute
var onTransitionEnd = function() {
panel.setAttribute('hidden', '');
panel.removeEventListener('transitionend', onTransitionEnd);
};
// Fallback timeout in case transitionend does not fire (Safari quirk)
var fallbackTimeout = setTimeout(function() {
panel.setAttribute('hidden', '');
panel.removeEventListener('transitionend', onTransitionEnd);
}, 400);
panel.addEventListener('transitionend', function handler() {
clearTimeout(fallbackTimeout);
panel.setAttribute('hidden', '');
panel.removeEventListener('transitionend', handler);
});
} else {
panel.style.display = 'none';
panel.setAttribute('hidden', '');
panel.setAttribute('aria-hidden', 'true');
}
};
Accordion.prototype.handleKeydown = function(event, trigger) {
var triggers = Array.prototype.slice.call(this.triggers);
var index = triggers.indexOf(trigger);
switch (event.key || event.keyCode) {
case 'ArrowDown':
case 40:
event.preventDefault();
if (index < triggers.length - 1) {
triggers[index + 1].focus();
}
break;
case 'ArrowUp':
case 38:
event.preventDefault();
if (index > 0) {
triggers[index - 1].focus();
}
break;
case 'Home':
case 36:
event.preventDefault();
triggers[0].focus();
break;
case 'End':
case 35:
event.preventDefault();
triggers[triggers.length - 1].focus();
break;
}
};
// Initialize when DOM is ready
function initAccordions() {
var accordions = document.querySelectorAll('.accordion');
Array.prototype.slice.call(accordions).forEach(function(el) {
new Accordion(el);
});
}
// Cross-browser DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initAccordions);
} else {
initAccordions();
}
})();
Testing Checklist
Use this checklist for the accordion and any cross-browser component:
- Renders correctly in Chrome (Windows and Mac)
- Renders correctly in Firefox (Windows and Mac)
- Renders correctly in Safari (Mac)
- Renders correctly in iOS Safari (iPhone and iPad)
- Renders correctly in Edge (Windows)
- Keyboard navigation works (Arrow keys, Home, End, Enter/Space)
- Screen reader announces expanded/collapsed state
- Focus outlines visible only on keyboard navigation
- Animations disabled when
prefers-reduced-motionis set - No horizontal overflow on small screens
- Click/tap target is at least 44x44px on mobile
-
transitionendfires correctly in Safari (fallback timeout exists)
Common Issues and Troubleshooting
1. CSS Grid items overflowing their container in Firefox
Firefox sometimes calculates grid item sizes differently when images or long words are involved. Fix: add min-width: 0 and overflow-wrap: break-word to grid items.
.grid-item {
min-width: 0;
overflow-wrap: break-word;
}
2. position: sticky not working in Safari inside an overflow container
Safari does not support position: sticky inside any ancestor with overflow: hidden, overflow: scroll, or overflow: auto. Restructure your DOM so the sticky element is not inside an overflow container.
3. transitionend event not firing in Safari
Safari occasionally skips the transitionend event, especially on elements transitioning to height: 0 or max-height: 0. Always use a fallback setTimeout alongside transitionend listeners.
4. iOS Safari zooming into form inputs
Safari on iOS zooms the viewport when focusing on inputs with font-size less than 16px. Set font-size: 16px on all form inputs, or use the maximum-scale=1 viewport meta tag (though this disables pinch-to-zoom, which is an accessibility concern).
<meta name="viewport" content="width=device-width, initial-scale=1">
input, select, textarea {
font-size: 16px;
}
5. Flexbox gap property not supported in older Safari
Safari versions before 14.1 do not support gap in flexbox (they do support it in Grid). Use margin-based spacing as a fallback:
.flex-container {
display: flex;
flex-wrap: wrap;
margin: -8px;
}
.flex-container > * {
margin: 8px;
}
@supports (gap: 16px) {
.flex-container {
gap: 16px;
margin: 0;
}
.flex-container > * {
margin: 0;
}
}
Best Practices
Define your browser support matrix early and reference it in every technical decision. Encode it in
.browserslistrcso your tools enforce it automatically.Use Autoprefixer and PostCSS — never write vendor prefixes by hand. Manual prefixing is error-prone and creates maintenance burden. Let tooling handle it.
Always feature-detect, never user-agent sniff. Feature detection tells you what the browser can do. User agent strings tell you what the browser claims to be, which is often wrong.
Write the fallback first, then the enhancement. Start with a baseline that works in the least capable browser in your support matrix, then layer improvements with
@supportsand feature detection.Test on real devices, not just emulators. Browser emulators miss rendering quirks, touch behaviors, and performance characteristics of real hardware. Use BrowserStack or physical devices.
Respect user preferences. Honor
prefers-reduced-motion,prefers-color-scheme, andprefers-contrast. These are both accessibility requirements and cross-platform considerations — they behave differently across operating systems.Keep polyfills targeted. Do not ship a 100KB polyfill bundle to Chrome users who do not need it. Use Babel's
useBuiltIns: 'usage'or conditional loading to serve polyfills only to browsers that need them.Document known browser quirks in your codebase. When you write a workaround, add a comment explaining which browser it fixes and link to the relevant bug report. Future developers (including yourself) will thank you.
References
- Can I Use — Browser support tables for web technologies
- MDN Web Docs — Comprehensive web technology documentation with compatibility tables
- Browserslist — Share target browsers configuration between tools
- Autoprefixer — PostCSS plugin for parsing CSS and adding vendor prefixes
- PostCSS Preset Env — Convert modern CSS into browser-compatible CSS
- Normalize.css — A modern CSS reset alternative
- Playwright — Cross-browser testing framework by Microsoft
- Core-js — Modular standard library polyfills
- WebKit Bug Tracker — Report and search Safari/WebKit bugs
- Chromium Bug Tracker — Report and search Chrome/Edge bugs