Why SwiftUI Finally Has a Native WebView
For years, embedding web content in a SwiftUI app meant wrapping UIKit's WKWebView inside a UIViewRepresentable bridge. It worked, sure—but it was clunky. You lost SwiftUI's declarative elegance, had to manage coordinators manually, and constantly fought against two fundamentally different UI paradigms. Honestly, it felt like duct-taping two worlds together.
With iOS 26, Apple finally fixed this by shipping a native WebView and its companion model WebPage, both designed from the ground up for Swift and SwiftUI.
This guide covers everything you need to embed, control, and communicate with web content using these new APIs. Whether you're building a simple in-app browser, a documentation viewer, or a hybrid app with deep JavaScript integration, you'll find working code and practical patterns here. So, let's dive in.
Getting Started: Your First WebView
The simplest way to display a web page in SwiftUI is literally a single line of code. Import WebKit alongside SwiftUI and pass a URL directly to WebView:
import SwiftUI
import WebKit
struct SimpleWebView: View {
var body: some View {
WebView(url: URL(string: "https://developer.apple.com/swift")!)
}
}
That's it. Seriously.
You get a fully functional web browser view powered by the same WebKit engine that runs Safari. The user can scroll, tap links, and interact with the page just as they would in a real browser. Unlike the old UIViewRepresentable approach, this view participates naturally in SwiftUI's layout system, responds to view modifiers, and requires zero boilerplate.
Making the URL Dynamic with @State
In real apps, you rarely hardcode a URL. Use @State to make it reactive:
struct DynamicWebView: View {
@State private var url = URL(string: "https://developer.apple.com")!
var body: some View {
VStack {
WebView(url: url)
HStack {
Button("Swift Docs") {
url = URL(string: "https://docs.swift.org")!
}
Button("SwiftUI Docs") {
url = URL(string: "https://developer.apple.com/swiftui/")!
}
}
.padding()
}
}
}
When the URL changes, WebView automatically loads the new page. Pure SwiftUI reactivity—no delegates, no manual reload calls. It just works.
WebPage: Taking Full Control of Web Content
The URL-based WebView initializer is great for simple cases, but most real apps need more than that: loading progress indicators, page titles for navigation bars, JavaScript execution, navigation policy control. That's where WebPage comes in.
WebPage is an Observable class that models a live web browsing session. You create it as a @State property, pass it to WebView, and then read its properties or call its methods to interact with the loaded content. Think of it as the "brain" behind your web view.
struct ControlledWebView: View {
@State private var page = WebPage()
var body: some View {
VStack(spacing: 0) {
// Navigation bar showing the page title
HStack {
Text(page.title ?? "Loading...")
.font(.headline)
.lineLimit(1)
Spacer()
if page.isLoading {
ProgressView()
}
}
.padding()
// Progress bar
ProgressView(value: page.estimatedProgress)
.opacity(page.isLoading ? 1 : 0)
// The web view
WebView(page)
}
.onAppear {
page.load(URLRequest(url: URL(string: "https://swift.org")!))
}
}
}
Key WebPage Properties
Because WebPage conforms to Observable, all its properties trigger SwiftUI view updates automatically. Here are the ones you'll reach for most often:
title— The current page's<title>element, updated as the user navigatesurl— The current URL, which changes as pages load and redirects happenestimatedProgress— ADoublefrom 0.0 to 1.0 representing load progressisLoading— A Boolean indicating whether content is currently loadinghasOnlySecureContent— Whether the page was loaded entirely over HTTPSbackForwardList— The navigation history, handy for building back/forward controls
Building a Mini Browser with Navigation Controls
One thing the native WebView doesn't include is built-in navigation chrome—no back button, no forward button, no address bar. That's actually by design: Apple gives you the building blocks and lets you compose the UI you need.
I think this is the right call. It means you're never fighting against a default UI that doesn't match your app's design. Here's how to build a fully functional mini browser:
struct MiniBrowser: View {
@State private var page = WebPage()
@State private var urlText = "https://swift.org"
var body: some View {
VStack(spacing: 0) {
// Address bar
HStack {
TextField("Enter URL", text: $urlText)
.textFieldStyle(.roundedBorder)
.textInputAutocapitalization(.never)
.keyboardType(.URL)
.onSubmit { loadURL() }
Button("Go") { loadURL() }
.buttonStyle(.bordered)
}
.padding(.horizontal)
.padding(.vertical, 8)
// Progress indicator
if page.isLoading {
ProgressView(value: page.estimatedProgress)
}
// Web content
WebView(page)
// Navigation toolbar
HStack(spacing: 24) {
Button(action: { page.goBack() }) {
Image(systemName: "chevron.left")
}
.disabled(!page.canGoBack)
Button(action: { page.goForward() }) {
Image(systemName: "chevron.right")
}
.disabled(!page.canGoForward)
Button(action: { page.reload() }) {
Image(systemName: "arrow.clockwise")
}
Spacer()
Button(action: { page.stopLoading() }) {
Image(systemName: "xmark")
}
.disabled(!page.isLoading)
}
.padding()
}
.onAppear { loadURL() }
.onChange(of: page.url) { _, newURL in
if let newURL {
urlText = newURL.absoluteString
}
}
}
private func loadURL() {
var urlString = urlText.trimmingCharacters(in: .whitespaces)
if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
urlString = "https://" + urlString
}
guard let url = URL(string: urlString) else { return }
page.load(URLRequest(url: url))
}
}
This example demonstrates several key patterns: using canGoBack and canGoForward to conditionally enable navigation buttons, syncing the address bar with the current URL via onChange, and handling the loading state for both a progress bar and a stop button. It's not a ton of code for what you get.
JavaScript Communication with callJavaScript
Alright, this is where things get really interesting. One of WebPage's most powerful features is callJavaScript(_:arguments:in:contentWorld:). It lets you execute JavaScript on the loaded page and receive results back, all using Swift's native async/await syntax.
Basic JavaScript Execution
struct JavaScriptExample: View {
@State private var page = WebPage()
@State private var pageTitle = ""
var body: some View {
VStack {
WebView(page)
Text("JS Title: \(pageTitle)")
.padding()
Button("Get Title via JS") {
Task {
do {
let result = try await page.callJavaScript(
"document.title"
)
if let title = result as? String {
pageTitle = title
}
} catch {
print("JS error: \(error)")
}
}
}
}
.onAppear {
page.load(URLRequest(url: URL(string: "https://swift.org")!))
}
}
}
Passing Arguments Safely
Hard-coding values into JavaScript strings is both fragile and dangerous—it opens the door to injection attacks. The arguments parameter solves this by letting you pass Swift values that become local JavaScript variables:
// Scroll to a specific element by its ID
func scrollToElement(id: String) async {
let script = """
const el = document.getElementById(elementId);
if (el) {
el.scrollIntoView({ behavior: 'smooth' });
return true;
}
return false;
"""
do {
let result = try await page.callJavaScript(
script,
arguments: ["elementId": id]
)
let found = result as? Bool ?? false
print("Element \(id) found: \(found)")
} catch {
print("Scroll failed: \(error)")
}
}
In this example, elementId is available as a local variable inside the JavaScript scope. You never need to worry about escaping special characters or quote handling—WebKit handles the serialization securely. This is one of those details that seems small but saves you from nasty bugs down the road.
Injecting Custom CSS
A really common use case is modifying the appearance of loaded web content to match your app's design. You can inject CSS by creating a <style> element via JavaScript:
func injectDarkModeCSS() async {
let script = """
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
"""
let css = """
body {
background-color: #1a1a1a !important;
color: #e0e0e0 !important;
}
a { color: #58a6ff !important; }
"""
do {
try await page.callJavaScript(script, arguments: ["css": css])
} catch {
print("CSS injection failed: \(error)")
}
}
This pattern is especially useful when you're embedding third-party content (like help docs or terms of service pages) and want them to respect your app's dark mode setting.
Controlling Navigation with NavigationDeciding
In many apps, you don't want users following arbitrary links inside your WebView. Maybe you're building a help viewer that should stay within your documentation domain, or you want external links to open in Safari instead. The WebPage.NavigationDeciding protocol gives you this control.
class DomainRestrictedNavigator: WebPage.NavigationDeciding {
private let allowedDomains: Set<String>
init(domains: [String]) {
self.allowedDomains = Set(domains)
}
func decidePolicy(
for action: WebPage.NavigationAction,
preferences: inout WebPage.NavigationPreferences
) async -> WKNavigationActionPolicy {
guard let url = action.request.url,
let host = url.host() else {
return .cancel
}
// Allow navigation within approved domains
if allowedDomains.contains(where: { host.hasSuffix($0) }) {
return .allow
}
// Open everything else in Safari
await MainActor.run {
UIApplication.shared.open(url)
}
return .cancel
}
}
To use this, pass the navigator when creating your WebPage:
@State private var page = WebPage(
navigationDecider: DomainRestrictedNavigator(
domains: ["swift.org", "developer.apple.com"]
)
)
Now any link pointing outside Swift.org or Apple's developer site opens in the system browser instead. Navigation within those domains works normally. Simple and effective.
Handling JavaScript Dialogs with DialogPresenting
Web pages can trigger alert(), confirm(), and prompt() dialogs. By default, WebView shows these as system alerts, but you might want to style them differently or log them for debugging. The WebPage.DialogPresenting protocol lets you intercept and replace these dialogs with native SwiftUI UI:
class SwiftUIDialogPresenter: WebPage.DialogPresenting {
@Published var alertMessage: String?
@Published var showAlert = false
func webPage(
_ page: WebPage,
handleJavaScriptAlert message: String,
initiatedByFrame frame: WebPage.FrameInfo
) async {
await MainActor.run {
alertMessage = message
showAlert = true
}
}
}
Then in your view, bind the presenter's published properties to a SwiftUI .alert() modifier to present dialogs that match your app's visual style. It's a nice touch that makes your app feel more polished.
Configuring WebPage Behavior
The WebPage.Configuration struct lets you customize the browsing environment before the page loads. Here are the key options and when you'd use them:
func makeConfiguredPage() -> WebPage {
var config = WebPage.Configuration()
// Identify your app in the user agent string
config.applicationNameForUserAgent = "MyApp/2.0"
// Enable AirPlay for media-heavy content
config.allowsAirPlayForMediaPlayback = true
// Auto-detect phone numbers, addresses, and links
config.dataDetectorTypes = [.phoneNumber, .link, .address]
// Enable inline text predictions
config.allowsInlinePredictions = true
return WebPage(configuration: config)
}
One important thing to note: configuration options are set during initialization. The configuration is immutable after the WebPage is created, so plan ahead for what your browsing session needs.
Useful View Modifiers for WebView
SwiftUI's WebView supports several view modifiers that customize user interaction beyond what WebPage itself provides:
.webViewScrollPosition(_:)— Bind to a scroll position value for programmatic scrolling and reading the user's scroll offset.webViewMagnificationGestures(enabled:)— Enable or disable pinch-to-zoom.findNavigator(isPresented:)— Show the built-in find-in-page interface (the same one Safari uses, which is a nice detail)
struct EnhancedWebView: View {
@State private var page = WebPage()
@State private var showFind = false
var body: some View {
WebView(page)
.findNavigator(isPresented: $showFind)
.webViewMagnificationGestures(enabled: true)
.toolbar {
ToolbarItem {
Button("Find") { showFind.toggle() }
}
}
.onAppear {
page.load(URLRequest(
url: URL(string: "https://docs.swift.org")!
))
}
}
}
Loading Local HTML Content
You don't always need a remote URL. WebPage can load HTML strings directly, which is perfect for rendering formatted content, email previews, or dynamically generated documents:
struct LocalHTMLView: View {
@State private var page = WebPage()
var body: some View {
WebView(page)
.onAppear {
let html = """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport"
content="width=device-width, initial-scale=1">
<style>
body {
font-family: -apple-system, system-ui;
padding: 20px;
line-height: 1.6;
}
h1 { color: #007AFF; }
code {
background: #f0f0f0;
padding: 2px 6px;
border-radius: 4px;
}
</style>
</head>
<body>
<h1>Welcome</h1>
<p>This content is rendered from a local
HTML string using <code>WebPage</code>.</p>
</body>
</html>
"""
page.loadHTML(html, baseURL: nil)
}
}
}
I've used this pattern a lot for displaying rich text content that's generated on-device—things like Markdown previews or styled error pages. It's surprisingly versatile.
Supporting Older iOS Versions
Since WebView and WebPage require iOS 26, you'll need a compatibility strategy if your app still supports earlier versions. The cleanest approach is a conditional wrapper:
struct CompatibleWebView: View {
let url: URL
var body: some View {
if #available(iOS 26, *) {
WebView(url: url)
} else {
LegacyWebView(url: url)
}
}
}
// Fallback for iOS 25 and earlier
struct LegacyWebView: UIViewRepresentable {
let url: URL
func makeUIView(context: Context) -> WKWebView {
WKWebView()
}
func updateUIView(_ webView: WKWebView, context: Context) {
webView.load(URLRequest(url: url))
}
}
This lets you adopt the new API right away while keeping your app available to users who haven't updated yet. Over time, as your minimum deployment target advances, you can remove the fallback entirely.
Known Caveats and Workarounds
The native SwiftUI WebView is a major step forward, but there are a few rough edges worth knowing about (as of the initial iOS 26 release):
- Alerts in sheets: Presenting a
WebViewinside a.sheetor.fullScreenCovercan cause issues where alerts trigger a page reload. UseNavigationStackornavigationDestinationas an alternative to avoid this bug. - Safe area layout: Some developers have reported layout glitches when using
.ignoresSafeArea()withWebView. Test your layout carefully on different device sizes—especially with the Dynamic Island. - Cookie management: While
WebPagehandles cookies within its session automatically, sharing cookies between yourWebViewandURLSessionnetwork requests still requires manual synchronization throughWKHTTPCookieStore. This is a common gotcha. - Deep WKWebView configuration: If you need user scripts, custom URL scheme handlers, or other low-level configuration not yet exposed through
WebPage.Configuration, you may still need to fall back to a UIKit-wrappedWKWebView. Hopefully Apple will close these gaps in future releases.
Real-World Example: Documentation Viewer
Let's put everything together into something practical—a documentation viewer that restricts navigation to specific domains, tracks page titles, provides search, and communicates with JavaScript:
struct DocumentationViewer: View {
@State private var page = WebPage(
navigationDecider: DomainRestrictedNavigator(
domains: ["docs.swift.org", "developer.apple.com"]
)
)
@State private var showFind = false
@State private var headings: [String] = []
var body: some View {
NavigationStack {
VStack(spacing: 0) {
if page.isLoading {
ProgressView(value: page.estimatedProgress)
}
WebView(page)
.findNavigator(isPresented: $showFind)
}
.navigationTitle(page.title ?? "Documentation")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button {
showFind.toggle()
} label: {
Image(systemName: "magnifyingglass")
}
Menu {
ForEach(headings, id: \.self) { heading in
Button(heading) {
Task {
await scrollToHeading(heading)
}
}
}
} label: {
Image(systemName: "list.bullet")
}
.disabled(headings.isEmpty)
}
ToolbarItemGroup(placement: .bottomBar) {
Button(action: { page.goBack() }) {
Image(systemName: "chevron.left")
}
.disabled(!page.canGoBack)
Button(action: { page.goForward() }) {
Image(systemName: "chevron.right")
}
.disabled(!page.canGoForward)
Spacer()
Button(action: { page.reload() }) {
Image(systemName: "arrow.clockwise")
}
}
}
}
.onAppear {
page.load(URLRequest(
url: URL(string: "https://docs.swift.org/swift-book/documentation/the-swift-programming-language/")!
))
}
.onChange(of: page.isLoading) { _, isLoading in
if !isLoading {
Task { await extractHeadings() }
}
}
}
private func extractHeadings() async {
let script = """
Array.from(document.querySelectorAll('h2, h3'))
.map(h => h.textContent.trim())
"""
do {
let result = try await page.callJavaScript(script)
if let titles = result as? [String] {
headings = titles
}
} catch {
print("Failed to extract headings: \(error)")
}
}
private func scrollToHeading(_ title: String) async {
let script = """
const headings = document.querySelectorAll('h2, h3');
for (const h of headings) {
if (h.textContent.trim() === targetTitle) {
h.scrollIntoView({ behavior: 'smooth' });
break;
}
}
"""
do {
try await page.callJavaScript(
script,
arguments: ["targetTitle": title]
)
} catch {
print("Scroll failed: \(error)")
}
}
}
This viewer combines domain-restricted navigation, a dynamic table of contents extracted via JavaScript, find-in-page search, and standard browser navigation—all in pure SwiftUI with zero UIKit bridging. It's a solid foundation you can adapt for your own app.
Frequently Asked Questions
What is the difference between WebView and WebPage in SwiftUI?
WebView is a SwiftUI view that displays web content on screen. WebPage is an Observable model class that represents and controls the browsing session. Use WebView(url:) for simple one-line embeds. Use WebPage with WebView(page) when you need access to the page title, URL, loading state, JavaScript execution, or navigation control. Think of WebView as the display and WebPage as the engine.
Can I use SwiftUI WebView on iOS versions earlier than iOS 26?
No. The native WebView and WebPage APIs require iOS 26 as the minimum deployment target. For apps that support earlier iOS versions, use #available(iOS 26, *) to conditionally show the native WebView, and fall back to a UIViewRepresentable wrapper around WKWebView on older systems.
How do I execute JavaScript and get a return value from WebPage?
Use page.callJavaScript(_:arguments:), which is an async throws method. It returns Any?, which you cast to the expected Swift type. For example, let count = try await page.callJavaScript("document.images.length") as? Int. Always use the arguments parameter to pass dynamic values securely instead of string interpolation.
Does SwiftUI WebView have built-in back and forward buttons?
No. The WebView intentionally doesn't include navigation chrome. You build your own navigation UI using WebPage properties like canGoBack, canGoForward, and methods like goBack() and goForward(). This gives you full design control over the browsing experience.
How do I restrict which URLs a WebView can navigate to?
Implement the WebPage.NavigationDeciding protocol and pass your implementation when creating a WebPage. In the decidePolicy(for:preferences:) method, inspect the URL and return .allow or .cancel. You can redirect blocked URLs to Safari using UIApplication.shared.open(url) before returning .cancel.