Skip to content

Before After Slider

Two stacked images split by a draggable handle that compares the before and after versions.

Before After Slider, playing
Download .swift
Origin
Original work
Found by
before after · compare · image · photo · slider · split · handle · antes y después · comparar · imagen · foto · deslizador · dividir

Two stacked images split by a handle you drag to compare them.

position runs from 0, all after, to 1, all before. VoiceOver adjusts it by swiping up or down.

struct BeforeAfterSliderDemo: View {
@State private var position = 0.5
private let before = demoPhoto(sky: [Color(white: 0.55), Color(white: 0.78)], hill: Color(white: 0.34), label: "Flat landscape")
private let after = demoPhoto(sky: [.indigo, .pink, .orange], hill: Color(red: 0.13, green: 0.16, blue: 0.4), label: "Graded landscape")
var body: some View {
BeforeAfterSlider(before: before, after: after, position: $position)
.aspectRatio(3 / 4, contentMode: .fit)
.clipShape(.rect(cornerRadius: 24))
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func demoPhoto(sky: [Color], hill: Color, label: String) -> Image {
let size = CGSize(width: 900, height: 1200)
return Image(size: size, label: Text(label)) { context in
context.fill(
Path(CGRect(origin: .zero, size: size)),
with: .linearGradient(Gradient(colors: sky), startPoint: .zero, endPoint: CGPoint(x: 0, y: size.height))
)
context.fill(Path(ellipseIn: CGRect(x: 540, y: 220, width: 200, height: 200)), with: .color(.white.opacity(0.85)))
var ridge = Path()
ridge.move(to: CGPoint(x: 0, y: size.height))
ridge.addLine(to: CGPoint(x: 0, y: 760))
ridge.addCurve(to: CGPoint(x: size.width, y: 660), control1: CGPoint(x: 300, y: 480), control2: CGPoint(x: 600, y: 1020))
ridge.addLine(to: CGPoint(x: size.width, y: size.height))
ridge.closeSubpath()
context.fill(ridge, with: .color(hill))
}
}
ParameterTypeDefault
beforeImageRequired
afterImageRequired
positionBinding<Double>Required
beforeTitleString"Before"
afterTitleString"After"

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

BeforeAfterSlider.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// Two stacked images split by a handle you drag to compare them.
///
/// `position` runs from 0, all after, to 1, all before. VoiceOver adjusts it by swiping up or down.
struct BeforeAfterSlider: View {
let before: Image
let after: Image
@Binding var position: Double
var beforeTitle = "Before"
var afterTitle = "After"
@ScaledMetric(relativeTo: .body) private var knobSize = 44.0
@Environment(\.layoutDirection) private var layoutDirection
private var clamped: Double { min(max(position, 0), 1) }
var body: some View {
GeometryReader { geometry in
let size = geometry.size
let handleX = size.width * clamped
ZStack {
photo(after, in: size)
photo(before, in: size)
.mask(alignment: .leading) {
Rectangle().frame(width: handleX)
}
handle(height: size.height)
.position(x: handleX, y: size.height / 2)
}
.overlay(alignment: .topLeading) { tag(beforeTitle).opacity(min(clamped / 0.25, 1)) }
.overlay(alignment: .topTrailing) { tag(afterTitle).opacity(min((1 - clamped) / 0.25, 1)) }
.contentShape(.rect)
.gesture(
DragGesture(minimumDistance: 0).onChanged { value in
guard size.width > 0 else { return }
// Gestures report screen points, but the layout mirrors in right-to-left layouts.
let x = layoutDirection == .rightToLeft ? size.width - value.location.x : value.location.x
position = min(max(x / size.width, 0), 1)
}
)
}
.clipped()
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(beforeTitle) and \(afterTitle) comparison")
.accessibilityValue("\(Int(clamped * 100)) percent \(beforeTitle)")
.accessibilityAdjustableAction { direction in
switch direction {
case .increment: position = min(clamped + 0.1, 1)
case .decrement: position = max(clamped - 0.1, 0)
@unknown default: break
}
}
}
private func photo(_ image: Image, in size: CGSize) -> some View {
image
.resizable()
.scaledToFill()
.frame(width: size.width, height: size.height)
.clipped()
}
private func handle(height: CGFloat) -> some View {
ZStack {
Rectangle()
.fill(.white)
.frame(width: 3, height: height)
Circle()
.fill(.white)
.frame(width: knobSize, height: knobSize)
.shadow(color: .black.opacity(0.3), radius: 6, y: 2)
Image(systemName: "arrow.left.and.right")
.font(.body.weight(.bold))
.foregroundStyle(.black.opacity(0.7))
}
}
private func tag(_ title: String) -> some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(.black.opacity(0.5), in: .capsule)
.padding(12)
}
}
// MARK: - Demo
struct BeforeAfterSliderDemo: View {
@State private var position = 0.5
private let before = demoPhoto(sky: [Color(white: 0.55), Color(white: 0.78)], hill: Color(white: 0.34), label: "Flat landscape")
private let after = demoPhoto(sky: [.indigo, .pink, .orange], hill: Color(red: 0.13, green: 0.16, blue: 0.4), label: "Graded landscape")
var body: some View {
BeforeAfterSlider(before: before, after: after, position: $position)
.aspectRatio(3 / 4, contentMode: .fit)
.clipShape(.rect(cornerRadius: 24))
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func demoPhoto(sky: [Color], hill: Color, label: String) -> Image {
let size = CGSize(width: 900, height: 1200)
return Image(size: size, label: Text(label)) { context in
context.fill(
Path(CGRect(origin: .zero, size: size)),
with: .linearGradient(Gradient(colors: sky), startPoint: .zero, endPoint: CGPoint(x: 0, y: size.height))
)
context.fill(Path(ellipseIn: CGRect(x: 540, y: 220, width: 200, height: 200)), with: .color(.white.opacity(0.85)))
var ridge = Path()
ridge.move(to: CGPoint(x: 0, y: size.height))
ridge.addLine(to: CGPoint(x: 0, y: 760))
ridge.addCurve(to: CGPoint(x: size.width, y: 660), control1: CGPoint(x: 300, y: 480), control2: CGPoint(x: 600, y: 1020))
ridge.addLine(to: CGPoint(x: size.width, y: size.height))
ridge.closeSubpath()
context.fill(ridge, with: .color(hill))
}
}
#Preview {
BeforeAfterSliderDemo()
}