Skip to content
Parallax Layers preview
An animated render of the SwiftUI source on this page.

Parallax Layers

From syxUI — written for both platforms, not translated between them.

Interactive

A card built from four layers that slide at different rates, turning a flat tile into a diorama.

Cards · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • card
  • parallax
  • layers
  • depth
  • scroll
  • drag
  • diorama
  • 3d

The actual source

DepthParallaxLayers.swift
// Parallax Layers · syxUI · https://syxui.dev/components/depth-parallax-layers
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A card built from four layers that slide at different rates, turning a
/// flat tile into a diorama.
///
/// One normalised `input` in −1…1 drives everything: sky `×−0.08`, mid shape
/// cluster `×−0.02`, subject `×+0.06`, foreground text `×+0.14`, each times
/// `depth`. The subject also scales up slightly and its shadow shifts the
/// *opposite* way — the counter-moving shadow is what reads as "the subject
/// is off the back plate" rather than just a sticker sliding on the surface.
struct DepthParallaxLayers: View {
    var skyA: Color = Color(red: 0.106, green: 0.18, blue: 0.42)
    var skyB: Color = Color(red: 0.486, green: 0.361, blue: 1.0)
    var subjectColor: Color = Color(red: 1.0, green: 0.827, blue: 0.478)
    var textColor: Color = Color(white: 1.0)
    /// How far the foreground layer slides, in points. The other three
    /// layers move at their own fraction of this.
    var depth: CGFloat = 26
    var mode: ParallaxMode = .drag
    /// Preview override. When set, the component renders that point of the
    /// idle sweep instead of waiting for drag or scroll.
    var phase: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var interactiveInput: Double = 0

    private let cardWidth: CGFloat = 300
    private let cardHeight: CGFloat = 190
    private let corner: CGFloat = 22

    /// The idle sweep: one full lap of `sin(2πt)` crosses the whole −1…1
    /// range, so a still frame never lands on the flat, unrevealing centre.
    private var input: Double {
        guard !reduceMotion else { return 0 }
        if let phase { return sin(2 * .pi * phase) }
        return interactiveInput
    }

    private var shape: RoundedRectangle {
        RoundedRectangle(cornerRadius: corner, style: .continuous)
    }

    var body: some View {
        ZStack {
            skyLayer.offset(x: input * -0.08 * depth)
            midShapesLayer.offset(x: input * -0.02 * depth)
            subjectLayer.offset(x: input * 0.06 * depth)
            textLayer.offset(x: input * 0.14 * depth)
        }
        .frame(width: cardWidth, height: cardHeight)
        .clipShape(shape)
        .rotation3DEffect(.degrees(input * 6), axis: (x: 0, y: 1, z: 0), perspective: 0.7)
        .contentShape(shape)
        .gesture(dragGesture, including: mode == .drag ? .all : .subviews)
        .onContinuousHover(coordinateSpace: .local, perform: handleHover)
        .background {
            // Scroll mode reads the card's own position in its enclosing
            // scroll view; outside one, minY is always 0 and the card just
            // sits centred, which is the correct graceful degradation.
            GeometryReader { proxy in
                Color.clear.preference(key: ScrollInputKey.self, value: scrollInput(for: proxy))
            }
        }
        .onPreferenceChange(ScrollInputKey.self) { value in
            guard mode == .scroll, phase == nil, !reduceMotion else { return }
            interactiveInput = value
        }
        .accessibilityElement(children: .combine)
        .accessibilityLabel("Parallax diorama card")
    }

    // MARK: Layers

    private var skyLayer: some View {
        LinearGradient(colors: [skyA, skyB], startPoint: .top, endPoint: .bottom)
    }

    private var midShapesLayer: some View {
        ZStack {
            Circle().fill(.white.opacity(0.14)).frame(width: 90, height: 90).offset(x: -70, y: -40)
            Circle().fill(.white.opacity(0.1)).frame(width: 130, height: 130).offset(x: 80, y: 30)
            Circle().fill(.white.opacity(0.08)).frame(width: 60, height: 60).offset(x: 20, y: -60)
        }
        .blur(radius: 1)
    }

    private var subjectLayer: some View {
        let magnitude = min(1, abs(input))
        return Circle()
            .fill(
                RadialGradient(
                    colors: [subjectColor, subjectColor.opacity(0.7)],
                    center: .init(x: 0.35, y: 0.3),
                    startRadius: 2,
                    endRadius: 46
                )
            )
            .frame(width: 74, height: 74)
            .scaleEffect(1 + 0.04 * magnitude)
            // Shifting opposite the subject's own slide is what sells "this
            // is a separate plane, not a sticker on the background."
            .shadow(color: .black.opacity(0.35), radius: 20, x: -input * 12, y: 10)
    }

    private var textLayer: some View {
        VStack(alignment: .leading, spacing: 2) {
            Spacer()
            Text("Aurora Basin")
                .font(.system(size: 18, weight: .bold))
                .foregroundStyle(textColor)
            Text("Layer 4 of 4")
                .font(.system(size: 11.5))
                .foregroundStyle(textColor.opacity(0.65))
        }
        .frame(width: cardWidth, height: cardHeight, alignment: .bottomLeading)
        .padding(16)
    }

    /// Normalised position of this card within its enclosing scroll view,
    /// −1…1: negative once scrolled past, positive while still approaching.
    private func scrollInput(for proxy: GeometryProxy) -> Double {
        let minY = proxy.frame(in: .scrollView).minY
        let height = max(proxy.size.height, 1)
        return min(max(minY / height, -1), 1)
    }

    // MARK: Input

    private var dragGesture: some Gesture {
        DragGesture(minimumDistance: 0)
            .onChanged { value in
                guard phase == nil, !reduceMotion, mode == .drag else { return }
                track(value.translation.width / (cardWidth / 2))
            }
            .onEnded { _ in settle() }
    }

    /// Mouse and trackpad have no drag gesture until a button is held, so a
    /// Mac cursor would never move this card without this — hover writes the
    /// same property the finger does, with the same spring underneath.
    private func handleHover(_ hoverPhase: HoverPhase) {
        guard phase == nil, !reduceMotion, mode == .drag else { return }
        switch hoverPhase {
        case .active(let location):
            track((location.x - cardWidth / 2) / (cardWidth / 2))
        case .ended:
            settle()
        }
    }

    private func track(_ value: Double) {
        withAnimation(.interactiveSpring(response: 0.15, dampingFraction: 0.9)) {
            interactiveInput = min(max(value, -1), 1)
        }
    }

    private func settle() {
        guard phase == nil, !reduceMotion, mode == .drag else { return }
        withAnimation(.spring(response: 0.55, dampingFraction: 0.70)) {
            interactiveInput = 0
        }
    }
}

enum ParallaxMode {
    case drag
    case scroll
}

private struct ScrollInputKey: PreferenceKey {
    // A mutable `static var` default is shared mutable state under Swift 6's
    // strict concurrency checking — `static let` is correct because a
    // PreferenceKey's default never changes.
    static let defaultValue: Double = 0
    static func reduce(value: inout Double, nextValue: () -> Double) {
        value = nextValue()
    }
}

#Preview {
    DepthParallaxLayers()
        .frame(width: 380, height: 240)
        .background(Color(white: 0.08))
}
iOS 17 · No dependencies

SwiftUI note. Drag mode is dead flat without touch, so the phase override drives a sin(2πt) sweep across the whole input range for the catalog card, and onContinuousHover writes the same property the drag gesture does so a Mac cursor works too. Scroll mode reads proxy.frame(in: .scrollView) via a PreferenceKey rather than a GeometryReader-plus-@State write during layout; .scrollTransition(.interactive) is a terser alternative worth trying if you only need this on iOS 17+ inside a List or ScrollView.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27