A new JavaScript/web API for Safari on visionOS that lets websites request a 3D model element go 'immersive', transporting visitors into a full spatial environment while keeping the webpage visible. It mirrors the familiar Fullscreen API pattern but extends beyond the browser window into the visionOS passthrough space.
⢠Game and product marketing sites can reuse existing USDZ/RealityKit environment assets to create fully immersive in-browser previews with minimal code ā no native app required.
⢠The API follows the existing Web Fullscreen API pattern, so web developers can adopt it quickly and progressively enhance visionOS experiences without breaking other platforms.
⢠Features like video docking into scene surfaces and entity transform control enable rich, story-driven spatial commerce and entertainment experiences directly in Safari.
Demonstrates how a Swift WKWebView host app can load a visionOS-targeted website that uses the new requestImmersive() web API to place a USDZ theater model into a full spatial environment when a seat is selected.
import SwiftUI
import WebKit
import RealityKit
// MARK: - SwiftUI entry point for a visionOS app hosting an immersive website
struct ImmersiveWebsiteView: View {
var body: some View {
VStack {
Text("Theater Seat Selector")
.font(.title)
.padding()
WebViewContainer()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
// MARK: - WKWebView container with immersive-capable configuration
struct WebViewContainer: UIViewRepresentable {
func makeUIView(context: Context) -> WKWebView {
// On visionOS, WKWebView automatically supports the immersive web API
// when the app's Info.plist declares the com.apple.developer.web-browser entitlement
// or when loaded inside Safari. The host app needs no additional configuration.
let config = WKWebViewConfiguration()
// Allow inline media playback so video docking inside the environment works
config.allowsInlineMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = []
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
// Load your web experience ā the HTML/JS side calls model.requestImmersive()
if let url = URL(string: "https://example.com/theater-seat-selector") {
webView.load(URLRequest(url: url))
}
return webView
}
func updateUIView(_ uiView: WKWebView, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator() }
// MARK: - Coordinator handles navigation events
class Coordinator: NSObject, WKNavigationDelegate {
func webView(_ webView: WKWebView,
didFinish navigation: WKNavigation!) {
// Inject a helper that checks for immersive API support
// and logs availability to the native side for debugging
let checkScript = """
(function() {
const model = document.querySelector('model');
if (!model) { return 'no-model-element'; }
const supported = typeof model.requestImmersive === 'function';
return supported ? 'immersive-supported' : 'immersive-not-supported';
})()
"""
webView.evaluateJavaScript(checkScript) { result, error in
if let status = result as? String {
print("[ImmersiveWeb] API status: \(status)")
}
}
}
func webView(_ webView: WKWebView,
didFail navigation: WKNavigation!,
withError error: Error) {
print("[ImmersiveWeb] Navigation failed: \(error.localizedDescription)")
}
}
}
// MARK: - Companion HTML/JS (served by your web server)
// The JavaScript below is the actual immersive API usage.
// It is embedded here as a Swift string to document the web-side contract.
let theaterPageJS = """
// 1. Feature-detect before showing the immersive button
const model = document.getElementById('theaterModel');
const btn = document.getElementById('enterBtn');
if (typeof model.requestImmersive === 'function') {
btn.style.display = 'block';
}
// 2. Build the entity transform for inline vs immersive presentation
function buildTransform(seatAngle, seatTranslation, isImmersive) {
const mat = new DOMMatrix();
if (!isImmersive) {
mat.translateSelf(seatTranslation.x, seatTranslation.y - 1.0, seatTranslation.z);
}
mat.rotateSelf(0, seatAngle + (isImmersive ? 15 : 0), 0);
return mat;
}
// 3. Sync entity transform whenever immersive state changes
model.addEventListener('immersivechange', () => {
const immersive = document.immersiveElement === model;
const seat = currentSeat();
model.entityTransform = buildTransform(seat.angle, seat.translation, immersive);
document.getElementById('exitBtn').style.display = immersive ? 'block' : 'none';
});
// 4. Request immersive on user tap (must be inside a user-gesture handler)
btn.addEventListener('click', async () => {
try {
await model.requestImmersive();
} catch (e) {
console.error('Immersive request failed:', e);
}
});
"""
#Preview {
ImmersiveWebsiteView()
}⢠requestImmersive() must be called in direct response to a user gesture ā cannot be triggered programmatically. ⢠Inline and immersive coordinate spaces differ: inline uses CSS units centered on the element; immersive uses real-world meters with origin at the user's feet. ⢠Setting display:none on the model element defers asset download until the immersive request fires, saving bandwidth for heavy assets. ⢠The Digital Crown always dismisses the environment, so you must listen to immersive change events to sync your UI state. ⢠Video docking and light-spill baking require custom RealityKit annotations in the USDZ file (via tools like the referenced Blender plugin) and are not yet web standards.
Apple Vision Pro required; API is visionOS-only and gracefully not available on macOS/iOS Safari (feature-detect before use)
More iOS 27 APIs land every week.
Get notified when new capabilities are published ā no noise, just signal.