When CSS opacity transition silently fails inside WKWebView

2026-05-06 11:28 (79 minutes ago)
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:

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

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:

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:

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:

<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');
}

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.

Please rate this article
Currently unrated
The author runs the application development company Cyberneura.
We look forward to discussing your development needs.

Categories

Archive