---
slug: "wkwebview-opacity-transition-not-working"
title: "When CSS opacity transition silently fails inside WKWebView"
description: "When CSS `opacity` transitions don't animate inside WKWebView, force a layer with `will-change` or `transform: translateZ(0)` — the usual workaround."
url: "https://www.ytyng.com/en/blog/wkwebview-opacity-transition-not-working"
publish_date: "2026-05-06T11:28:21.114Z"
created: "2026-05-06T11:28:21.115Z"
updated: "2026-05-11T13:10:24.517Z"
categories: []
keywords: ""
featured_image_url: "https://media.ytyng.com/resize/20260506/fe5cdad1e3ca4b1f9a2aeba932f2a568.png.webp?width=768"
has_video: true
has_music: true
video_urls: ["https://media.ytyng.net/ytyng-blog/347/featured-video-1.mp4", "https://media.ytyng.net/ytyng-blog/347/featured-video-2.mp4", "https://media.ytyng.net/ytyng-blog/347/featured-video-3.mp4"]
music_urls: ["https://media.ytyng.net/ytyng-blog/347/featured-music-347-1.mp3", "https://media.ytyng.net/ytyng-blog/347/featured-music-347-2.mp3"]
lang: "en"
---

# When CSS opacity transition silently fails inside WKWebView

## Symptom

You embed a webapp inside a WKWebView (macOS, iOS, or related engines like Electron, Capacitor, or Tauri). Everything looks correct:

- The HTML and CSS load fine.
- Network shows 200 OK for the image.
- The element has the right `class="layer active"`.
- And yet **nothing shows up on screen**.

The CSS is a textbook fade-in:

```css
.layer { opacity: 0; transition: opacity 1s ease-in-out; }
.layer.active { opacity: 1; }
```

In the Web Inspector, `getComputedStyle(el).opacity` is `"0"` and refuses to budge.

## Cause

WebKit pauses CSS transitions and animations when `document.visibilityState === 'hidden'`. While the document is hidden:

- `requestAnimationFrame` is paused.
- CSS transitions stop progressing.
- `setTimeout` / `setInterval` get throttled.

When you open a normal browser tab this never bites you because the tab is `visible`. But a WKWebView that is **not visible from the OS's point of view** is reported as hidden — and your `opacity: 0 → 1` transition starts at frame t=0 and never advances. The class is applied, the final value is parsed, but on screen the element stays at opacity 0.

## Where you hit this

- macOS screensavers (`legacyScreenSaver` process hosting a WKWebView).
- iOS / iPadOS background preload paths.
- Electron with `BrowserWindow({ show: false })`.
- Mac Catalyst back-buffer windows.
- Capacitor / Tauri before the first render frame.
- Headless Chrome screenshots that fire too early.

## How to confirm

In the Web Inspector console, run:

```js
document.visibilityState                           // → "hidden" → bingo
el.classList.contains('active')                    // → true
getComputedStyle(el).opacity                       // → "0" stuck
el.style.transition = 'none'; el.style.opacity = 1 // → instantly visible
```

If all four match, you have this bug.

## Fix A: spoof visibility from the native side

Inject a `WKUserScript` at document-start that overrides the visibility getters on `Document.prototype`:

```swift
let visibilityOverride = """
try {
  Object.defineProperty(Document.prototype, 'hidden', {
    configurable: true, get: function() { return false; }
  });
  Object.defineProperty(Document.prototype, 'visibilityState', {
    configurable: true, get: function() { return 'visible'; }
  });
} catch (e) {}
"""

config.userContentController.addUserScript(
  WKUserScript(source: visibilityOverride,
               injectionTime: .atDocumentStart,
               forMainFrameOnly: false)
)
```

Notes:

- Override `Document.prototype`, not `document` itself. The instance properties are sometimes already locked.
- Always set `configurable: true`. Without it, redefining throws.
- Use `.atDocumentStart` so this runs before your app's JS reads the values.

## Fix B: kill the transition with injected CSS

If spoofing visibility is not enough or kicks in too late, bypass the transition entirely:

```swift
let cssOverride = """
(function(){
  var css = '.layer { transition: none !important; }' +
            '.layer.active { opacity: 1 !important; }';
  var apply = function(){
    if (!document.head) return false;
    var s = document.createElement('style');
    s.textContent = css;
    document.head.appendChild(s);
    return true;
  };
  if (!apply()) document.addEventListener('DOMContentLoaded', apply);
})();
"""

config.userContentController.addUserScript(
  WKUserScript(source: cssOverride,
               injectionTime: .atDocumentEnd,
               forMainFrameOnly: true)
)
```

It's harmless to combine A and B.

## Fix C: hand the webapp a flag

Put `?embed=1` (or similar) in the URL and let the webapp opt out of the transition itself:

```html
<style>
  .layer { opacity: 0; transition: opacity 1s; }
  .layer.active { opacity: 1; }
  body.embed .layer { transition: none; opacity: 1; }
</style>
```

```js
if (new URLSearchParams(location.search).get('embed') === '1') {
  document.body.classList.add('embed');
}
```

This is the cleanest long-term answer because the embedding host doesn't have to know anything about the webapp's CSS internals.

## Takeaway

When an element has the right class and the right URL but stays at opacity 0, suspect `document.visibilityState`. In a WKWebView you can spoof it through `Document.prototype`, drop the `transition` with injected CSS, or — best — give the webapp a flag and let it handle the embed case itself.
