Skip to content

Video Poster To Player

A poster with a play button that opens into the playing video in the same frame through a growing circle.

Video Poster To Player, playing
  • Media
  • iOS 18.0+
  • intermediate
Download .swift
Origin
Original work
Found by
video · poster · thumbnail · play button · reveal · transition · inline player · vídeo · póster · miniatura · botón de reproducir · revelar · transición

A poster with a play button that opens into the playing video, in the same frame.

Pass any views: usually Image("poster").resizable().scaledToFill() and VideoPlayer(player: player). Start and stop your player from isPlaying. With Reduce Motion on, the video fades in instead of opening from the button.

struct VideoPosterToPlayerDemo: View {
@State private var isPlaying = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
ZStack {
LinearGradient(colors: [Color(white: 0.1), .black], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
VStack(alignment: .leading, spacing: 16) {
VideoPosterToPlayer(isPlaying: $isPlaying) {
LinearGradient(colors: [.teal, .blue, .indigo], startPoint: .topLeading, endPoint: .bottomTrailing)
.overlay(alignment: .bottomLeading) {
Label("Trailer", systemImage: "film")
.font(.headline)
.foregroundStyle(.white)
.padding(16)
}
} player: {
videoPlaceholder
}
Text("The Long Way Home")
.font(.title2.bold())
.foregroundStyle(.white)
Text(isPlaying ? "Now playing" : "A two-minute trailer about a very long walk.")
.font(.body)
.foregroundStyle(.white.opacity(0.7))
}
.padding(20)
}
}
private var videoPlaceholder: some View {
TimelineView(.animation(minimumInterval: 1.0 / 20.0, paused: reduceMotion || !isPlaying)) { context in
let time = context.date.timeIntervalSinceReferenceDate
LinearGradient(
colors: [.orange, .pink, .purple],
startPoint: UnitPoint(x: 0.5 + sin(time * 0.9) * 0.5, y: 0),
endPoint: UnitPoint(x: 0.5 - sin(time * 0.9) * 0.5, y: 1)
)
}
.overlay {
Image(systemName: "waveform")
.font(.largeTitle)
.foregroundStyle(.white.opacity(0.6))
}
.accessibilityHidden(true)
}
}
ParameterTypeDefault
isPlayingBinding<Bool>Required
aspectRatioCGFloat16.0 / 9.0
cornerRadiusCGFloat22
poster() -> PosterRequired
player() -> PlayerRequired

Copy this code into your project, or download the file.

VideoPosterToPlayer.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// A poster with a play button that opens into the playing video, in the same frame.
///
/// Pass any views: usually `Image("poster").resizable().scaledToFill()` and
/// `VideoPlayer(player: player)`. Start and stop your player from `isPlaying`.
/// With Reduce Motion on, the video fades in instead of opening from the button.
struct VideoPosterToPlayer<Poster: View, Player: View>: View {
@Binding var isPlaying: Bool
var aspectRatio: CGFloat = 16.0 / 9.0
var cornerRadius: CGFloat = 22
@ViewBuilder var poster: () -> Poster
@ViewBuilder var player: () -> Player
@ScaledMetric(relativeTo: .title) private var buttonSize = 72.0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var animation: Animation? { reduceMotion ? nil : .smooth(duration: 0.6) }
var body: some View {
Color.clear
.aspectRatio(aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : 16.0 / 9.0, contentMode: .fit)
.overlay { poster().accessibilityHidden(true) }
.overlay {
if isPlaying {
player().transition(reduceMotion ? .opacity : AnyTransition(VideoPosterToPlayerReveal()))
}
}
.overlay(alignment: .topTrailing) {
if isPlaying {
Button {
withAnimation(animation) { isPlaying = false }
} label: {
Image(systemName: "xmark")
.font(.footnote.bold())
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(.black.opacity(0.5), in: .circle)
// The padding is part of the button, so all of it answers to a touch.
.padding(10)
.contentShape(.rect)
}
.accessibilityLabel("Close video")
.transition(.opacity)
}
}
.overlay {
if !isPlaying {
Button {
withAnimation(animation) { isPlaying = true }
} label: {
Image(systemName: "play.fill")
.font(.title)
.foregroundStyle(.white)
.frame(width: buttonSize, height: buttonSize)
.background(.ultraThinMaterial, in: .circle)
}
.buttonStyle(.plain)
.accessibilityLabel("Play video")
.transition(reduceMotion ? .opacity : .scale(scale: 1.8).combined(with: .opacity))
}
}
.clipShape(.rect(cornerRadius: cornerRadius))
}
}
/// Reveals the player through a circle that grows from the center until it covers the frame.
private struct VideoPosterToPlayerReveal: Transition {
func body(content: Content, phase: TransitionPhase) -> some View {
content.mask {
GeometryReader { proxy in
let diameter = hypot(proxy.size.width, proxy.size.height)
Circle()
.frame(width: diameter, height: diameter)
.scaleEffect(phase.isIdentity ? 1 : 0.05)
.position(x: proxy.size.width / 2, y: proxy.size.height / 2)
}
}
}
}
// MARK: - Demo
struct VideoPosterToPlayerDemo: View {
@State private var isPlaying = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
ZStack {
LinearGradient(colors: [Color(white: 0.1), .black], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
VStack(alignment: .leading, spacing: 16) {
VideoPosterToPlayer(isPlaying: $isPlaying) {
LinearGradient(colors: [.teal, .blue, .indigo], startPoint: .topLeading, endPoint: .bottomTrailing)
.overlay(alignment: .bottomLeading) {
Label("Trailer", systemImage: "film")
.font(.headline)
.foregroundStyle(.white)
.padding(16)
}
} player: {
videoPlaceholder
}
Text("The Long Way Home")
.font(.title2.bold())
.foregroundStyle(.white)
Text(isPlaying ? "Now playing" : "A two-minute trailer about a very long walk.")
.font(.body)
.foregroundStyle(.white.opacity(0.7))
}
.padding(20)
}
}
private var videoPlaceholder: some View {
TimelineView(.animation(minimumInterval: 1.0 / 20.0, paused: reduceMotion || !isPlaying)) { context in
let time = context.date.timeIntervalSinceReferenceDate
LinearGradient(
colors: [.orange, .pink, .purple],
startPoint: UnitPoint(x: 0.5 + sin(time * 0.9) * 0.5, y: 0),
endPoint: UnitPoint(x: 0.5 - sin(time * 0.9) * 0.5, y: 1)
)
}
.overlay {
Image(systemName: "waveform")
.font(.largeTitle)
.foregroundStyle(.white.opacity(0.6))
}
.accessibilityHidden(true)
}
}
#Preview {
VideoPosterToPlayerDemo()
}