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.
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:
- A
WKWebViewthat loads the official YouTube IFrame API https://www.youtube-nocookie.comas the player host to reduce the cross-site cookie footprintplayerVarsconfigured for modest branding and fewer end-screen recommendations- 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.comreduces persistent cookies set by YouTube.modestbranding: 1removes the YouTube logo from the control bar in some cases, but not all of them.rel: 0no longer removes recommendations entirely. These days it mostly limits them to the same channel.playsinline: 0, together withallowsInlineMediaPlayback = falseon theWKWebViewconfiguration, 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
WKWebViewin the hierarchy so YouTube still loads normally - Hide it with
opacity(0)andallowsHitTesting(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.
- Use the IFrame API with
controls=0and 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. - 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.