Swipe To Confirm Slider
A track with a thumb you drag to the end to confirm, which springs back if released early.

- Controls
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- slide to confirm · swipe to pay · slider · drag · unlock · payment · confirmation · deslizar para confirmar · deslizar para pagar · deslizador · arrastrar · desbloquear · pago · confirmación
Description
Section titled “Description”A track with a thumb you drag to the end to confirm. Letting go early springs it back.
With Reduce Motion on, the thumb returns without bouncing. VoiceOver users confirm with a double tap.
struct SwipeToConfirmSliderDemo: View { @State private var payments = 0
var body: some View { SwipeToConfirmSlider(title: "Slide to pay") { payments += 1 } .padding(.horizontal, 24) .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(alignment: .top) { VStack(spacing: 6) { Text("48.00 €") .font(.system(.largeTitle, design: .rounded, weight: .bold)) Text(payments == 1 ? "1 payment sent" : "\(payments) payments sent") .font(.headline) .foregroundStyle(.secondary) } .padding(.top, 72) } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
title | LocalizedStringResource | Required |
confirmedTitle | LocalizedStringResource | "Confirmed" |
tint | Color | .indigo |
resetDelay | Double | 1.4 |
onConfirm | () -> 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
enum SwipeToConfirmSliderMath { /// How far along its travel the thumb is, from 0 to 1. static func progress(translation: Double, trackWidth: Double, thumbWidth: Double) -> Double { let travel = trackWidth - thumbWidth guard travel > 0 else { return 0 } return min(max(translation / travel, 0), 1) }
static func thumbOffset(progress: Double, trackWidth: Double, thumbWidth: Double) -> Double { max(trackWidth - thumbWidth, 0) * min(max(progress, 0), 1) }
static func isConfirmed(progress: Double, threshold: Double = 0.92) -> Bool { progress >= threshold }}
/// A track with a thumb you drag to the end to confirm. Letting go early springs it back.////// With Reduce Motion on, the thumb returns without bouncing. VoiceOver users/// confirm with a double tap.struct SwipeToConfirmSlider: View { let title: LocalizedStringResource var confirmedTitle: LocalizedStringResource = "Confirmed" var tint: Color = .indigo var resetDelay: Double = 1.4 let onConfirm: () -> Void
@State private var progress = 0.0 @State private var isConfirmed = false @GestureState private var isDragging = false @ScaledMetric(relativeTo: .title3) private var height = 68.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.layoutDirection) private var layoutDirection
private let inset = 5.0
/// Gestures report screen points, but layout mirrors in right-to-left layouts: 1 or -1 converts between them. private var forward: CGFloat { layoutDirection == .rightToLeft ? -1 : 1 }
private var settle: Animation { reduceMotion ? .easeOut(duration: 0.15) : .spring(duration: 0.45, bounce: 0.35) }
var body: some View { GeometryReader { proxy in let trackWidth = proxy.size.width - inset * 2 let thumbWidth = height - inset * 2 let offset = SwipeToConfirmSliderMath.thumbOffset( progress: progress, trackWidth: trackWidth, thumbWidth: thumbWidth )
ZStack(alignment: .leading) { Capsule() .fill(.quaternary)
Capsule() .fill(tint.gradient) .frame(width: offset + height)
Text(isConfirmed ? confirmedTitle : title) .font(.title3.weight(.semibold)) .lineLimit(1) .minimumScaleFactor(0.7) .foregroundStyle(isConfirmed ? Color.white : Color.primary) .opacity(isConfirmed ? 1 : max(1 - progress * 1.6, 0)) .padding(.horizontal, height) .frame(maxWidth: .infinity)
// Both symbols live in the thumb and fade in place. One image that changes its // symbol leaves the old one behind while the thumb travels back. ZStack { Image(systemName: "chevron.forward.2") .opacity(isConfirmed ? 0 : 1) Image(systemName: "checkmark") .opacity(isConfirmed ? 1 : 0) .scaleEffect(isConfirmed || reduceMotion ? 1 : 0.4) } .font(.title3.bold()) .foregroundStyle(tint) .frame(width: thumbWidth, height: thumbWidth) .background(.white, in: .circle) .shadow(color: .black.opacity(0.2), radius: 5, y: 3) .contentShape(.circle) .offset(x: inset + offset) .gesture(drag(trackWidth: trackWidth, thumbWidth: thumbWidth)) } } .frame(height: height) .onChange(of: isDragging) { _, dragging in guard !dragging, !isConfirmed else { return } withAnimation(settle) { progress = 0 } } .sensoryFeedback(.success, trigger: isConfirmed) { _, confirmed in confirmed } .accessibilityElement(children: .ignore) .accessibilityLabel(isConfirmed ? confirmedTitle : title) .accessibilityHint("Double tap to confirm") .accessibilityAddTraits(.isButton) .accessibilityAction { confirm() } }
private func drag(trackWidth: Double, thumbWidth: Double) -> some Gesture { DragGesture(minimumDistance: 0) .updating($isDragging) { _, state, _ in state = true } .onChanged { value in guard !isConfirmed else { return } progress = SwipeToConfirmSliderMath.progress( translation: value.translation.width * forward, trackWidth: trackWidth, thumbWidth: thumbWidth ) } .onEnded { _ in if SwipeToConfirmSliderMath.isConfirmed(progress: progress) { confirm() } } }
private func confirm() { guard !isConfirmed else { return } withAnimation(settle) { progress = 1 isConfirmed = true } onConfirm()
Task { try? await Task.sleep(for: .seconds(resetDelay)) withAnimation(settle) { progress = 0 isConfirmed = false } } }}
// MARK: - Demo
struct SwipeToConfirmSliderDemo: View { @State private var payments = 0
var body: some View { SwipeToConfirmSlider(title: "Slide to pay") { payments += 1 } .padding(.horizontal, 24) .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(alignment: .top) { VStack(spacing: 6) { Text("48.00 €") .font(.system(.largeTitle, design: .rounded, weight: .bold)) Text(payments == 1 ? "1 payment sent" : "\(payments) payments sent") .font(.headline) .foregroundStyle(.secondary) } .padding(.top, 72) } }}
#Preview { SwipeToConfirmSliderDemo()}