
Store Hero
From syxUI — written for both platforms, not translated between them.
InteractiveA feature card that takes over the screen, corners flattening, with drag-down to shrink back.
Cards · SwiftUI · Flutter · iOS 17 · Flutter 3.27
- card
- hero
- fullscreen
- expand
- app-store
- drag-dismiss
- takeover
The actual source
MorphStoreHero.swift
// Store Hero · syxUI · https://syxui.dev/components/morph-store-hero
// Free in any project. Keep this line and credit syxUI where a person can read it.
import SwiftUI
/// A feature card that takes over its container, corners flattening, with
/// drag-down to shrink back.
///
/// The expanded card lives in the *same* `ZStack` as the collapsed one —
/// never a `.fullScreenCover` — because a cover cannot be drag-scrubbed and
/// namespaces do not cross a presentation boundary. `GeometryReader` supplies
/// "fullscreen", so the same code reads as a takeover of a small preview tile
/// or of a real screen without any branching.
struct MorphStoreHero: View {
var heroA: Color = Color(red: 0.106, green: 0.180, blue: 0.420)
var heroB: Color = Color(red: 0.486, green: 0.361, blue: 1.0)
var surface: Color = Color(white: 1.0)
var dimColor: Color = Color(white: 0.0)
var cornerRadius: CGFloat = 26
/// Downward drag distance, in points, that fully dismisses the card.
var dismissDistance: CGFloat = 180
/// Preview override. 0 is the resting card, 1 the fullscreen takeover.
var progress: Double? = nil
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var interactive: Double = 0
@State private var stage: Stage = .closed
@State private var dragAnchor: UnitPoint = .center
private enum Stage { case closed, open }
private var p: Double { progress ?? interactive }
private let closedSize = CGSize(width: 190, height: 230)
var body: some View {
GeometryReader { geo in
let openSize = geo.size
let cardWidth = lerp(closedSize.width, openSize.width, p)
let cardHeight = lerp(closedSize.height, openSize.height, p)
let radius = lerp(cornerRadius, 6, p)
let heroScale = lerp(1.0, 1.06, p)
// Below p = 1 the card also shrinks slightly beyond its frame,
// about wherever the finger is — the rubber band that makes the
// dismiss feel like it is being pulled rather than resized.
let extraScale = lerp(0.9, 1.0, p)
let backdropScale = lerp(1.0, 0.94, p)
ZStack {
backdrop(dim: 0.12 * p, scale: backdropScale)
card(width: cardWidth, height: cardHeight, radius: radius, heroScale: heroScale)
.scaleEffect(extraScale, anchor: dragAnchor)
}
.frame(width: openSize.width, height: openSize.height)
.contentShape(Rectangle())
.highPriorityGesture(dragGesture(containerHeight: openSize.height))
.onTapGesture(perform: tap)
.accessibilityElement()
.accessibilityAddTraits(.isButton)
.accessibilityLabel("Today feature, Focus Better Today")
.accessibilityValue(stage == .open ? "Expanded" : "Collapsed")
}
}
private func backdrop(dim: Double, scale: Double) -> some View {
ZStack {
surface
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(white: 0.90))
.frame(height: 60)
.offset(y: -closedSize.height / 2 - 44)
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(white: 0.93))
.frame(height: 60)
.offset(y: closedSize.height / 2 + 44)
}
.scaleEffect(scale)
.overlay(dimColor.opacity(dim))
.accessibilityHidden(true)
}
private func card(width: CGFloat, height: CGFloat, radius: CGFloat, heroScale: CGFloat) -> some View {
let shape = RoundedRectangle(cornerRadius: radius, style: .continuous)
return LinearGradient(colors: [heroA, heroB], startPoint: .topLeading, endPoint: .bottomTrailing)
.scaleEffect(heroScale)
.overlay(alignment: .topLeading) { headline(cardHeight: height) }
.frame(width: width, height: height)
.clipShape(shape)
.shadow(color: .black.opacity(0.28 * (1 - p)), radius: 18, y: 10)
.accessibilityHidden(true)
}
private func headline(cardHeight: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("EDITOR'S PICK")
.font(.system(size: 11, weight: .bold))
.tracking(1.4)
.foregroundStyle(.white.opacity(0.7))
Text("Focus Better\nToday")
.font(.system(size: 26, weight: .heavy))
.foregroundStyle(.white)
.lineLimit(2)
}
.padding(.horizontal, 18)
.padding(.top, lerp(cardHeight - 74, 28, p))
.scaleEffect(lerp(0.86, 1.0, p), anchor: .topLeading)
}
private func dragGesture(containerHeight: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 6)
.onChanged { value in
guard progress == nil, stage == .open else { return }
dragAnchor = UnitPoint(
x: 0.5,
y: min(max(value.startLocation.y / max(containerHeight, 1), 0), 1)
)
let dy = max(0, value.translation.height)
interactive = 1 - min(dy / dismissDistance, 1)
}
.onEnded { value in
guard progress == nil, stage == .open else { return }
if interactive < 0.55 {
stage = .closed
withAnimation(springOrSnap(response: 0.46, damping: 0.88)) { interactive = 0 }
} else {
withAnimation(springOrSnap(response: 0.50, damping: 0.86)) { interactive = 1 }
}
dragAnchor = .center
}
}
private func tap() {
guard progress == nil, stage == .closed else { return }
stage = .open
withAnimation(springOrSnap(response: 0.50, damping: 0.86)) { interactive = 1 }
}
private func springOrSnap(response: Double, damping: Double) -> Animation {
reduceMotion ? .linear(duration: 0) : .spring(response: response, dampingFraction: damping)
}
}
private func lerp(_ a: CGFloat, _ b: CGFloat, _ t: Double) -> CGFloat {
a + (b - a) * CGFloat(t)
}
private func lerp(_ a: Double, _ b: Double, _ t: Double) -> Double {
a + (b - a) * t
}
#Preview {
HStack(spacing: 30) {
MorphStoreHero()
MorphStoreHero(progress: 0.6)
}
.frame(width: 900, height: 320)
.padding(40)
}
iOS 17 · No dependencies
SwiftUI note. GeometryReader supplies "fullscreen", so the same code reads as a takeover of the preview tile or of a real screen with no branching. A real navigation-stack variant is `matchedGeometryEffect(id:in:isSource:)` with `.navigationTransition(.zoom(...))` on iOS 18+, but neither can be driven by a Double — this file ships the lerped version and drag-scrubs it directly.
Dependencies
- SwiftUI
- No external dependencies
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


