Skip to content

Play Pause Morph Button

A play button whose triangle morphs into two pause bars by interpolating its path.

Play Pause Morph Button, playing
Download .swift
Origin
Original work
Found by
play · pause · morph · media button · player · shape animation · toggle · reproducir · pausa · transformación · botón multimedia · reproductor · animación de forma

A play button whose triangle morphs into two pause bars.

With Reduce Motion on, the icon changes without morphing.

struct PlayPauseMorphButtonDemo: View {
@State private var isPlaying = false
var body: some View {
VStack(spacing: 32) {
VStack(spacing: 6) {
Text("Midnight Drive")
.font(.system(.title, design: .rounded, weight: .bold))
Text(isPlaying ? "Now playing" : "Paused")
.font(.headline)
.foregroundStyle(.secondary)
}
PlayPauseMorphButton(isPlaying: $isPlaying)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
ParameterTypeDefault
isPlayingBinding<Bool>Required
colors[Color][.pink, .purple]

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

PlayPauseMorphButton.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// A triangle that morphs into two bars as `progress` goes from 0 to 1.
nonisolated struct PlayPauseMorphButtonShape: Shape {
var progress: Double
var animatableData: Double {
get { progress }
set { progress = newValue }
}
private static let iconTop = 0.1
private static let iconBottom = 0.9
/// The x where the triangle is cut in two.
private static let triangleCut = 0.57
/// The triangle, cut in two, in a unit square. Corners match one to one with `pauseBars`.
private static let playHalves: [[CGPoint]] = [
[CGPoint(x: 0.22, y: iconTop), CGPoint(x: triangleCut, y: 0.3), CGPoint(x: triangleCut, y: 0.7), CGPoint(x: 0.22, y: iconBottom)],
[CGPoint(x: triangleCut, y: 0.3), CGPoint(x: 0.92, y: 0.5), CGPoint(x: 0.92, y: 0.5), CGPoint(x: triangleCut, y: 0.7)],
]
private static let pauseBars: [[CGPoint]] = [
[CGPoint(x: 0.2, y: iconTop), CGPoint(x: 0.42, y: iconTop), CGPoint(x: 0.42, y: iconBottom), CGPoint(x: 0.2, y: iconBottom)],
[CGPoint(x: 0.58, y: iconTop), CGPoint(x: 0.8, y: iconTop), CGPoint(x: 0.8, y: iconBottom), CGPoint(x: 0.58, y: iconBottom)],
]
/// The two four-cornered pieces at the current progress, in a unit square.
func pieces() -> [[CGPoint]] {
let amount = min(max(progress, 0), 1)
return zip(Self.playHalves, Self.pauseBars).map { play, pause in
zip(play, pause).map { from, to in
CGPoint(x: from.x + (to.x - from.x) * amount, y: from.y + (to.y - from.y) * amount)
}
}
}
func path(in rect: CGRect) -> Path {
var path = Path()
for piece in pieces() {
path.addLines(piece.map { CGPoint(x: rect.minX + $0.x * rect.width, y: rect.minY + $0.y * rect.height) })
path.closeSubpath()
}
return path
}
}
/// A play button whose triangle morphs into two pause bars.
///
/// With Reduce Motion on, the icon changes without morphing.
struct PlayPauseMorphButton: View {
@Binding var isPlaying: Bool
var colors: [Color] = [.pink, .purple]
@ScaledMetric(relativeTo: .largeTitle) private var size = 92.0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// The side of the icon as a fraction of the button's.
private let iconScale = 0.42
var body: some View {
Button {
withAnimation(reduceMotion ? nil : .spring(duration: 0.42, bounce: 0.2)) { isPlaying.toggle() }
} label: {
let shape = PlayPauseMorphButtonShape(progress: isPlaying ? 1 : 0)
ZStack {
shape.fill(.white)
// A stroke of the same color softens the corners.
shape.stroke(.white, style: StrokeStyle(lineWidth: size * 0.04, lineJoin: .round))
}
.frame(width: size * iconScale, height: size * iconScale)
.frame(width: size, height: size)
.background(.linearGradient(colors: colors, startPoint: .topLeading, endPoint: .bottomTrailing), in: Circle())
.shadow(color: (colors.first ?? .black).opacity(0.4), radius: 18, y: 10)
}
.buttonStyle(.plain)
.accessibilityLabel(isPlaying ? "Pause" : "Play")
}
}
// MARK: - Demo
struct PlayPauseMorphButtonDemo: View {
@State private var isPlaying = false
var body: some View {
VStack(spacing: 32) {
VStack(spacing: 6) {
Text("Midnight Drive")
.font(.system(.title, design: .rounded, weight: .bold))
Text(isPlaying ? "Now playing" : "Paused")
.font(.headline)
.foregroundStyle(.secondary)
}
PlayPauseMorphButton(isPlaying: $isPlaying)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
#Preview {
PlayPauseMorphButtonDemo()
}