Embedding YouTube Videos in SwiftUI (Minimal Branding and Smooth Fullscreen)

How I embed YouTube videos in SwiftUI while keeping YouTube branding low and making fullscreen playback feel native.

Published
27 April 2026
Reading time
7 minutes

I needed a way to show YouTube videos inside a SwiftUI app without the app UI flashing underneath when iOS switched to fullscreen. I also wanted the playback flow to feel native, not like a web view awkwardly sitting inside the app.

The requirements were straightforward:

  • Play a YouTube video by ID
  • Keep YouTube branding as minimal as the API and Terms of Service allow
  • Present the native fullscreen player cleanly from SwiftUI

Approach

This is the setup I ended up using:

  1. A WKWebView that loads the official YouTube IFrame API
  2. https://www.youtube-nocookie.com as the player host to reduce the cross-site cookie footprint
  3. playerVars configured for modest branding and fewer end-screen recommendations
  4. A fullscreen handoff on iOS by disabling inline playback, then covering the web view with a native SwiftUI overlay during the transition so the app UI never flashes through

Why I use this pattern

The YouTube IFrame API is still the most practical way to embed and control YouTube playback inside a web view. It is predictable, supported, and gives enough control for this kind of setup.

The other reason this works well is that iOS handles fullscreen playback better than I do. Instead of fighting the system player or trying to fake the transition, I let iOS take over and just manage the timing around it.

The JavaScript bridge is the last piece. window.webkit.messageHandlers lets the page tell SwiftUI when the player is ready, when playback starts, when it ends, and when something goes wrong, which makes the overlay logic much easier to keep in sync.

Implementation

HTML

HTML loaded into the WKWebView (the actual string used in the project):

<!DOCTYPE html>
<html>
  <head>
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, maximum-scale=1"
    />
    <meta name="referrer" content="origin" />
    <style>
      body,
      html {
        width: 100%;
        height: 100%;
        margin: 0;
        padding: 0;
        background-color: black;
        overflow: hidden;
      }
      iframe {
        width: 100%;
        height: 100%;
        border: none;
      }
    </style>
  </head>
  <body>
    <div id="player"></div>
    <script>
      var tag = document.createElement("script")
      tag.src = "https://www.youtube.com/iframe_api"
      var firstScriptTag = document.getElementsByTagName("script")[0]
      firstScriptTag.parentNode.insertBefore(tag, firstScriptTag)


      var player
      function onYouTubeIframeAPIReady() {
        player = new YT.Player("player", {
          height: "100%",
          width: "100%",
          videoId: "VIDEO_ID",
          host: "https://www.youtube-nocookie.com",
          playerVars: {
            playsinline: 0,
            autoplay: 1,
            controls: 1,
            rel: 0,
            modestbranding: 1,
            origin: "https://jacob.com.hk",
          },
          events: {
            onReady: onPlayerReady,
            onStateChange: onPlayerStateChange,
            onError: onPlayerError,
          },
        })
      }


      function onPlayerReady(event) {
        window.webkit.messageHandlers.yt.postMessage("ready")
        event.target.playVideo()
      }


      function onPlayerStateChange(event) {
        if (event.data == YT.PlayerState.PLAYING) {
          window.webkit.messageHandlers.yt.postMessage("playing")
        } else if (
          event.data == YT.PlayerState.ENDED ||
          event.data == YT.PlayerState.PAUSED
        ) {
          window.webkit.messageHandlers.yt.postMessage("ended")
        }
      }


      function onPlayerError(event) {
        window.webkit.messageHandlers.yt.postMessage("error")
      }
    </script>
  </body>
</html>

Options

A few options matter more than the others:

  • host: https://www.youtube-nocookie.com reduces persistent cookies set by YouTube.
  • modestbranding: 1 removes the YouTube logo from the control bar in some cases, but not all of them.
  • rel: 0 no longer removes recommendations entirely. These days it mostly limits them to the same channel.
  • playsinline: 0, together with allowsInlineMediaPlayback = false on the WKWebView configuration, pushes iOS toward the native fullscreen player.

SwiftUI wrapper and message bridge

On the SwiftUI side, YouTubePlayerView acts as a fullscreen overlay and shows a black ProgressView while the player starts up.

YouTubeWebView is the UIViewRepresentable wrapper around WKWebView. It injects the HTML, passes in the video ID, and registers a WKScriptMessageHandler called "yt".

From there, the JavaScript sends simple state messages back into the app:

  • "ready"
  • "playing"
  • "ended"
  • "error"

The coordinator listens for those messages, updates the bindings, and calls onEnded when playback finishes or the player exits in a way your UI should treat as dismissal.

Full Swift implementation

import SwiftUI
import WebKit


struct YouTubePlayerView: View {
    let videoId: String
    let onDismiss: () -> Void


    @State private var isPlaying = false
    @State private var isReady = false
    @State private var showOverlay = true


    var body: some View {
        ZStack {
            if showOverlay {
                Color.black.ignoresSafeArea()


                ProgressView()
                    .progressViewStyle(CircularProgressViewStyle(tint: .white))
            }


            YouTubeWebView(
                videoId: videoId,
                isReady: $isReady,
                isPlaying: $isPlaying,
                showOverlay: $showOverlay,
                onEnded: onDismiss
            )
            .ignoresSafeArea()
            .opacity(0) // Fully hide the webview without making it 1x1 so YouTube loads high quality
            .allowsHitTesting(false)
        }
    }
}


private struct YouTubeWebView: UIViewRepresentable {
    let videoId: String
    @Binding var isReady: Bool
    @Binding var isPlaying: Bool
    @Binding var showOverlay: Bool
    let onEnded: () -> Void


    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }


    func makeUIView(context: Context) -> WKWebView {
        let config = WKWebViewConfiguration()
        config.allowsInlineMediaPlayback = false
        config.mediaTypesRequiringUserActionForPlayback = []


        let userContentController = WKUserContentController()
        userContentController.add(context.coordinator, name: "yt")
        config.userContentController = userContentController


        let webView = WKWebView(frame: .zero, configuration: config)
        webView.scrollView.isScrollEnabled = false
        webView.backgroundColor = .black
        webView.isOpaque = false


        let html = """
        <!DOCTYPE html>
        <html>
        <head>
            <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
            <meta name="referrer" content="origin">
            <style>
                body, html { width: 100%; height: 100%; margin: 0; padding: 0; background-color: black; overflow: hidden; }
                iframe { width: 100%; height: 100%; border: none; }
            </style>
        </head>
        <body>
            <div id="player"></div>
            <script>
                var tag = document.createElement('script');
                tag.src = "https://www.youtube.com/iframe_api";
                var firstScriptTag = document.getElementsByTagName('script')[0];
                firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);


                var player;
                function onYouTubeIframeAPIReady() {
                    player = new YT.Player('player', {
                        height: '100%',
                        width: '100%',
                        videoId: '\(videoId)',
                        host: 'https://www.youtube-nocookie.com',
                        playerVars: {
                            'playsinline': 0,
                            'autoplay': 1,
                            'controls': 1,
                            'rel': 0,
                            'modestbranding': 1,
                            'origin': 'https://jacob.com.hk'
                        },
                        events: {
                            'onReady': onPlayerReady,
                            'onStateChange': onPlayerStateChange,
                            'onError': onPlayerError
                        }
                    });
                }


                function onPlayerReady(event) {
                    window.webkit.messageHandlers.yt.postMessage('ready');
                    event.target.playVideo();
                }


                function onPlayerStateChange(event) {
                    if (event.data == YT.PlayerState.PLAYING) {
                        window.webkit.messageHandlers.yt.postMessage('playing');
                    } else if (event.data == YT.PlayerState.ENDED || event.data == YT.PlayerState.PAUSED) {
                        // Dismiss the overlay if the video ends or pauses (e.g. when exiting the native fullscreen player)
                        window.webkit.messageHandlers.yt.postMessage('ended');
                    }
                }


                function onPlayerError(event) {
                    window.webkit.messageHandlers.yt.postMessage('error');
                }
            </script>
        </body>
        </html>
        """


        webView.loadHTMLString(html, baseURL: URL(string: "https://jacob.com.hk/"))
        return webView
    }


    func updateUIView(_ uiView: WKWebView, context: Context) {}


    class Coordinator: NSObject, WKScriptMessageHandler {
        var parent: YouTubeWebView


        init(parent: YouTubeWebView) {
            self.parent = parent
        }


        func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
            guard message.name == "yt", let body = message.body as? String else { return }


            DispatchQueue.main.async {
                switch body {
                case "ready":
                    self.parent.isReady = true
                case "playing":
                    if !self.parent.isPlaying {
                        self.parent.isPlaying = true
                        // Delay hiding the overlay so the native fullscreen player has time to present,
                        // preventing the app UI from flashing underneath during the transition.
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
                            withAnimation(.easeOut(duration: 0.3)) {
                                self.parent.showOverlay = false
                            }
                        }
                    }
                case "ended":
                    self.parent.onEnded()
                case "error":
                    // fallback to just show player if error prevents autoplay
                    if !self.parent.isPlaying {
                        self.parent.isPlaying = true
                        self.parent.showOverlay = false
                    }
                default:
                    break
                }
            }
        }
    }
}

The trick that stops the UI flash

This was the part that mattered most in practice.

When the player jumps into native fullscreen, iOS creates its own player overlay. If your web view is still visible during that handoff, the app UI can flash underneath for a moment, which looks rough.

What fixed it for me was simple:

  • Keep the WKWebView in the hierarchy so YouTube still loads normally
  • Hide it with opacity(0) and allowsHitTesting(false)
  • Show a full-screen black SwiftUI overlay while the player starts
  • Wait until JavaScript reports "playing"
  • Delay very slightly, then fade the overlay out after the native player is already on screen

That short delay is doing a lot of work. Without it, the transition is much more likely to show the app underneath.

Alternatives

If you want different tradeoffs, there are two obvious alternatives.

  1. Use the IFrame API with controls=0 and build your own native controls around it. That gives you more control over the surrounding UI, but it does not remove YouTube’s branding rules and it means more work.
  2. Host the video yourself and use AVPlayer, assuming you have the rights to do that. That is the cleanest option if you want complete control over playback UI, branding, and fullscreen behavior.

This setup is a good middle ground. You still use YouTube’s supported embed path, but the fullscreen experience feels much closer to a native player than a web embed jammed into SwiftUI.