
Deck Fan
From syxUI — written for both platforms, not translated between them.
InteractiveA 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
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


