Skip to content

Shutter Capture Button

A camera shutter whose inner shape shrinks on press and flashes a ring on capture, with a photo and video toggle.

Shutter Capture Button, playing
  • Media
  • iOS 18.0+
  • intermediate
Download .swift
Origin
Original work
Found by
camera · shutter · capture · photo · video · record button · ring · cámara · obturador · capturar · foto · vídeo · botón de grabar

A camera shutter with a photo and video toggle.

In photo mode a tap calls onCapture and flashes a ring. In video mode a tap toggles isRecording. With Reduce Motion on, the shape changes without moving.

struct ShutterCaptureButtonDemo: View {
@State private var mode = ShutterCaptureButtonMode.photo
@State private var isRecording = false
@State private var photos = 0
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 24) {
RoundedRectangle(cornerRadius: 24)
.fill(LinearGradient(colors: [.teal, .indigo, .purple], startPoint: .top, endPoint: .bottom))
.overlay {
Image(systemName: "mountain.2.fill")
.font(.largeTitle)
.foregroundStyle(.white.opacity(0.7))
}
.overlay(alignment: .top) {
Text(isRecording ? "Recording" : "^[\(photos) photo](inflect: true)")
.font(.footnote.weight(.semibold).monospacedDigit())
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isRecording ? Color.red : Color.black.opacity(0.4), in: .capsule)
.padding(14)
}
.accessibilityHidden(true)
ShutterCaptureButton(mode: $mode, isRecording: $isRecording) { photos += 1 }
}
.padding(16)
}
}
}
ParameterTypeDefault
modeBinding<ShutterCaptureButtonMode>Required
isRecordingBinding<Bool>Required
onCapture() -> Void{}

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

ShutterCaptureButton.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
enum ShutterCaptureButtonMode: String, CaseIterable, Identifiable, Sendable {
case photo = "Photo"
case video = "Video"
var id: Self { self }
}
/// A camera shutter with a photo and video toggle.
///
/// In photo mode a tap calls `onCapture` and flashes a ring. In video mode a tap
/// toggles `isRecording`. With Reduce Motion on, the shape changes without moving.
struct ShutterCaptureButton: View {
@Binding var mode: ShutterCaptureButtonMode
@Binding var isRecording: Bool
var onCapture: () -> Void = {}
@State private var captureCount = 0
@ScaledMetric(relativeTo: .largeTitle) private var scaledSize = 80.0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var size: CGFloat { min(scaledSize, 120) }
private var animation: Animation? { reduceMotion ? nil : .snappy(duration: 0.25) }
private var label: String {
switch mode {
case .photo: "Take photo"
case .video: isRecording ? "Stop recording" : "Start recording"
}
}
var body: some View {
VStack(spacing: 20) {
HStack(spacing: 28) {
ForEach(ShutterCaptureButtonMode.allCases) { option in
Button {
withAnimation(animation) { mode = option }
} label: {
// A word is too small to hit. The padding widens what answers to a
// touch, and the same padding taken back keeps the layout as it was.
Text(option.rawValue)
.padding(13)
.contentShape(.rect)
.padding(-13)
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(mode == option ? Color.yellow : Color.white)
.accessibilityAddTraits(mode == option ? .isSelected : [])
}
}
.disabled(isRecording)
.opacity(isRecording ? 0.4 : 1)
Button(action: shutterTapped) {
ZStack {
Circle().strokeBorder(.white, lineWidth: 4)
if captureCount > 0, !reduceMotion {
ShutterCaptureButtonFlash().id(captureCount)
}
}
.frame(width: size, height: size)
.contentShape(.circle)
}
.buttonStyle(ShutterCaptureButtonStyle(mode: mode, isRecording: isRecording, size: size, reduceMotion: reduceMotion))
.accessibilityLabel(label)
.sensoryFeedback(.impact(flexibility: .rigid), trigger: captureCount)
.sensoryFeedback(.selection, trigger: isRecording)
}
}
private func shutterTapped() {
switch mode {
case .photo:
captureCount &+= 1
onCapture()
case .video:
withAnimation(animation) { isRecording.toggle() }
}
}
}
/// Draws the inner shape: it shrinks while pressed and becomes a square while recording.
private struct ShutterCaptureButtonStyle: ButtonStyle {
let mode: ShutterCaptureButtonMode
let isRecording: Bool
let size: CGFloat
let reduceMotion: Bool
func makeBody(configuration: Configuration) -> some View {
let innerSize = size * (isRecording ? 0.42 : 0.82)
return configuration.label
.background {
RoundedRectangle(cornerRadius: isRecording ? 8 : innerSize / 2)
.fill(mode == .video ? Color.red : Color.white)
.frame(width: innerSize, height: innerSize)
.scaleEffect(configuration.isPressed && !reduceMotion ? 0.86 : 1)
.opacity(configuration.isPressed && reduceMotion ? 0.7 : 1)
.animation(reduceMotion ? nil : .snappy(duration: 0.15), value: configuration.isPressed)
}
}
}
private struct ShutterCaptureButtonFlash: View {
@State private var isExpanded = false
var body: some View {
Circle()
.stroke(.white, lineWidth: 3)
.scaleEffect(isExpanded ? 1.4 : 1)
.opacity(isExpanded ? 0 : 0.9)
.onAppear {
withAnimation(.easeOut(duration: 0.4)) { isExpanded = true }
}
}
}
// MARK: - Demo
struct ShutterCaptureButtonDemo: View {
@State private var mode = ShutterCaptureButtonMode.photo
@State private var isRecording = false
@State private var photos = 0
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 24) {
RoundedRectangle(cornerRadius: 24)
.fill(LinearGradient(colors: [.teal, .indigo, .purple], startPoint: .top, endPoint: .bottom))
.overlay {
Image(systemName: "mountain.2.fill")
.font(.largeTitle)
.foregroundStyle(.white.opacity(0.7))
}
.overlay(alignment: .top) {
Text(isRecording ? "Recording" : "^[\(photos) photo](inflect: true)")
.font(.footnote.weight(.semibold).monospacedDigit())
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isRecording ? Color.red : Color.black.opacity(0.4), in: .capsule)
.padding(14)
}
.accessibilityHidden(true)
ShutterCaptureButton(mode: $mode, isRecording: $isRecording) { photos += 1 }
}
.padding(16)
}
}
}
#Preview {
ShutterCaptureButtonDemo()
}