Pulse Ring Button
A round button with rings that keep radiating out from behind it.

- Buttons
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- pulse · rings · radiate · ripple · round button · microphone · phase animator · pulso · anillos · ondas · onda expansiva · botón redondo · micrófono
Description
Section titled “Description”A round button with rings that keep radiating out from behind it.
With Reduce Motion on, a single still halo replaces the rings. Set isPulsing to false to leave the button without them, as when what it signals has stopped.
struct PulseRingButtonDemo: View { @State private var isRecording = true
var body: some View { PulseRingButton(systemImage: isRecording ? "mic.fill" : "mic.slash.fill", label: "Record", isPulsing: isRecording) { isRecording.toggle() } .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(alignment: .top) { VStack(spacing: 6) { Text(isRecording ? "Listening" : "Muted") .font(.largeTitle.bold()) Text("Tap to toggle the microphone") .font(.headline) .foregroundStyle(.white.opacity(0.65)) } .foregroundStyle(.white) .padding(.top, 72) } .background(Color(red: 0.1, green: 0.05, blue: 0.08).ignoresSafeArea()) }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
systemImage | String | Required |
label | LocalizedStringResource | Required |
tint | Color | .red |
ringCount | Int | 3 |
ringDuration | Double | 1.6 |
stagger | Double | 0.35 |
isPulsing | Bool | true |
action | () -> Void | Required |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
private enum PulseRingButtonPhase { case rest, expanded}
/// A round button with rings that keep radiating out from behind it.////// With Reduce Motion on, a single still halo replaces the rings. Set `isPulsing`/// to false to leave the button without them, as when what it signals has stopped.struct PulseRingButton: View { let systemImage: String let label: LocalizedStringResource var tint: Color = .red var ringCount: Int = 3 var ringDuration: Double = 1.6 var stagger: Double = 0.35 var isPulsing = true let action: () -> Void
@ScaledMetric(relativeTo: .largeTitle) private var diameter = 104.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { ZStack { Group { if !isPulsing { EmptyView() } else if reduceMotion { Circle() .fill(tint.opacity(0.2)) .scaleEffect(1.35) } else { ForEach(0..<ringCount, id: \.self) { index in ring(at: index) } } } .allowsHitTesting(false) .accessibilityHidden(true)
Button(action: action) { Image(systemName: systemImage) .font(.largeTitle.bold()) .foregroundStyle(PulseRingButtonContrastingForeground(background: tint)) .frame(width: diameter, height: diameter) .background(tint.gradient, in: .circle) .contentShape(.circle) } .buttonStyle(.plain) .accessibilityLabel(label) } .frame(width: diameter, height: diameter) }
/// Every ring waits before growing and again before resetting, and the two /// delays add up to the same total, so all rings keep the same period. private func ring(at index: Int) -> some View { let startDelay = Double(index) * stagger let endDelay = Double(ringCount - 1 - index) * stagger
return Circle() .fill(tint) .phaseAnimator([PulseRingButtonPhase.rest, .expanded]) { content, phase in content .scaleEffect(phase == .expanded ? 2.5 : 1) .opacity(phase == .expanded ? 0 : 0.55) } animation: { phase in phase == .expanded ? .easeOut(duration: ringDuration).delay(startDelay) : .linear(duration: 0.01).delay(endDelay) } }}
/// White or black, whichever reads better on `background`. Black only on a/// light color, so the system's own colors keep white.nonisolated struct PulseRingButtonContrastingForeground: ShapeStyle { let background: Color
func resolve(in environment: EnvironmentValues) -> Color { let color = background.resolve(in: environment) let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue return luminance > 0.5 ? .black : .white }}
// MARK: - Demo
struct PulseRingButtonDemo: View { @State private var isRecording = true
var body: some View { PulseRingButton(systemImage: isRecording ? "mic.fill" : "mic.slash.fill", label: "Record", isPulsing: isRecording) { isRecording.toggle() } .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(alignment: .top) { VStack(spacing: 6) { Text(isRecording ? "Listening" : "Muted") .font(.largeTitle.bold()) Text("Tap to toggle the microphone") .font(.headline) .foregroundStyle(.white.opacity(0.65)) } .foregroundStyle(.white) .padding(.top, 72) } .background(Color(red: 0.1, green: 0.05, blue: 0.08).ignoresSafeArea()) }}
#Preview { PulseRingButtonDemo()}