iOS 26+ CSS Rotation Workaround for WKWebView Fullscreen
The Problem
Starting with iOS 26, Apple introduced a bug where calling requestGeometryUpdate (programmatic orientation change) breaks WKWebView's internal gesture recognizers. After a programmatic rotation, touch events stop being delivered to web content — taps, swipes, and all interactions inside the WebView become unresponsive.
This is a confirmed Apple bug (similar to this Flutter issue (opens in a new tab)) affecting any native app that embeds a WKWebView and programmatically rotates the interface.
Root Cause
WKWebView (WebKit) uses its own internal UIGestureRecognizer instances to handle touch events. When requestGeometryUpdate triggers an orientation change, these recognizers enter a broken state and never recover — even after rotating back.
The Solution: CSS Rotation
Instead of rotating the entire app interface via your app's native implementation, keep the app in portrait and use a CSS transform: rotate() injected into the WebView to simulate landscape layout. This avoids calling requestGeometryUpdate entirely, so WebKit's gesture recognizers remain intact.
How It Works
- The HTML content inside the WebView is rotated 90° via CSS
- The
widthandheightare swapped using viewport units (100vh/100vw) - The content is repositioned so it fills the portrait screen as if it were landscape
- Touch events continue to work because no UIKit orientation change occurred
Implementation Guide
Step 1: Detect iOS 26+
Add a version check so you only apply the CSS workaround on affected versions. Earlier iOS versions can continue using native requestGeometryUpdate.
private var isIOS26Plus: Bool {
if let majorVersion = Int(
UIDevice.current.systemVersion
.components(separatedBy: ".").first ?? "0"
) {
return majorVersion >= 26
}
return false
}Step 2: Add CSS Rotation Helpers
These two methods inject and remove a <style> tag that rotates the <html> element.
/// Tracks whether CSS rotation is currently active
@State private var isCSSRotated: Bool = false
/// Injects a CSS transform to rotate web content 90° and fill the viewport.
/// Rotation direction is based on the device's physical orientation.
private func applyCSSRotation() {
let orientation = UIDevice.current.orientation
// Match rotation direction to device tilt
let degrees = orientation == .landscapeRight ? -90 : 90
let position = degrees == 90
? "top: 0; left: 100vw;" // clockwise: anchor top-left, shift right
: "top: 100vh; left: 0;" // counterclockwise: anchor top-left, shift down
let script = """
(function() {
var existing = document.getElementById('gs-css-rotation');
if (existing) existing.remove();
var style = document.createElement('style');
style.id = 'gs-css-rotation';
style.textContent = 'html { \
position: fixed !important; \
transform-origin: top left !important; \
transform: rotate(\(degrees)deg) !important; \
width: 100vh !important; \
height: 100vw !important; \
\(position) \
overflow: hidden !important; \
}';
document.head.appendChild(style);
})();
"""
webView.evaluateJavaScript(script)
isCSSRotated = true
}
/// Removes the injected CSS rotation, restoring normal layout.
private func removeCSSRotation() {
let script = """
(function() {
var style = document.getElementById('gs-css-rotation');
if (style) style.remove();
})();
"""
webView.evaluateJavaScript(script)
isCSSRotated = false
}Step 3: Modify Your Fullscreen Toggle
When the WebView requests fullscreen (via a JavaScript bridge message):
func messageHandler(type: String) {
if (type == "toggleFullscreen") {
isPresented.toggle()
if isIOS26Plus {
// iOS 26+: Use CSS rotation to avoid the touch event bug
if isPresented {
let isCurrentlyPortrait =
UIScreen.main.bounds.size.width < UIScreen.main.bounds.size.height
if isCurrentlyPortrait {
// Delay slightly to let SwiftUI layout settle at fullscreen size
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.applyCSSRotation()
}
}
// If already in physical landscape, no CSS rotation needed
} else {
// Exiting fullscreen — remove CSS rotation
removeCSSRotation()
}
} else {
// iOS < 26: Native programmatic rotation works fine
changeOrientation(to: isPresented ? .landscape : .portrait)
}
} else if (type == "init") {
isVideoPlayerReady = true
onToggleFullscreen()
}
}Step 4: Handle Physical Device Rotation
When the user physically rotates their device, you need to coordinate with the CSS rotation state:
func onToggleFullscreen() {
if(selectedTab == 0){
// This is needed to avoid unknown orientation value
let isPortrait = UIDevice.current.orientation.rawValue == 0 || UIDevice.current.orientation.isFlat
? UIScreen.main.bounds.size.width < UIScreen.main.bounds.size.height
: UIDevice.current.orientation == .portrait || UIDevice.current.orientation.isFlat
orientationSource = .accelerometer
// On iOS 26+: if device physically rotated to landscape while CSS rotation is active,
// remove CSS rotation — native landscape takes over seamlessly
if !isPortrait && isPresented && isCSSRotated {
removeCSSRotation()
return
}
// Normal fullscreen toggle based on physical rotation
if (!isPortrait && !isPresented) || (isPortrait && isPresented) {
hideKeyboard()
if isCSSRotated {
removeCSSRotation()
}
isPresented.toggle()
let script = """
document.querySelector(".video-container")?.classList.toggle("full-screen")
"""
webViewCoordinator.webView.evaluateJavaScript(script)
}
}
}Register this handler for orientation change notifications:
private let rotationPublisher = NotificationCenter.default
.publisher(for: UIDevice.orientationDidChangeNotification)
// In your view body:
.onReceive(rotationPublisher) { _ in
onToggleFullscreen()
}CSS Rotation Explained
The CSS transform works by re-laying out the HTML at landscape dimensions while visually rotating it to fit the portrait viewport:
Portrait viewport (390 × 844)
┌─────────────────────┐
│ │
│ HTML rendered at │
│ 844 × 390 │
│ (landscape dims) │
│ │
│ Then rotated 90° │
│ to fill portrait │
│ viewport │
│ │
└─────────────────────┘For clockwise rotation (90°)
Simulates landscapeLeft — content top points right.
html {
position: fixed;
transform-origin: top left;
transform: rotate(90deg);
width: 100vh; /* viewport height becomes content width */
height: 100vw; /* viewport width becomes content height */
top: 0;
left: 100vw; /* shift right so rotated content lands in view */
overflow: hidden;
}For counterclockwise rotation (-90°)
Simulates landscapeRight — content top points left.
html {
position: fixed;
transform-origin: top left;
transform: rotate(-90deg);
width: 100vh;
height: 100vw;
top: 100vh; /* shift down so rotated content lands in view */
left: 0;
overflow: hidden;
}Behavior Summary
| Scenario | iOS < 26 | iOS 26+ |
|---|---|---|
| Fullscreen button (portrait) | requestGeometryUpdate → native landscape | CSS rotation → simulated landscape |
| Fullscreen button (landscape) | requestGeometryUpdate → portrait | Toggle isPresented only |
| Physical rotation to landscape | Native rotation, no API call | Native rotation + auto-remove CSS if active |
| Physical rotation to portrait | Exit fullscreen naturally | Exit fullscreen + clean up CSS |
| Touch events | Always work | Always work (no requestGeometryUpdate called) |