WKWebView で CSS の opacity transition が動かないとき疑うこと

2026-05-06 11:28 (61 minutes ago)
WKWebView で CSS の opacity transition が動かないとき疑うこと

症状

WKWebView (macOS / iOS) や同系統のエンジン (Electron, Capacitor, Tauri) で、

  • HTML / CSS のロードは成功している
  • 画像 URL も取れて Network には 200 OK
  • <div class="layer active">active クラスもちゃんと付与されている
  • なのに 画面に何も表示されない

CSS は典型的なフェードイン:

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

Web Inspector で getComputedStyle(el).opacity を見ると "0" のまま固まっている。

原因

WebKit は document.visibilityState === 'hidden' のとき CSS transition / animation を停止する 最適化を持っている。具体的には:

  • requestAnimationFrame を停止
  • CSS transition の進行も止める
  • setTimeout / setInterval は throttled

普通にブラウザでタブを開いていれば visible なので問題ないが、WKWebView が OS から見て表示されてない 状態だと hidden 扱いになる。

opacity: 0 → 1 の transition は開始時刻 t=0 のフレームから始まって 1 秒かけて t=1 になるはずが、t=0 で停止してしまう。class は付いているし、CSS の最終値も解釈されているのに、画面上は opacity 0 のまま。

どこで踏むか

  • macOS のスクリーンセーバ (legacyScreenSaver プロセス内の WKWebView)
  • iOS / iPadOS のバックグラウンドプリロード
  • Electron で BrowserWindow({ show: false }) のまま操作
  • Mac Catalyst の背面ウィンドウ
  • Capacitor / Tauri の初期表示前のフレーム
  • Headless Chrome で screenshot を急いで撮らせている時

切り分け

Web Inspector の Console で 4 つチェック:

document.visibilityState                           // → "hidden" ならビンゴ
el.classList.contains('active')                    // → true (= class は付いている)
getComputedStyle(el).opacity                       // → "0" のまま固まっている
el.style.transition = 'none'; el.style.opacity = 1 // → 即座に表示される

これが揃ったら確定。

解決策 A: WKUserScript で visibility を偽装する

WKWebView 側 (Swift) で WKUserScript を仕掛ける。

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

ポイント:

  • document に直接 Object.defineProperty するのではなく Document.prototype 経由 で再定義する。document 自体はインスタンスプロパティが既に確定済みのことがある
  • configurable: true を必ず付ける。再定義時の例外を防ぐため
  • injectionTime: .atDocumentStart で webapp の JS より早く走らせる

解決策 B: CSS で transition を殺す

visibility 偽装が効かない場合や効果が遅れる場合、CSS で直接 transition を切る:

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

A と B を両方仕掛けても害はない。

解決策 C: webapp 側に flag を渡す

URL に ?embed=1 のような印を付けて、webapp 側で transition を無効化、初期から opacity: 1 で render する。長期的にはこれが一番きれい。

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

埋め込み側 (WKWebView) が webapp の CSS 詳細を知らずに済むので、知識の局在化という意味でも筋が良い。

まとめ

「class は付いているのに opacity だけ 0」のとき document.visibilityState を疑う。WKWebView では Object.defineProperty(Document.prototype, 'visibilityState', ...) で偽装可能だが、可能なら webapp 側に flag を渡して対応するのがきれい。transition: none !important の CSS 注入を併用すると確実。

評価をお願いします
まだ評価がありません
著者は、アプリケーション開発会社 Cyberneura を運営しています。
開発相談をお待ちしています。

アーカイブ