When CSS opacity transition silently fails inside WKWebView

2026-05-06 11:28 (6 months ago)
Frozen Opacity
Play a song themed on this article

Symptom

In WKWebView (macOS / iOS) or engines of the same family (Electron, Capacitor, Tauri):

  • The HTML / CSS loads successfully
  • The image URL is fetched too, and Network shows 200 OK
  • The active class is properly added to <div class="layer active">
  • And yet nothing is shown on screen

The CSS is a typical fade-in.

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

When you look at getComputedStyle(el).opacity in Web Inspector, it is stuck at "0".

Cause

WebKit has an optimization that stops CSS transitions / animations when document.visibilityState === 'hidden'. Specifically:

  • requestAnimationFrame is stopped
  • CSS transitions also stop progressing
  • setTimeout / setInterval are throttled

If you just have a tab open in a browser, it is visible, so there is no problem. But when the WKWebView is not displayed from the OS's point of view, it is treated as hidden.

The opacity: 0 → 1 transition should start from the frame at start time t=0 and take 1 second to reach t=1, but it stops at t=0. The class is applied and the final CSS value is parsed, but on screen the opacity stays at 0.

Where you hit this

  • macOS screensavers (a WKWebView inside the legacyScreenSaver process)
  • Background preloading on iOS / iPadOS
  • Operating Electron with BrowserWindow({ show: false }) still in place
  • Background windows in Mac Catalyst
  • Frames before the first display in Capacitor / Tauri
  • When you make Headless Chrome take a screenshot in a hurry

How to confirm

Check four things in the Web Inspector Console.

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 of these match, it is confirmed.

Fix A: spoof visibility from the native side

Set up a WKUserScript on the WKWebView side (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)
)

The points are as follows.

  • Do not call Object.defineProperty directly on document. Redefine it through Document.prototype. On document itself, the instance properties are sometimes already fixed
  • Always add configurable: true. This prevents an exception on redefinition
  • Use injectionTime: .atDocumentStart to run it earlier than the webapp's JS

Fix B: kill the transition with injected CSS

If spoofing visibility does not work, or its effect comes late, turn off the transition directly with CSS.

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)
)

There is no harm in setting up both A and B.

Fix C: hand the webapp a flag

Add a marker like ?embed=1 to the URL, and on the webapp side disable the transition and render with opacity: 1 from the start. In the long run this is the cleanest.

<style>
  .layer { opacity: 0; transition: opacity 1s; }
  .layer.active { opacity: 1; }
  body.embed .layer { transition: none; opacity: 1; }
</style>
if (new URLSearchParams(location.search).get('embed') === '1') {
  document.body.classList.add('embed');
}

The embedding side (WKWebView) does not need to know the details of the webapp's CSS, so it is also a sound approach in the sense of keeping knowledge local.

Categories

Archive