Skip to content
Deck Fan preview
An animated render of the SwiftUI source on this page.

Deck Fan

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

Interactive

A tight stack of cards that fans out like a hand, each on its own delayed spring.

Cards · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • card
  • stack
  • fan
  • deck
  • spread
  • stagger
  • spring
  • gallery

The actual source

MorphDeckFan.swift
// Deck Fan · syxUI · https://syxui.dev/components/morph-deck-fan
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A tight stack of cards that fans out like a hand, each on its own
/// delayed spring.
///
/// One shared `progress` drives every card. Each card reads its own
/// staggered slice of it — `pi = clamp((p − i·stagger) / span, 0, 1)` — and
/// runs that slice through `easeOutBack` itself, so the small per-card
/// overshoot on arrival is baked into the geometry rather than into a
/// `.delay()` on the animation. That is what keeps controlled mode (the
/// preview scrubbing `progress`) identical to a real tap or drag.
struct MorphDeckFan: View {
    var cardColors: [Color] = [
        Color(red: 0.184, green: 0.435, blue: 0.929),
        Color(red: 0.486, green: 0.361, blue: 1.0),
        Color(red: 0.925, green: 0.282, blue: 0.600),
        Color(red: 0.961, green: 0.620, blue: 0.043),
    ]
    var background: Color = Color(white: 1.0)
    var spread: Double = 70
    var maxRotation: Double = 14
    var lift: Double = 18
    var stagger: Double = 0.09

    /// Preview override. When set, this renders that point of one fan
    /// instead of waiting for a tap or drag.
    var progress: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var interactive: Double = 0
    /// Captured on the first `onChanged` of a drag, from the live value —
    /// not from wherever the last spring was *headed* — so grabbing the fan
    /// mid-settle starts the drag from what is actually on screen.
    @State private var dragStart: Double? = nil

    private var p: Double { progress ?? interactive }

    /// How much of the shared progress each card's own arc consumes. Once
    /// every card has started (`(count-1) · stagger`), the remainder is what
    /// is left for the last card to complete its own motion in.
    private static let span = 0.73

    var body: some View {
        let count = max(cardColors.count, 1)

        ZStack {
            ForEach(Array(cardColors.enumerated()), id: \.offset) { index, color in
                let localProgress = perCardProgress(index: index, count: count)
                let eased = Self.easeOutBack(localProgress)
                let t = spreadPosition(index: index, count: count)

                RoundedRectangle(cornerRadius: 16, style: .continuous)
                    .fill(color)
                    .frame(width: 96, height: 132)
                    .scaleEffect(lerp(restScale(index), 1, eased))
                    .rotationEffect(.degrees(lerp(0, t * maxRotation, eased)), anchor: .bottom)
                    .offset(
                        x: lerp(0, t * spread, eased),
                        y: lerp(restLift(index), -lift, eased)
                    )
                    .shadow(color: .black.opacity(0.24), radius: lerp(6, 18, eased), y: 4)
                    .zIndex(Double(index))
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(background)
        .contentShape(Rectangle())
        .onTapGesture(perform: toggle)
        .gesture(dragGesture)
        .accessibilityElement()
        .accessibilityAddTraits(.isButton)
        .accessibilityLabel("Card fan")
        .accessibilityValue(p > 0.5 ? "Fanned" : "Stacked")
        .accessibilityHint("Drag or tap to fan the cards")
    }

    /// Rest-state offsets: a shallow cascade so the closed deck still reads
    /// as separate cards, not one solid block.
    private func restLift(_ index: Int) -> Double { Double(index) * 8 }
    private func restScale(_ index: Int) -> Double { 1 - Double(index) * 0.04 }

    /// -1...1 across the deck, centred on the middle card(s).
    private func spreadPosition(index: Int, count: Int) -> Double {
        guard count > 1 else { return 0 }
        return Double(index) / Double(count - 1) * 2 - 1
    }

    private func perCardProgress(index: Int, count: Int) -> Double {
        let delay = Double(index) * stagger
        return min(max((p - delay) / Self.span, 0), 1)
    }

    private var dragGesture: some Gesture {
        DragGesture(minimumDistance: 4)
            .onChanged { value in
                guard progress == nil else { return }
                let base = dragStart ?? interactive
                if dragStart == nil { dragStart = base }
                interactive = min(max(base + value.translation.width / 120, 0), 1)
            }
            .onEnded { _ in
                guard progress == nil else { return }
                dragStart = nil
                settle(toward: interactive > 0.5 ? 1 : 0)
            }
    }

    private func toggle() {
        guard progress == nil else { return }
        settle(toward: p > 0.5 ? 0 : 1)
    }

    private func settle(toward target: Double) {
        if reduceMotion {
            interactive = target
        } else {
            withAnimation(.spring(response: 0.46, dampingFraction: 0.74)) {
                interactive = target
            }
        }
    }

    private func lerp(_ a: Double, _ b: Double, _ t: Double) -> Double { a + (b - a) * t }

    /// `1 + c3·(x−1)³ + c1·(x−1)²` — the standard easeOutBack cubic. Runs
    /// slightly past 1 before settling, which is the overshoot that makes
    /// each card's arrival read as a small, deliberate snap.
    private static func easeOutBack(_ x: Double) -> Double {
        let c1 = 1.70158
        let c3 = c1 + 1
        let t = x - 1
        return 1 + c3 * t * t * t + c1 * t * t
    }
}

#Preview {
    MorphDeckFan()
        .frame(width: 380, height: 240)
        .padding(24)
}
iOS 17 · No dependencies

SwiftUI note. Card count comes from cardColors.count, so adding a fifth colour adds a fifth card. Rotation anchors at .bottom — anchoring at centre is what makes a fake fan look fake.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27