Skip to content

Swipe To Confirm Slider

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

Swipe To Confirm Slider, playing
Download .swift
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

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)
}
}
}
ParameterTypeDefault
titleLocalizedStringResourceRequired
confirmedTitleLocalizedStringResource"Confirmed"
tintColor.indigo
resetDelayDouble1.4
onConfirm() -> VoidRequired

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

SwipeToConfirmSlider.swift
// 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()
}