
Stack Collapse
From syxUI — written for both platforms, not translated between them.
InteractiveOverlapping alert cards fan into a shallow depth stack and cascade apart on tap, capped at a +N more chip.
Banners · SwiftUI · Flutter · iOS 17 · Flutter 3.27
- banner
- stack
- notification
- collapse
- cascade
- depth
- overlay
The actual source
StackCollapse.swift
// Stack Collapse · syxUI · https://syxui.dev/components/banner-stack-collapse
// Free in any project. Keep this line and credit syxUI where a person can read it.
import SwiftUI
// MARK: - Motion
/// The spring pairs this component uses, named so the intent survives an edit.
///
/// `(response, dampingFraction)` in SwiftUI's terms. At unit mass the Dart
/// equivalents are `stiffness = (2π/response)²` and `damping = 2ζ(2π/response)`,
/// so the Flutter file runs the same springs rather than an approximation.
private let expandSpring: (response: Double, damping: Double) = (0.40, 0.75)
private let collapseSpring: (response: Double, damping: Double) = (0.34, 0.85)
private let exitSpring: (response: Double, damping: Double) = (0.50, 0.85)
private let returnSpring: (response: Double, damping: Double) = (0.34, 0.85)
private let promoteSpring: (response: Double, damping: Double) = (0.42, 0.78)
private let hoverSpring: (response: Double, damping: Double) = (0.30, 0.82)
/// How long a spring takes to be visually settled: `6/(ζω₀)`.
///
/// Not the commonly quoted `4/(ζω₀)` — at 4 the analytic curve below is still
/// ~2 % high at `p = 1`, and 2 % of a 76 pt card is a visible misalignment in a
/// still frame. At 6 it lands inside 0.4 %.
private func settleTime(_ spring: (response: Double, damping: Double)) -> Double {
6 / (spring.damping * (2 * Double.pi / spring.response))
}
/// The analytic underdamped step response, so a `progress` override samples the
/// same curve the live path actually runs.
///
/// The live path hands gesture velocity to real springs, which cannot be sampled
/// from a `Double`. This is the resolution: one geometry, two drives.
private func springShape(_ p: Double, _ spring: (response: Double, damping: Double)) -> Double {
let clamped = min(max(p, 0), 1)
let omega = 2 * Double.pi / spring.response
let zeta = min(max(spring.damping, 0.05), 0.999)
let damped = omega * (1 - zeta * zeta).squareRoot()
let t = clamped * settleTime(spring)
return 1 - exp(-zeta * omega * t) * (cos(damped * t) + (zeta * omega / damped) * sin(damped * t))
}
/// A spring that continues at the finger's speed instead of restarting.
///
/// `interpolatingSpring` is the only SwiftUI spring that takes a release
/// velocity, and `initialVelocity` is **normalised and signed relative to the
/// remaining travel**: 1.0 means "cover what is left in one second", negative
/// means the finger is still moving away from the target. Passing raw pt/s
/// throws the card clean off the screen.
private func handOff(
_ spring: (response: Double, damping: Double),
velocity: Double,
travel: Double
) -> Animation {
let omega = 2 * Double.pi / spring.response
return .interpolatingSpring(
stiffness: omega * omega,
damping: 2 * spring.damping * omega,
initialVelocity: abs(travel) < 1 ? 0 : velocity / travel
)
}
// MARK: - Content
/// One row of the stack. Swap `StackAlert.sample` for your own feed.
struct StackAlert: Identifiable {
let id: Int
let title: String
let detail: String
let glyph: String
static let sample: [StackAlert] = [
StackAlert(id: 0, title: "3 payments failed",
detail: "Retry queued · 2 min ago", glyph: "creditcard.fill"),
StackAlert(id: 1, title: "Build #4181 failed",
detail: "web · main · 40s", glyph: "hammer.fill"),
StackAlert(id: 2, title: "Storage almost full",
detail: "92% of 100 GB used", glyph: "externaldrive.fill"),
StackAlert(id: 3, title: "TLS cert expires soon",
detail: "api.example.com · 6 days", glyph: "lock.fill"),
StackAlert(id: 4, title: "Webhook retries paused",
detail: "12 events waiting", glyph: "arrow.triangle.2.circlepath"),
]
}
// MARK: - Component
/// Overlapping alert cards that fan into a shallow depth stack and cascade
/// apart on tap, capped at "+N more".
///
/// Each layer behind the front card sits `sliverOffset` lower, is scaled down by
/// `depthScaleStep` from its top edge and faded by 0.18 — the offset-and-falloff
/// shape a collapsed toast stack has. Anything past `visibleDepth` becomes a
/// single chip, so the view count is flat no matter how many alerts queue up.
///
/// This is an **overlay**: the box stays the height of the collapsed fan and the
/// spread cards draw over whatever is below, the way a notification group does.
/// Nothing under it ever jumps.
struct StackCollapse: View {
// MARK: Controls
/// Slivers behind the front card before the rest fold into "+N more".
var visibleDepth: Int = 2
var sliverOffset: CGFloat = 8
var depthScaleStep: Double = 0.05
var cornerRadius: CGFloat = 16
var surface: Color = Color(red: 0.122, green: 0.122, blue: 0.137)
var accent: Color = Color(red: 0.039, green: 0.518, blue: 1.0)
// MARK: Shape and feel
var cardHeight: CGFloat = 76
var expandedGap: CGFloat = 10
/// Per-card delay down the cascade, ms. One haptic for the whole spread.
var cascade: Double = 40
/// How long the survivors' reflow trails the dismissed card's exit, ms.
/// The lag is what makes the promotion read as physical rather than as a
/// list re-rendering.
var reflowDelay: Double = 70
/// Release speed that commits a dismissal on its own, pt/s.
var dismissVelocity: CGFloat = 640
/// Fraction of the card's width that commits on distance alone.
var commitFraction: CGFloat = 0.34
var alerts: [StackAlert] = StackAlert.sample
/// Preview override. 0 is fully stacked, 1 fully spread.
var progress: Double? = nil
// MARK: State
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var spread: Double = 0
@State private var offsetX: [Int: CGFloat] = [:]
@State private var exits: Set<Int> = []
@State private var dragging = false
@State private var armed = false
@State private var release = Release()
@State private var hovering = false
@State private var textFloor: CGFloat = 0
@State private var chipFloor: CGFloat = 0
/// What the last finger-lift asked for. Read when the keyed animations
/// resolve, which is how one state change drives two different springs.
private struct Release: Equatable {
var velocity: Double = 0
var travel: Double = 1
var committed = false
}
private struct Slot: Identifiable {
let alert: StackAlert
let rank: Int
let leaving: Bool
var id: Int { alert.id }
}
private struct TextWidthKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
private struct ChipWidthKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
// MARK: Derived geometry
private var rowInset: CGFloat { 14 }
private var badgeSize: CGFloat { 30 }
private var badgeGap: CGFloat { 12 }
private var chipGap: CGFloat { 10 }
private var titleColor: Color { Color(white: 0.961) }
private var detailColor: Color { Color(white: 0.60) }
private var live: [StackAlert] { alerts.filter { !exits.contains($0.id) } }
private var depth: Int { max(0, min(visibleDepth, live.count - 1)) }
private var renderedCount: Int { min(live.count, depth + 1) }
private var overflow: Int { max(0, live.count - renderedCount) }
/// The box reserves room for the full `visibleDepth` and never changes, so
/// dismissing a card cannot reflow the page below. Only the stack's own
/// members move.
private var reservedDepth: Int { max(0, min(visibleDepth, alerts.count - 1)) }
private var boxHeight: CGFloat {
sliverOffset * CGFloat(reservedDepth) + cardHeight
}
/// A Mac cursor gets the same affordance a finger does: the fan opens a
/// little to say it opens.
private var peek: CGFloat { hovering && spread < 0.5 && progress == nil ? 3 : 0 }
private func stackedY(_ rank: Int) -> CGFloat { sliverOffset * CGFloat(rank) }
private func expandedY(_ rank: Int) -> CGFloat { (cardHeight + expandedGap) * CGFloat(rank) }
/// 0 stacked, 1 in its own slot. The live path lets SwiftUI interpolate a
/// plain `spread` with the per-card spring below; the preview path walks the
/// analytic curve so a still can be sampled from one `Double`.
private func localSpread(_ rank: Int) -> Double {
guard let progress else { return spread }
let stagger = cascade / 1000 / settleTime(expandSpring)
let span = max(1 - stagger * Double(max(renderedCount - 1, 1)), 0.25)
return springShape((progress - stagger * Double(rank)) / span, expandSpring)
}
private var slots: [Slot] {
var out: [Slot] = []
var rank = 0
for alert in alerts {
if exits.contains(alert.id) {
// Kept in the list so its flight animates from where the finger
// left it instead of popping in at the target.
out.append(Slot(alert: alert, rank: 0, leaving: true))
} else if rank <= depth {
out.append(Slot(alert: alert, rank: rank, leaving: false))
rank += 1
}
}
return out
}
// MARK: Body
var body: some View {
GeometryReader { proxy in
let width = proxy.size.width
ZStack(alignment: .top) {
ForEach(slots) { slot in card(slot, width: width) }
footer(width: width)
}
.frame(width: width, height: boxHeight, alignment: .top)
}
.frame(height: boxHeight)
.background(alignment: .topLeading) { probes.hidden() }
.onPreferenceChange(TextWidthKey.self) { textFloor = $0 }
.onPreferenceChange(ChipWidthKey.self) { chipFloor = $0 }
.onContinuousHover(coordinateSpace: .local) { phase in
hovering = if case .active = phase { true } else { false }
}
.sensoryFeedback(.selection, trigger: spread > 0.5)
.sensoryFeedback(trigger: armed) { _, now in
now ? .impact(weight: .light, intensity: 0.4) : nil
}
}
// MARK: Cards
private func card(_ slot: Slot, width: CGFloat) -> some View {
let rank = slot.rank
let t = slot.leaving ? min(spread, 1) : localSpread(rank)
let dx = offsetX[slot.alert.id] ?? 0
// The card fades as it travels, so the fade is velocity-coupled for
// free: a hard flick reaches transparent sooner than a slow drag.
let fade = min(max(abs(dx) / max(width * 0.62, 1), 0), 1)
let scale = 1 - depthScaleStep * Double(rank) * (1 - t)
let y = (stackedY(rank) + peek * CGFloat(rank)) * CGFloat(1 - t)
+ expandedY(rank) * CGFloat(t)
// Alpha alone is the wrong falloff for a card of unknown-background
// provenance: a dark card fading on a light page recedes *toward* the
// page, and on a dark page it disappears. So each layer back is a
// lifted surface — the palette's own idea of depth — and carries only a
// trace of alpha behind that.
let dim = 1 - 0.06 * Double(rank) * (1 - t)
let lift = 0.09 * Double(rank) * (1 - t)
// One tight shadow on the front card to lift it off the layers, one
// wider one under the deepest, and nothing in between: the collapsed
// stack is one object and casts one ground shadow. Both ease to the
// front card's own values as the cards become separate objects.
let deep = rank == depth
let shadowAlpha = (rank == 0 ? 0.18 : (deep ? 0.16 : 0)) * (1 - t) + 0.18 * t
let shadowBlur = (rank == 0 ? 8.0 : 12.0) * (1 - t) + 8 * t
let shadowDrop = (rank == 0 ? 3.0 : 6.0) * (1 - t) + 3 * t
let isFront = !slot.leaving && rank == 0
return chrome(slot.alert, rank: rank, width: width, spread: t, lift: lift)
.shadow(color: Color.black.opacity(shadowAlpha),
radius: CGFloat(shadowBlur), y: CGFloat(shadowDrop))
// Width only, anchored at the top edge: each layer keeps its full
// height, so the lip it shows below the card in front is exactly
// `sliverOffset` rather than that minus the height it lost.
.scaleEffect(x: CGFloat(scale), y: 1, anchor: .top)
.opacity(dim * (1 - Double(fade)))
.offset(x: dx, y: y)
.animation(dragging ? nil : releaseAnimation(leaving: slot.leaving), value: offsetX)
.animation(cascadeAnimation(rank), value: spread)
.animation(reduceMotion ? nil : spring(hoverSpring), value: hovering)
.zIndex(slot.leaving ? 100 : Double(renderedCount - rank))
.allowsHitTesting(!slot.leaving)
.contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.gesture(dragGesture(slot.alert, width: width), including: isFront ? .all : .subviews)
.onTapGesture { toggle() }
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(slot.alert.title). \(slot.alert.detail)")
.accessibilityAddTraits(isFront ? .isButton : [])
.accessibilityHint(isFront
? (spread > 0.5 ? "Collapses the stack" : "Expands \(live.count) alerts")
: "")
.accessibilityHidden(slot.leaving || (rank > 0 && spread < 0.5))
}
/// Surface, hairline and clip. Split out because one chain from row to
/// shadow is more than the type checker will infer in reasonable time.
private func chrome(
_ alert: StackAlert,
rank: Int,
width: CGFloat,
spread t: Double,
lift: Double
) -> some View {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
let edge: Double = 0.07 + 0.09 * Double(rank) * (1 - t)
return row(alert, rank: rank, width: width, spread: t)
.frame(width: width, height: cardHeight, alignment: .leading)
.background(ZStack {
shape.fill(surface)
shape.fill(Color(white: 1).opacity(lift))
})
// Clip inside, shadow outside: a clip on a shadowed node eats the
// shadow, and `.clipped()` would let the fill through the corner.
.clipShape(shape)
.overlay(shape.strokeBorder(Color(white: 1).opacity(edge), lineWidth: 0.6))
}
private func row(
_ alert: StackAlert,
rank: Int,
width: CGFloat,
spread t: Double
) -> some View {
HStack(spacing: badgeGap) {
Image(systemName: alert.glyph)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(accent)
.frame(width: badgeSize, height: badgeSize)
.background(accent.opacity(0.16), in: Circle())
VStack(alignment: .leading, spacing: 3) {
Text(alert.title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(titleColor)
.lineLimit(1)
Text(alert.detail)
.font(.system(size: 12).monospacedDigit())
.foregroundStyle(detailColor)
.lineLimit(1)
}
Spacer(minLength: chipGap)
if rank == 0 && overflow > 0 && fitsChip(width: width) {
// Fades rather than leaves the row, so the title column never
// reflows on the way open.
pill("+\(overflow) more").opacity(1 - min(t / 0.30, 1))
}
}
.padding(.horizontal, rowInset)
}
private func pill(_ label: String) -> some View {
Text(label)
.font(.system(size: 12, weight: .medium).monospacedDigit())
.foregroundStyle(accent)
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(accent.opacity(0.16), in: Capsule())
.fixedSize()
}
/// Once the cards are in their own slots the count has to live somewhere,
/// so the chip hands off to a pill under the last card.
private func footer(width: CGFloat) -> some View {
let last = max(renderedCount - 1, 0)
let t = localSpread(last)
let reveal = min(max((t - 0.55) / 0.45, 0), 1)
return pill("+\(overflow) more")
.opacity(overflow > 0 ? reveal : 0)
.offset(y: expandedY(last) + cardHeight + expandedGap + 6 * CGFloat(1 - reveal))
.animation(cascadeAnimation(last), value: spread)
.allowsHitTesting(false)
.accessibilityHidden(true)
}
/// The guard against the defect this category ships most often. The chip is
/// the element that yields, never the copy: if the measured column plus the
/// chip does not fit, the chip goes and the title keeps its full width.
private func fitsChip(width: CGFloat) -> Bool {
width - rowInset * 2 - badgeSize - badgeGap - chipGap - chipFloor >= textFloor
}
/// Measured outside the constrained row — `.fixedSize()` makes each probe
/// report its natural width whatever the row proposes — so the card knows
/// what the copy needs before it can ever squeeze it. Only the longest
/// string of each kind is measured, so this is three hidden text runs, not
/// two per alert.
private var probes: some View {
VStack(alignment: .leading, spacing: 0) {
Text(longest { $0.title })
.font(.system(size: 15, weight: .semibold))
.fixedSize()
.measured(TextWidthKey.self)
Text(longest { $0.detail })
.font(.system(size: 12).monospacedDigit())
.fixedSize()
.measured(TextWidthKey.self)
pill("+\(max(overflow, 1)) more")
.measured(ChipWidthKey.self)
}
}
private func longest(_ field: (StackAlert) -> String) -> String {
alerts.map(field).max(by: { $0.count < $1.count }) ?? ""
}
// MARK: Interaction
private func toggle() {
guard progress == nil else { return }
spread = spread > 0.5 ? 0 : 1
}
private func dragGesture(_ alert: StackAlert, width: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 6)
.onChanged { value in
guard progress == nil, alert.id == live.first?.id else { return }
dragging = true
offsetX[alert.id] = value.translation.width
let threshold = width * commitFraction
// Latched: re-arm only well below the line, or a finger resting
// on it buzzes on every frame.
if abs(value.translation.width) >= threshold {
armed = true
} else if abs(value.translation.width) < threshold * 0.85 {
armed = false
}
}
.onEnded { value in
guard progress == nil, alert.id == live.first?.id else { return }
let dx = value.translation.width
let velocity = value.velocity.width
// Decide on the projected stop, not where the finger stopped.
// 0.20 s of coasting — a banner is a control-scale throw, not a
// page-scale one.
let projected = dx + velocity * 0.20
let commits = abs(dx) >= width * commitFraction
|| abs(projected) >= width * 0.5
|| abs(velocity) >= dismissVelocity
dragging = false
armed = false
if commits {
let target = (dx < 0 || (dx == 0 && velocity < 0))
? -(width + 48)
: (width + 48)
release = Release(velocity: Double(velocity),
travel: Double(target - dx),
committed: true)
exits.insert(alert.id)
offsetX[alert.id] = target
} else {
// A half-flick that does not commit should still look
// thrown, so the spring-back carries the same velocity.
release = Release(velocity: Double(velocity),
travel: Double(-dx),
committed: false)
offsetX[alert.id] = 0
}
}
}
// MARK: Animations
private func spring(_ pair: (response: Double, damping: Double)) -> Animation {
.spring(response: pair.response, dampingFraction: pair.damping)
}
/// One state change, two springs. The dismissed card leaves on the release
/// velocity; its neighbours promote on a *later* spring, so the reflow
/// trails the exit instead of racing it.
private func releaseAnimation(leaving: Bool) -> Animation? {
guard !reduceMotion else { return nil }
guard release.committed else {
return handOff(returnSpring, velocity: release.velocity, travel: release.travel)
}
return leaving
? handOff(exitSpring, velocity: release.velocity, travel: release.travel)
: spring(promoteSpring).delay(reflowDelay / 1000)
}
/// Front-to-back opening, back-to-front closing. The same order both ways
/// reads like a rewind rather than a stack folding up.
private func cascadeAnimation(_ rank: Int) -> Animation? {
guard !reduceMotion else { return nil }
let opening = spread > 0.5
let order = opening ? rank : max(renderedCount - 1 - rank, 0)
return spring(opening ? expandSpring : collapseSpring)
.delay(cascade / 1000 * Double(order))
}
}
private extension View {
/// Reports this view's own width up the preference tree.
func measured<K: PreferenceKey>(_ key: K.Type) -> some View where K.Value == CGFloat {
background(
GeometryReader { proxy in
Color.clear.preference(key: key, value: proxy.size.width)
}
)
}
}
#Preview {
StackCollapse()
.padding(16)
.frame(width: 420, height: 240)
}
iOS 17 · No dependencies
SwiftUI note. Overlay: the box reserves the full `visibleDepth` height and never changes, so the spread cards draw over the content below and nothing on the page jumps — wrap it in an animated `.frame(height:)` of your own if you want the page to displace instead. When the container narrows it drops the chip rather than truncating a title, so keep your own copy inside the measured column. `progress` drives the whole cascade with no touch, which is what the catalog still samples.
Dependencies
- SwiftUI
- No external dependencies
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


