Skip to content
Store Hero preview
An animated render of the SwiftUI source on this page.

Store Hero

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

Interactive

A 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

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27