cd ../writing
// field guide · iOS 26 tested

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.

8 bugs iOS 16+ covered iOS 26 Liquid Glass © use anywhere

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.

BUG 01HIGHfilter / compositing / iOS 16+

filter: blur() gets clipped at the element's box

The blur halo around your element appears trapped in a hard rectangle — extending up to the box edge, then sliced. On desktop and Android the same code shows a soft glow extending well past the 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.

✗ doesn't work on iOS
.bloom {
  position: absolute;
  inset: -3px;             /* too tight */
  border-radius: 9999px;
  filter: blur(7px);     /* needs ~14px of breathing room */
}
✓ works everywhere
.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);
}
Why translate3d: on its own this is a no-op — moving by zero pixels. But it forces WebKit to promote the element to a GPU compositing layer earlier in the render pipeline, which makes the blur calculation use the GPU's larger sampling region instead of CPU rasterization.
BUG 02HIGH@property / animations / iOS 16.3 and below

@property animations work everywhere except iOS

A gradient that rotates smoothly on desktop and Android — animating a custom property like --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.

✓ Works on iOS 16.4+ (including iOS 26)
@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);
}
For older iOS (16.3 and below — still <5% of iOS users at time of writing), use @supports to ship a static gradient fallback. Don't try to polyfill — the JS overhead isn't worth it.
BUG 03HIGHcompositing / iOS 26

Animations leave color-stained ghost trails

An animated, blurred element leaves a faint colored smear behind it during motion — like a fingerprint of where it was a frame ago. Only on iOS 26.

// 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.

✗ leaves trails on iOS 26
.bloom {
  filter: blur(7px);
  animation: rotate 2s linear infinite;
  will-change: filter;   /* causes ghost-trail */
}
✓ clean motion on every platform
.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;
}
BUG 04MEDbackdrop-filter / iOS 15+

backdrop-filter flashes white on first paint

A glass-morphism element with 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.

✓ no flash
.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 */
}
The -webkit-backdrop-filter prefix is still required on iOS Safari for full support — even on iOS 26. Don't drop it.
BUG 05HIGHposition:fixed / viewport / iOS 15+

position: fixed jumps when keyboard appears

A fixed-position element — a sticky CTA, bottom nav, modal — pops above the keyboard or floats in midair when the user focuses an input. On desktop and Android the same code stays put.

// 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.

✓ stays glued to bottom
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);
}
BUG 06HIGHviewport units / iOS 14+

100vh is taller than the screen

A hero section with 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.

✓ matches the screen on every iOS version
.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.
BUG 07MEDborder-radius / transforms / iOS 14+

Child elements leak past parent's border-radius

You have a rounded card containing a child that has 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.

✓ clean rounded corners
.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 */
}
BUG 08LOWscroll-snap / iOS 15+

scroll-snap stops working after first interaction

Horizontal scroll-snap carousel works on first scroll. After one swipe, snap-points stop activating — scroll becomes free-form. Refresh fixes it temporarily.

// 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.

✓ snap stays sticky
.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.