How to Implement Safari View in SwiftUI

iOS 17+ Xcode 16+ Beginner APIs: SafariView · SFSafariViewController · UIViewControllerRepresentable Updated: May 11, 2026
TL;DR

SwiftUI has no built-in SafariView, so you wrap SFSafariViewController in a UIViewControllerRepresentable struct and present it with .sheet. The user gets a full, sandboxed in-app browser — cookies, Reader Mode, and all — in about 10 lines.

import SwiftUI
import SafariServices

struct SafariView: UIViewControllerRepresentable {
    let url: URL
    func makeUIViewController(context: Context) -> SFSafariViewController {
        SFSafariViewController(url: url)
    }
    func updateUIViewController(_ vc: SFSafariViewController, context: Context) {}
}

// In any SwiftUI view:
.sheet(isPresented: $showSafari) {
    SafariView(url: URL(string: "https://apple.com")!)
        .ignoresSafeArea()
}

Full implementation

The complete version adds an initializer that exposes the two most useful SFSafariViewController.Configuration options — Reader Mode and bar collapsing — plus preferred tint colours so the chrome matches your app. Everything is still presented modally via .sheet, which is the only layout that works correctly with SFSafariViewController on iPhone.

import SwiftUI
import SafariServices

// MARK: - Reusable SafariView wrapper

struct SafariView: UIViewControllerRepresentable {
    let url: URL
    private let configuration: SFSafariViewController.Configuration

    init(
        url: URL,
        entersReaderIfAvailable: Bool = false,
        barCollapsingEnabled: Bool = true
    ) {
        self.url = url
        let config = SFSafariViewController.Configuration()
        config.entersReaderIfAvailable = entersReaderIfAvailable
        config.barCollapsingEnabled = barCollapsingEnabled
        self.configuration = config
    }

    func makeUIViewController(context: Context) -> SFSafariViewController {
        let vc = SFSafariViewController(url: url, configuration: configuration)
        vc.preferredBarTintColor = .systemBackground
        vc.preferredControlTintColor = .systemBlue
        vc.dismissButtonStyle = .done
        return vc
    }

    func updateUIViewController(
        _ uiViewController: SFSafariViewController,
        context: Context
    ) {}
}

// MARK: - Example screen

struct BrowserDemoView: View {
    @State private var showSafari = false
    private let docsURL = URL(string: "https://developer.apple.com/documentation/swiftui")!

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                Image(systemName: "safari.fill")
                    .font(.system(size: 64))
                    .foregroundStyle(.blue)

                Text("SwiftUI Safari View")
                    .font(.title2.bold())

                Text("Opens a full in-app browser with\nReader Mode, history, and sharing.")
                    .multilineTextAlignment(.center)
                    .foregroundStyle(.secondary)

                Button {
                    showSafari = true
                } label: {
                    Label("Open SwiftUI Docs", systemImage: "safari")
                        .frame(maxWidth: .infinity)
                        .padding(.vertical, 14)
                        .background(.blue)
                        .foregroundStyle(.white)
                        .clipShape(RoundedRectangle(cornerRadius: 14))
                }
                .accessibilityLabel("Open SwiftUI documentation in Safari")
                .padding(.horizontal)
            }
            .navigationTitle("Browser Demo")
        }
        .sheet(isPresented: $showSafari) {
            SafariView(url: docsURL, entersReaderIfAvailable: false)
                .ignoresSafeArea()
        }
    }
}

#Preview {
    BrowserDemoView()
}

How it works

  1. UIViewControllerRepresentable bridge. The SafariView struct adopts UIViewControllerRepresentable, which is SwiftUI's mechanism for hosting any UIKit view controller inside a SwiftUI hierarchy. You implement two required methods: makeUIViewController (called once) and updateUIViewController (called on every SwiftUI state change).
  2. SFSafariViewController.Configuration. The Configuration object is created before the controller and passed to its initializer. Setting entersReaderIfAvailable = true auto-activates Reader Mode when the page supports it — useful for article-heavy apps. barCollapsingEnabled lets the address bar hide on scroll for more reading space.
  3. Tint customisation via UIKit properties. preferredBarTintColor and preferredControlTintColor are set on the returned SFSafariViewController instance inside makeUIViewController. These mirror your app's colour scheme; .systemBackground adapts automatically to Dark Mode.
  4. Modal presentation with .sheet. SFSafariViewController must be presented modally — embedding it inside a NavigationStack or VStack directly causes layout and interaction bugs. The .sheet(isPresented:) modifier gives the controller a proper modal context on both iPhone and iPad.
  5. .ignoresSafeArea() on the sheet content. Without .ignoresSafeArea(), the Safari toolbar is inset by the sheet's safe area insets on iPhones with home indicators, producing an awkward gap. Applying it to the SafariView inside the sheet body fixes this.

Variants

Reader Mode for articles

Pass entersReaderIfAvailable: true when linking to long-form articles. If the destination page is not Reader-compatible, Safari silently falls back to normal mode.

struct ArticleView: View {
    @State private var showArticle = false
    let articleURL = URL(string: "https://www.swift.org/blog/")!

    var body: some View {
        Button("Read Swift Blog") {
            showArticle = true
        }
        .sheet(isPresented: $showArticle) {
            SafariView(
                url: articleURL,
                entersReaderIfAvailable: true   // ← auto Reader Mode
            )
            .ignoresSafeArea()
        }
    }
}

Full-screen immersive browser

Replace .sheet with .fullScreenCover for a cinematic, edge-to-edge browser with no sheet drag-to-dismiss handle. The user still gets the built-in Done button from dismissButtonStyle = .done (set in makeUIViewController) to close the view. This pattern works well for OAuth flows or in-app onboarding pages where you want the browser to feel like part of your app rather than a temporary overlay.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement safari view in SwiftUI for iOS 17+.
Use SFSafariViewController and UIViewControllerRepresentable (SafariView).
Expose entersReaderIfAvailable and preferredControlTintColor as parameters.
Present via .sheet with .ignoresSafeArea().
Make it accessible (VoiceOver labels on the trigger button).
Add a #Preview with realistic sample data.

In the Soarias Build phase, paste this prompt into a feature ticket to let Claude Code scaffold the SafariView wrapper and wire it to your existing Link or Button components in one shot.

Related

FAQ

Does this work on iOS 16?
Yes. SFSafariViewController has been available since iOS 9, and UIViewControllerRepresentable since SwiftUI's first release on iOS 13. The wrapper shown here compiles and runs correctly on iOS 16. The only iOS 17-specific feature referenced is the #Preview macro in the preview — swap it for a PreviewProvider if your deployment target is iOS 16.
Can I intercept URLs or inject JavaScript into SafariView?
No — and this is intentional. SFSafariViewController runs in a separate process for privacy: it shares the system cookie jar with Safari but exposes no navigation delegate or script handler to your app. If you need URL interception, OAuth callback handling, or DOM manipulation, use WKWebView wrapped in a UIViewControllerRepresentable (see how-to-build-web-view). For OAuth specifically, prefer ASWebAuthenticationSession instead of SFSafariViewController — it handles the redirect URL callback natively.
What is the UIKit equivalent?
In UIKit you create an instance of SFSafariViewController directly and present it with present(safariVC, animated: true) from any UIViewController. The SwiftUI wrapper shown on this page does exactly the same thing under the hood — makeUIViewController returns the SFSafariViewController and SwiftUI's hosting machinery calls present for you when the .sheet activates.

Last reviewed: 2026-05-11 by the Soarias team.