Skip to content

Parallax Push Transition

A custom Transition where the incoming page slides over while the outgoing page moves at a slower parallax speed.

Parallax Push Transition, playing
Download .swift
Origin
Original work
Found by
transition · parallax · push · page transition · slide · custom transition · transición · parallax · empujar · cambio de página · deslizar · transición personalizada

The incoming page slides over while the outgoing page moves at a slower parallax speed.

A Transition cannot read the environment, so pass Reduce Motion in: with it on, the pages crossfade. Give the page on top the higher zIndex.

struct ParallaxPushTransitionDemo: View {
private struct Screen {
let title: String
let symbol: String
let colors: [Color]
}
private let screens = [
Screen(title: "Arrive", symbol: "airplane.arrival", colors: [.indigo, .blue]),
Screen(title: "Stay", symbol: "bed.double.fill", colors: [.orange, .pink]),
Screen(title: "Taste", symbol: "fork.knife", colors: [.green, .teal]),
Screen(title: "Depart", symbol: "airplane.departure", colors: [.purple, .pink]),
]
// Keeps growing, so the newest page always has the highest `zIndex`.
@State private var generation = 0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var screen: Screen { screens[generation % screens.count] }
var body: some View {
ZStack {
page(screen)
.transition(.parallaxPush(reduceMotion: reduceMotion))
.id(generation)
.zIndex(Double(generation))
}
.overlay(alignment: .bottom) {
Button("Next page", systemImage: "arrow.forward", action: advance)
.font(.headline)
.buttonStyle(.borderedProminent)
.tint(.white.opacity(0.25))
.padding(.bottom, 32)
}
.task(id: reduceMotion) {
guard !reduceMotion else { return }
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(1.3))
guard !Task.isCancelled else { return }
advance()
}
}
}
private func advance() {
withAnimation(.smooth(duration: 0.7)) {
generation = generation == Int.max ? 0 : generation + 1
}
}
private func page(_ screen: Screen) -> some View {
ZStack {
LinearGradient(colors: screen.colors, startPoint: .topLeading, endPoint: .bottomTrailing)
.ignoresSafeArea()
VStack(spacing: 18) {
Image(systemName: screen.symbol)
.font(.system(size: 90))
.accessibilityHidden(true)
Text(screen.title)
.font(.largeTitle.bold())
}
.foregroundStyle(.white)
}
}
}
ParameterTypeDefault
isForwardBooltrue
reduceMotionBoolfalse
parallaxCGFloat0.3

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

ParallaxPushTransition.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
enum ParallaxPushTransitionMath {
/// The horizontal shift as a fraction of the page width.
static func shift(phase: TransitionPhase, isForward: Bool, parallax: CGFloat) -> CGFloat {
switch phase {
case .identity: 0
case .willAppear: isForward ? 1 : -parallax
case .didDisappear: isForward ? -parallax : 1
}
}
}
/// The incoming page slides over while the outgoing page moves at a slower parallax speed.
///
/// A `Transition` cannot read the environment, so pass Reduce Motion in: with it on, the
/// pages crossfade. Give the page on top the higher `zIndex`.
struct ParallaxPushTransition: Transition {
var isForward = true
var reduceMotion = false
var parallax: CGFloat = 0.3
func body(content: Content, phase: TransitionPhase) -> some View {
// `visualEffect` takes a `@Sendable` closure, so it works with a local copy.
let shift = ParallaxPushTransitionMath.shift(phase: phase, isForward: isForward, parallax: parallax)
if reduceMotion {
content.opacity(phase.isIdentity ? 1 : 0)
} else {
content
.brightness(shift < 0 ? -0.25 : 0)
.visualEffect { effect, proxy in
effect.offset(x: shift * proxy.size.width)
}
}
}
}
extension Transition where Self == ParallaxPushTransition {
/// For moving forward. Moving back uses `.parallaxPop`, set before the page changes.
static func parallaxPush(reduceMotion: Bool = false) -> ParallaxPushTransition {
ParallaxPushTransition(isForward: true, reduceMotion: reduceMotion)
}
static func parallaxPop(reduceMotion: Bool = false) -> ParallaxPushTransition {
ParallaxPushTransition(isForward: false, reduceMotion: reduceMotion)
}
}
// MARK: - Demo
struct ParallaxPushTransitionDemo: View {
private struct Screen {
let title: String
let symbol: String
let colors: [Color]
}
private let screens = [
Screen(title: "Arrive", symbol: "airplane.arrival", colors: [.indigo, .blue]),
Screen(title: "Stay", symbol: "bed.double.fill", colors: [.orange, .pink]),
Screen(title: "Taste", symbol: "fork.knife", colors: [.green, .teal]),
Screen(title: "Depart", symbol: "airplane.departure", colors: [.purple, .pink]),
]
// Keeps growing, so the newest page always has the highest `zIndex`.
@State private var generation = 0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var screen: Screen { screens[generation % screens.count] }
var body: some View {
ZStack {
page(screen)
.transition(.parallaxPush(reduceMotion: reduceMotion))
.id(generation)
.zIndex(Double(generation))
}
.overlay(alignment: .bottom) {
Button("Next page", systemImage: "arrow.forward", action: advance)
.font(.headline)
.buttonStyle(.borderedProminent)
.tint(.white.opacity(0.25))
.padding(.bottom, 32)
}
.task(id: reduceMotion) {
guard !reduceMotion else { return }
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(1.3))
guard !Task.isCancelled else { return }
advance()
}
}
}
private func advance() {
withAnimation(.smooth(duration: 0.7)) {
generation = generation == Int.max ? 0 : generation + 1
}
}
private func page(_ screen: Screen) -> some View {
ZStack {
LinearGradient(colors: screen.colors, startPoint: .topLeading, endPoint: .bottomTrailing)
.ignoresSafeArea()
VStack(spacing: 18) {
Image(systemName: screen.symbol)
.font(.system(size: 90))
.accessibilityHidden(true)
Text(screen.title)
.font(.largeTitle.bold())
}
.foregroundStyle(.white)
}
}
}
#Preview {
ParallaxPushTransitionDemo()
}