The iOS Safari Bestiary.
A field guide to iOS Safari's worst rendering bugs — the ones you find at 2 AM, on a phone, in a hotel, when your work looks fine everywhere except the one device the client is holding. Each entry: the symptom, the why, the actual fix. Tested on iOS 26.
iOS Safari is the second-most-important browser in the world by usage and arguably the most punishing one to develop for. WebKit ships on iOS exclusively — even Chrome and Firefox on iPhone use it under the hood. So when something breaks in Safari, it breaks on every iPhone, full stop.
Worse: iOS Safari has a habit of being just different from desktop Safari in ways that don't show up in your simulator. The Liquid Glass compositing rewrite in iOS 26 made this significantly worse. What works in DevTools breaks on the actual device.
This is a catalog of the bugs I've personally hit while shipping production work. Each entry tells you what the bug looks like, why WebKit does this, and the workaround that actually holds up — not the theoretical fix from a 2019 Stack Overflow answer.
filter: blur() gets clipped at the element's box
// why this happens
When iOS Safari needs to apply filter: blur(), it promotes the element to a compositing layer. The layer is sized to the element's box. The blur algorithm needs to read pixels outside the box to produce a soft falloff — but those pixels don't exist in the layer. WebKit clips them. The result: a sharp edge where the blur should have softened.
Chrome and Firefox grow the compositing layer slightly past the element bounds to give the blur room. Safari doesn't.
// the fix
Make the element bigger than the visible shape. Use negative inset (or padding) to expand the box past where you want the blur to die out. A safe rule: extend the box by at least 2× the blur radius in every direction.
.bloom {
position: absolute;
inset: -3px; /* too tight */
border-radius: 9999px;
filter: blur(7px); /* needs ~14px of breathing room */
}
.bloom {
position: absolute;
inset: -14px; /* 2× blur radius */
border-radius: 9999px;
filter: blur(7px);
/* iOS hint: force compositing layer up-front */
transform: translate3d(0, 0, 0);
-webkit-transform: translate3d(0, 0, 0);
}
@property animations work everywhere except iOS
--angle via @property — is static and lifeless on iPhone. No motion. No errors.// why this happens
The @property at-rule landed in Safari 16.4 (March 2023). Anything older — iOS 16.3 and below — doesn't recognize the syntax. The animation doesn't error out; the keyframe rule that sets --angle: 360deg is simply ignored because the browser doesn't know --angle is an <angle> type.
Even on supported versions (16.4+), @property animations sometimes fail when combined with filter on iOS 26 — a Liquid Glass compositing quirk.
// the fix
Two layers of defense. First, the GPU compositing hint to fix the iOS 26 case. Second, a fallback gradient for older iOS where @property doesn't exist at all.
@property --angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
@keyframes rotate {
to { --angle: 360deg; }
}
.bloom {
background: conic-gradient(from var(--angle), red, blue, red);
animation: rotate 2s linear infinite;
/* iOS 26 fix */
transform: translate3d(0, 0, 0);
}
@supports to ship a static gradient fallback. Don't try to polyfill — the JS overhead isn't worth it.
Animations leave color-stained ghost trails
// why this happens
will-change: filter tells the browser: "I'm going to animate this filter, please optimize." On iOS 26 with Liquid Glass compositing, Safari over-caches the result — it holds the previous frame's blur output in the compositing layer and doesn't clear it cleanly when the next frame paints. Old frames accumulate as a static colored ghost.
// the fix
Counter-intuitively, remove the will-change hint. Replace it with backface-visibility: hidden, which gives WebKit enough of a compositing nudge to use the GPU but doesn't cause it to over-cache.
.bloom {
filter: blur(7px);
animation: rotate 2s linear infinite;
will-change: filter; /* causes ghost-trail */
}
.bloom {
filter: blur(7px);
animation: rotate 2s linear infinite;
transform: translate3d(0, 0, 0);
-webkit-transform: translate3d(0, 0, 0);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
backdrop-filter flashes white on first paint
backdrop-filter: blur flashes briefly opaque (often white or solid background color) when it first appears or scrolls into view. Especially noticeable on dark themes.// why this happens
iOS Safari's compositor needs a render pass to capture the content behind the element before it can apply the backdrop filter. On the first paint, that capture hasn't happened yet — so it falls back to the element's background color (or transparent rendered against the page background). One frame later, the blur kicks in. The result is a perceptible flash.
// the fix
Pre-promote the element to a compositing layer with transform: translateZ(0) so it's ready when the first paint hits. Also set the background to matched transparency rather than opaque — so even if the flash happens, it's invisible.
.glass {
background: rgba(10, 6, 18, 0.4); /* same hue as page bg */
backdrop-filter: blur(20px) saturate(1.5);
-webkit-backdrop-filter: blur(20px) saturate(1.5);
transform: translateZ(0); /* pre-promote layer */
}
-webkit-backdrop-filter prefix is still required on iOS Safari for full support — even on iOS 26. Don't drop it.
position: fixed jumps when keyboard appears
// why this happens
iOS Safari changes what "viewport" means when the keyboard appears. The visual viewport (what the user sees) shrinks. The layout viewport (what CSS uses for positioning) does not. A position: fixed element is positioned against the layout viewport — so it appears to fly upward as the visual viewport contracts.
// the fix
Use the Visual Viewport API in JS to track the actual visible area, and reposition the element when the viewport changes. There's no pure-CSS fix for this one.
if (window.visualViewport) {
const el = document.querySelector('.sticky-cta');
const reposition = () => {
const vv = window.visualViewport;
// offset = how far the visual vp top is below the layout vp top
el.style.bottom = (window.innerHeight - vv.height - vv.offsetTop) + 'px';
};
window.visualViewport.addEventListener('resize', reposition);
window.visualViewport.addEventListener('scroll', reposition);
}
100vh is taller than the screen
height: 100vh extends below the visible area. Users have to scroll to see your bottom CTA. The header looks "cut off" because the screen is shorter than 100vh.// why this happens
On iOS Safari, 100vh is the height of the viewport when the address bar is hidden — not its current height. When the page loads with the URL bar visible, 100vh is taller than what's actually shown. The browser does this intentionally to prevent layout shift when the bar collapses on scroll.
// the fix
Use 100dvh (dynamic viewport height) — supported on iOS 15.4+. It tracks the actual visible viewport and updates as the URL bar appears/disappears. For older iOS, fall back to 100vh with a JS adjustment.
.hero {
height: 100vh; /* fallback */
height: 100dvh; /* iOS 15.4+, modern Chrome/Firefox */
}
100svh is the smallest viewport (with URL bar visible — never overflows but feels short). 100lvh is the largest. 100dvh is the dynamic one. Use dvh for hero sections, svh only if you need a guarantee no scroll appears.
Child elements leak past parent's border-radius
transform: scale or transform: translate on hover. On Safari, the child renders outside the card's rounded corners — sharp rectangular bleed at the corners.// why this happens
When a child element has a transform, Safari promotes it to its own compositing layer. The parent's border-radius clipping doesn't extend to compositing layers from its descendants — the child renders against the device pixel grid, ignoring the rounded mask. This is technically per-spec but Chrome/Firefox handle it gracefully.
// the fix
Force the parent into its own compositing layer too, with transform: translateZ(0) or isolation: isolate. Either creates a new stacking context that the rounded clip can apply to.
.card {
border-radius: 16px;
overflow: hidden;
isolation: isolate; /* contain child compositing */
transform: translateZ(0); /* belt-and-suspenders */
}
.card img:hover {
transform: scale(1.05); /* now stays inside the radius */
}
scroll-snap stops working after first interaction
// why this happens
iOS Safari has a long-standing bug where scroll-snap-type is computed only at scroll-container initialization. If a child element's size changes (e.g. due to ::before rendering or async image load), the snap calculations become stale. After the first scroll, Safari uses the stale calculations and the snap fails silently.
// the fix
Set explicit dimensions on snap children and avoid dynamic content inside them. If your carousel loads images, set their dimensions in CSS or as HTML attributes. Don't rely on intrinsic sizing.
.carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
}
.carousel > .slide {
scroll-snap-align: start;
scroll-snap-stop: always;
flex: 0 0 100%; /* explicit width — critical */
min-width: 100%; /* prevent shrinking on iOS */
}
The unifying rule.
If you look at the fixes above, almost all of them involve promoting an element to its own compositing layer — via translate3d, translateZ(0), isolation, or backface-visibility. iOS Safari's rendering pipeline is much more conservative about creating GPU layers than Chrome or Firefox, and most of its rendering bugs stem from layers being created too late, too small, or not at all.
The mental model: if it looks wrong on iOS but right elsewhere, try forcing a compositing layer first. It won't fix everything, but it fixes more than any other single technique.
And keep this list bookmarked. iOS 27 is going to add new entries.