agentleFS
BigTsungBigTsung/SwiftUI-Motion-Library/llms-full.txt

SwiftUI-Motion-Library

llms.txt2 starsChanged 8 days agoMITView on GitHub

Every effect with its API, usage, an implementation description and the complete source. Generated from the catalog; 145 effects. Then import MotionLibrary. Requires iOS 17, macOS 14 or visionOS 1; effects marked iOS 18+ are @available(iOS 18, macOS 15, visionOS 2, *). In an Xcode app project, add the package with File → Add Package Dependencies… rather than editing project.pbxproj by hand. To use one effect without the package, copy its source file plus any files listed under "Files" for…

The file

We split this llms-full.txt into the rules it gives an agent

1 rule. About 0 are common, 1 is distinctive, and 0 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 0

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Every few seconds the view tears: an RGB split, sliced rows and jitter.

    line 1161

≈ “Common” is an estimate: at least 1 in 5 of the 3,592 llms.txt files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
llms-full.txt
# SwiftUI Motion Library: full reference

> Every effect with its API, usage, an implementation description and the complete source. Generated from the catalog; 145 effects.

## Install

```swift
// Package.swift
.package(url: "https://github.com/BigTsung/SwiftUI-Motion-Library", from: "0.6.0")
// target dependencies
.product(name: "MotionLibrary", package: "SwiftUI-Motion-Library")
```

Then `import MotionLibrary`. Requires iOS 17, macOS 14 or visionOS 1; effects marked iOS 18+ are `@available(iOS 18, macOS 15, visionOS 2, *)`.

In an Xcode app project, add the package with **File → Add Package Dependencies…** rather than editing `project.pbxproj` by hand. To use one effect without the package, copy its source file plus any files listed under "Files" for it (shared helpers such as `Support/Haptics.swift`); every effect compiles from just those files.

---

## Typewriter (`typewriter`)

Characters appear one by one with a blinking cursor.

- API: `TypewriterText("…")`
- Component: Text
- Trigger: Appear / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/TypewriterText.swift
- Files: Sources/MotionLibrary/Text/TypewriterText.swift

### Usage

```swift
TypewriterText("Hello, SwiftUI!", interval: 0.06)
    .font(.title.bold())
```

### How it works

Create a SwiftUI "typewriter" text view (iOS 17+). It reveals a string one character at a time at a fixed interval (default 60 ms), followed by a cursor character "|" in the accent color that blinks every 0.5 s. Implementation: keep a @State visibleCount, render Text(String(text.prefix(visibleCount))) concatenated with the cursor Text, and drive it from .task(id: text) with Task.sleep so it restarts when the text changes and cancels when the view disappears. Use the full text as the accessibility label.

### Source

```swift
import SwiftUI

/// Reveals a string one character at a time with an optional blinking cursor.
///
/// ```swift
/// TypewriterText("Hello, SwiftUI!")
///     .font(.title.bold())
/// ```
///
/// The animation restarts whenever `text` changes.
public struct TypewriterText: View {
    private let text: String
    private let interval: Double
    private let showsCursor: Bool
    private let cursor: String

    @State private var visibleCount = 0
    @State private var cursorVisible = true

    /// - Parameters:
    ///   - text: The text to type out.
    ///   - interval: Seconds between characters.
    ///   - showsCursor: Shows a blinking cursor after the text.
    ///   - cursor: The cursor character.
    public init(
        _ text: String,
        interval: Double = 0.06,
        showsCursor: Bool = true,
        cursor: String = "|"
    ) {
        self.text = text
        self.interval = interval
        self.showsCursor = showsCursor
        self.cursor = cursor
    }

    @available(*, deprecated, message: "Pass interval in seconds as a Double, e.g. interval: 0.06")
    public init(
        _ text: String,
        interval: Duration,
        showsCursor: Bool = true,
        cursor: String = "|"
    ) {
        let parts = interval.components
        self.init(text, interval: Double(parts.seconds) + Double(parts.attoseconds) / 1e18, showsCursor: showsCursor, cursor: cursor)
    }

    public var body: some View {
        (
            Text(String(text.prefix(visibleCount)))
            + Text(showsCursor ? cursor : "")
                .foregroundStyle(cursorVisible ? Color.accentColor : Color.clear)
        )
        .accessibilityLabel(text)
        .task(id: text) {
            visibleCount = 0
            guard !text.isEmpty else { return }
            for index in 1...text.count {
                try? await Task.sleep(for: .seconds(interval))
                if Task.isCancelled { return }
                visibleCount = index
            }
        }
        .task {
            guard showsCursor else { return }
            while !Task.isCancelled {
                try? await Task.sleep(for: .milliseconds(500))
                cursorVisible.toggle()
            }
        }
    }
}

#Preview {
    TypewriterText("Turning complex data into meaningful visuals.")
        .font(.title2.weight(.semibold))
        .padding()
}
```

---

## Scramble / Decode (`scramble`)

Random glyphs resolve into the real text from left to right.

- API: `ScrambleText("…")`
- Component: Text
- Trigger: Appear / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/ScrambleText.swift
- Files: Sources/MotionLibrary/Text/ScrambleText.swift

### Usage

```swift
ScrambleText("ACCESS GRANTED", duration: 1.0)
    .font(.title.bold())
```

### How it works

Create a SwiftUI "decode" text effect (iOS 17+). On appear, every character of the string is shown as a random glyph from a charset (A–Z, 0–9, #%&*), re-randomised every 30 ms. Over the given duration, characters lock into their real values from left to right until the full string is shown. Spaces never scramble. Use a monospaced font design so the width doesn't jitter, drive frames from .task(id: text) with Task.sleep, and set the real string as the accessibility label.

### Source

```swift
import SwiftUI

/// "Decoding" text effect: random glyphs settle into the real string from
/// left to right.
///
/// ```swift
/// ScrambleText("ACCESS GRANTED")
///     .font(.title.bold())
/// ```
public struct ScrambleText: View {
    private let text: String
    private let duration: Double
    private let charset: [Character]

    @State private var display: String
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    public init(
        _ text: String,
        duration: Double = 1.0,
        charset: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*"
    ) {
        self.text = text
        self.duration = duration
        self.charset = Array(charset)
        _display = State(initialValue: text)
    }

    public var body: some View {
        Text(display)
            .fontDesign(.monospaced)
            .accessibilityLabel(text)
            .task(id: text) {
                await run()
            }
    }

    private func run() async {
        // Reduce Motion: show the text without the flicker of random glyphs.
        guard !reduceMotion else {
            display = text
            return
        }
        let target = Array(text)
        let frameInterval = 0.03
        let steps = max(1, Int(duration / frameInterval))

        for step in 0...steps {
            let revealed = Int(Double(target.count) * Double(step) / Double(steps))
            var output: [Character] = []
            output.reserveCapacity(target.count)
            for (index, character) in target.enumerated() {
                if index < revealed || character == " " || charset.isEmpty {
                    output.append(character)
                } else {
                    output.append(charset.randomElement() ?? character)
                }
            }
            display = String(output)

            try? await Task.sleep(for: .seconds(frameInterval))
            if Task.isCancelled { return }
        }
        display = text
    }
}

#Preview {
    ScrambleText("ACCESS GRANTED", duration: 1.2)
        .font(.title.bold())
        .padding()
}
```

---

## Shimmer (`shimmer`)

A band of light sweeps across text or skeleton placeholders.

- API: `.shimmer()`
- Component: Text
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/Shimmer.swift
- Files: Sources/MotionLibrary/Text/Shimmer.swift

### Usage

```swift
Text("Loading…")
    .font(.title)
    .shimmer()

RoundedRectangle(cornerRadius: 8)
    .fill(.gray.opacity(0.3))
    .frame(height: 44)
    .shimmer(duration: 1.2)
```

### How it works

Create a reusable SwiftUI .shimmer() view modifier (iOS 17+). A soft white highlight band (a horizontal LinearGradient clear → white 80% → clear, about 35% of the view width) moves from just off the left edge to just off the right edge, repeating forever with linear timing (~1.6 s). The band is placed in an overlay with a GeometryReader and masked by the original content so it only lights up the view's own pixels (text glyphs or skeleton shapes). It must not intercept touches and should accept isActive, duration, bandWidth and highlight color parameters.

### Source

```swift
import SwiftUI

/// A band of light that sweeps across the view, clipped to the view's own
/// shape. Works on text, images, and skeleton placeholders.
struct ShimmerModifier: ViewModifier {
    var isActive: Bool
    var duration: Double
    var bandWidth: CGFloat
    var highlight: Color

    @State private var animating = false
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        content
            .overlay {
                if isActive {
                    GeometryReader { geometry in
                        let width = geometry.size.width
                        let band = max(1, width * bandWidth)
                        LinearGradient(
                            colors: [.clear, highlight, .clear],
                            startPoint: .leading,
                            endPoint: .trailing
                        )
                        .frame(width: band, height: geometry.size.height)
                        .offset(x: animating ? width : -band)
                    }
                    .mask { content }
                    .allowsHitTesting(false)
                    .onAppear {
                        animating = false
                        // Reduce Motion: no sweep; the band stays parked off-screen.
                        guard !reduceMotion else { return }
                        withAnimation(.linear(duration: duration).repeatForever(autoreverses: false)) {
                            animating = true
                        }
                    }
                }
            }
    }
}

public extension View {
    /// Sweeps a highlight across the view.
    ///
    /// ```swift
    /// Text("Loading…").font(.title).shimmer()
    /// RoundedRectangle(cornerRadius: 8).fill(.gray.opacity(0.3)).shimmer()
    /// ```
    func shimmer(
        isActive: Bool = true,
        duration: Double = 1.6,
        bandWidth: CGFloat = 0.35,
        highlight: Color = .white.opacity(0.8)
    ) -> some View {
        modifier(ShimmerModifier(
            isActive: isActive,
            duration: duration,
            bandWidth: bandWidth,
            highlight: highlight
        ))
    }
}

#Preview {
    VStack(spacing: 16) {
        Text("Shimmering title")
            .font(.largeTitle.bold())
            .foregroundStyle(.gray)
            .shimmer()
        RoundedRectangle(cornerRadius: 12)
            .fill(.gray.opacity(0.25))
            .frame(height: 60)
            .shimmer()
    }
    .padding()
}
```

---

## Gradient Sweep (`gradient-sweep`)

A multi-colour gradient flows through the letters forever.

- API: `.gradientSweep()`
- Component: Text
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/GradientSweep.swift
- Files: Sources/MotionLibrary/Text/GradientSweep.swift

### Usage

```swift
Text("Motion Library")
    .font(.largeTitle.weight(.heavy))
    .gradientSweep()

Image(systemName: "sparkles")
    .font(.system(size: 60))
    .gradientSweep(colors: [.yellow, .orange, .pink], duration: 2)
```

### How it works

Create a SwiftUI .gradientSweep(colors:duration:) view modifier (iOS 17+) that paints a view's own pixels with a flowing multi-colour gradient. In an overlay, use a GeometryReader to draw a horizontal LinearGradient twice as wide as the view whose colours are the palette repeated twice plus the first colour again (so the right half matches the left half). Offset it from 0 to −width with a linear animation that repeats forever without autoreversing, so the loop is seamless. Mask the overlay with the original content so only glyphs or shapes are coloured, and disable hit testing. Default palette pink, orange, yellow, mint, cyan, purple over 3 s. With Reduce Motion on, show the gradient without moving it.

### Source

```swift
import SwiftUI

/// Fills the view's own pixels with a multi-colour gradient that keeps
/// flowing sideways. Made for headlines, but works on symbols and shapes too.
struct GradientSweepModifier: ViewModifier {
    var colors: [Color]
    var duration: Double

    @State private var flowing = false
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        content
            .overlay {
                GeometryReader { geometry in
                    let width = max(1, geometry.size.width)
                    // Two copies of the palette side by side, twice as wide as
                    // the view. Sliding left by one view width shows the second
                    // copy, which looks exactly like the first, so it loops.
                    let palette = colors.isEmpty ? [Color.primary] : colors
                    LinearGradient(
                        colors: palette + palette + [palette[0]],
                        startPoint: .leading,
                        endPoint: .trailing
                    )
                    .frame(width: width * 2, height: geometry.size.height)
                    .offset(x: flowing ? -width : 0)
                }
                .mask { content }
                .allowsHitTesting(false)
            }
            .onAppear {
                guard !reduceMotion else { return }
                flowing = false
                withAnimation(.linear(duration: duration).repeatForever(autoreverses: false)) {
                    flowing = true
                }
            }
    }
}

public extension View {
    /// Paints the view with a gradient that flows sideways forever.
    ///
    /// ```swift
    /// Text("Motion Library")
    ///     .font(.largeTitle.bold())
    ///     .gradientSweep()
    /// ```
    func gradientSweep(
        colors: [Color] = [.pink, .orange, .yellow, .mint, .cyan, .purple],
        duration: Double = 3
    ) -> some View {
        modifier(GradientSweepModifier(colors: colors, duration: duration))
    }
}

#Preview {
    Text("Motion Library")
        .font(.largeTitle.weight(.heavy))
        .gradientSweep()
        .padding()
}
```

---

## Blur-in Words (`blur-in`)

Words come into focus one after another: fade, rise and un-blur.

- API: `BlurInText("…")`
- Component: Text
- Trigger: Appear / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/BlurInText.swift
- Files: Sources/MotionLibrary/Text/BlurInText.swift

### Usage

```swift
BlurInText("Design is how it works.")
    .font(.title.weight(.semibold))

BlurInText("Letter by letter", by: .character, stagger: 0.03)
```

### How it works

Create a SwiftUI BlurInText view (iOS 17+) that reveals a string piece by piece, split by word (default) or by character. Lay the pieces out with a custom Layout that flows left to right, wraps to new lines and centres each line; the gap between words is about 0.28 × the line height. Each piece starts at opacity 0, blur radius 8 and 8 pt lower, and animates to opacity 1, no blur and no offset with easeOut(0.6 s), delayed by index × stagger (0.08 s default). Replay when the text changes by resetting a revealed flag and setting it again on the next run loop. Expose the whole string as one accessibility label. With Reduce Motion on, only fade.

### Source

```swift
import SwiftUI

/// Text that comes into focus one word (or one letter) at a time: each piece
/// fades in, rises a few points and un-blurs, staggered from first to last.
/// Replays whenever the string changes.
public struct BlurInText: View {
    /// How the string is split into animated pieces.
    public enum Unit: Sendable {
        case word
        case character
    }

    private let text: String
    private let unit: Unit
    private let stagger: Double
    private let duration: Double

    @State private var revealed = false
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - text: The string to show.
    ///   - unit: Animate word by word or character by character.
    ///   - stagger: Delay between consecutive pieces, in seconds.
    ///   - duration: How long each piece takes to come into focus.
    public init(_ text: String, by unit: Unit = .word, stagger: Double = 0.08, duration: Double = 0.6) {
        self.text = text
        self.unit = unit
        self.stagger = stagger
        self.duration = duration
    }

    public var body: some View {
        FlowLayout(spacing: unit == .word ? 0.28 : 0) {
            ForEach(Array(pieces.enumerated()), id: \.offset) { index, piece in
                Text(piece)
                    .opacity(revealed ? 1 : 0)
                    .blur(radius: revealed || reduceMotion ? 0 : 8)
                    .offset(y: revealed || reduceMotion ? 0 : 8)
                    .animation(
                        .easeOut(duration: duration).delay(Double(index) * stagger),
                        value: revealed
                    )
            }
        }
        .id(text)
        .onAppear(perform: reveal)
        .onChange(of: text) { reveal() }
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(text)
    }

    private var pieces: [String] {
        switch unit {
        case .word:
            text.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
        case .character:
            text.map(String.init)
        }
    }

    private func reveal() {
        revealed = false
        DispatchQueue.main.async { revealed = true }
    }
}

/// Lays children out left to right, wrapping onto new lines. `spacing` is a
/// fraction of the line height, so word gaps scale with the font.
struct FlowLayout: Layout {
    var spacing: CGFloat

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let rows = arrange(subviews: subviews, maxWidth: proposal.width ?? .infinity)
        let width = rows.map(\.width).max() ?? 0
        let height = rows.reduce(0) { $0 + $1.height }
        return CGSize(width: width, height: height)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let rows = arrange(subviews: subviews, maxWidth: bounds.width)
        var y = bounds.minY
        for row in rows {
            // Centre each row, like multi-line centred text.
            var x = bounds.minX + (bounds.width - row.width) / 2
            for item in row.items {
                subviews[item.index].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(item.size))
                x += item.size.width + item.gap
            }
            y += row.height
        }
    }

    private struct Row {
        var items: [(index: Int, size: CGSize, gap: CGFloat)] = []
        var width: CGFloat = 0
        var height: CGFloat = 0
    }

    private func arrange(subviews: Subviews, maxWidth: CGFloat) -> [Row] {
        var rows: [Row] = []
        var row = Row()
        for index in subviews.indices {
            let size = subviews[index].sizeThatFits(.unspecified)
            let gapBefore = row.items.last?.gap ?? 0
            let width = row.items.isEmpty ? size.width : row.width + gapBefore + size.width
            if width > maxWidth, !row.items.isEmpty {
                rows.append(row)
                row = Row()
                row.width = size.width
            } else {
                row.width = width
            }
            row.items.append((index, size, size.height * spacing))
            row.height = max(row.height, size.height)
        }
        rows.append(row)
        return rows
    }
}

#Preview {
    BlurInText("Design is not just what it looks like. Design is how it works.")
        .font(.title2.weight(.semibold))
        .padding()
}
```

---

## Marquee (`marquee`)

Text too long for its line scrolls sideways forever, with faded edges.

- API: `MarqueeText("…")`
- Component: Text
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/MarqueeText.swift
- Files: Sources/MotionLibrary/Text/MarqueeText.swift

### Usage

```swift
MarqueeText("Now playing: Midnight Drive — Neon Coast")
    .font(.headline)
    .frame(width: 200)

MarqueeText(longTitle, speed: 1.5, gap: 24)
```

### How it works

Create a SwiftUI MarqueeText view (iOS 17+) for a single line of text. Measure the text's natural width (fixedSize text with a GeometryReader background) and the container width. If the text fits, show it still. If not, show two copies in an HStack separated by a gap (40 pt) and offset them left by (time × 40 pt/s × speed, a multiplier defaulting to 1) modulo (textWidth + gap) using TimelineView(.animation), so the second copy takes the first copy's place and the loop is seamless. Speed defaults to 40 pt/s. Clip to the container and mask the edges with 16 pt linear fades. Reserve one line of height with a hidden copy of the text. Expose the full text to accessibility, and with Reduce Motion on, don't scroll.

### Source

```swift
import SwiftUI

/// A single line of text that scrolls sideways forever when it's too long to
/// fit, with faded edges. Short text just sits still.
public struct MarqueeText: View {
    private let text: String
    private let speed: Double
    private let gap: CGFloat
    private let fadeWidth: CGFloat

    @State private var textWidth: CGFloat = 0
    @State private var containerWidth: CGFloat = 0
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - text: The string to show.
    ///   - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast.
    ///   - gap: Space between the end of the text and its next repetition.
    ///   - fadeWidth: Width of the faded edges.
    public init(_ text: String, speed: Double = 1, gap: CGFloat = 40, fadeWidth: CGFloat = 16) {
        self.text = text
        self.speed = speed * 40
        self.gap = gap
        self.fadeWidth = fadeWidth
    }

    public var body: some View {
        let scrolls = textWidth > containerWidth && !reduceMotion
        // Reserve one line of height; the measured text lives in the overlay.
        Text(text)
            .lineLimit(1)
            .hidden()
            .frame(maxWidth: .infinity, alignment: .leading)
            .overlay(alignment: .leading) {
                TimelineView(.animation(paused: !scrolls || speed == 0)) { timeline in
                    let cycle = textWidth + gap
                    let offset = scrolls
                        ? -CGFloat((timeline.date.timeIntervalSinceReferenceDate * speed)
                            .truncatingRemainder(dividingBy: Double(max(cycle, 1))))
                        : 0
                    HStack(spacing: gap) {
                        label
                        if scrolls { label }
                    }
                    .offset(x: offset)
                }
            }
            .background {
                GeometryReader { proxy in
                    Color.clear
                        .onAppear { containerWidth = proxy.size.width }
                        .onChange(of: proxy.size.width) { _, width in containerWidth = width }
                }
            }
            .clipped()
            .mask {
                if scrolls {
                    HStack(spacing: 0) {
                        LinearGradient(colors: [.clear, .black], startPoint: .leading, endPoint: .trailing)
                            .frame(width: fadeWidth)
                        Color.black
                        LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing)
                            .frame(width: fadeWidth)
                    }
                } else {
                    Color.black
                }
            }
            .accessibilityElement(children: .ignore)
            .accessibilityLabel(text)
    }

    private var label: some View {
        Text(text)
            .lineLimit(1)
            .fixedSize()
            .background {
                GeometryReader { proxy in
                    Color.clear
                        .onAppear { textWidth = proxy.size.width }
                        .onChange(of: proxy.size.width) { _, width in textWidth = width }
                }
            }
    }
}

#Preview {
    VStack(alignment: .leading, spacing: 20) {
        MarqueeText("Now playing: Midnight City — M83 · Hurry Up, We're Dreaming")
            .font(.headline)
        MarqueeText("Short text stays put")
    }
    .frame(width: 240)
    .padding()
}
```

---

## Wave Text (`wave-text`)

Letters bob in a travelling wave, drawn with a TextRenderer.

- API: `WaveText("…")`
- Component: Text
- Trigger: Loop
- Requires: iOS 18+ · macOS 15+ · visionOS 2+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/WaveText.swift
- Files: Sources/MotionLibrary/Text/WaveText.swift

### Usage

```swift
if #available(iOS 18, *) {
    WaveText("Making waves")
        .font(.largeTitle.bold())
        .foregroundStyle(.blue)
}
```

### How it works

Create a SwiftUI WaveText view for iOS 18+ (mark it @available(iOS 18, macOS 15, visionOS 2, *)) that makes each letter bob up and down in a travelling wave while keeping normal text layout. Implement a TextRenderer whose draw(layout:in:) walks every line, run and glyph, keeps a running glyph index, copies the GraphicsContext, translates it vertically by sin((index / wavelength − time × speed) × 2π) × amplitude, and draws the glyph. Set displayPadding to the amplitude above and below so nothing clips. Drive time from TimelineView(.animation) and apply the renderer with .textRenderer. Defaults: amplitude 5 pt, wavelength 8 letters, 1 wave per second. With Reduce Motion on, pause the timeline and use zero amplitude.

### Source

```swift
import SwiftUI

/// Letters bob up and down in a travelling wave, drawn with a `TextRenderer`
/// so the text keeps its normal layout, wrapping and accessibility.
@available(iOS 18, macOS 15, visionOS 2, *)
public struct WaveText: View {
    private let text: Text
    private let amplitude: CGFloat
    private let wavelength: Double
    private let speed: Double

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - text: The string to show.
    ///   - amplitude: How far letters move up and down, in points.
    ///   - wavelength: Number of letters in one full wave.
    ///   - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast.
    public init(_ text: String, amplitude: CGFloat = 5, wavelength: Double = 8, speed: Double = 1) {
        self.init(Text(text), amplitude: amplitude, wavelength: wavelength, speed: speed)
    }

    /// Wraps an existing `Text`, keeping its styling.
    public init(_ text: Text, amplitude: CGFloat = 5, wavelength: Double = 8, speed: Double = 1) {
        self.text = text
        self.amplitude = amplitude
        self.wavelength = wavelength
        self.speed = speed
    }

    public var body: some View {
        TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in
            text.textRenderer(WaveRenderer(
                time: reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate,
                amplitude: reduceMotion ? 0 : amplitude,
                wavelength: wavelength,
                speed: speed
            ))
        }
    }
}

@available(iOS 18, macOS 15, visionOS 2, *)
struct WaveRenderer: TextRenderer {
    var time: Double
    var amplitude: CGFloat
    var wavelength: Double
    var speed: Double

    var displayPadding: EdgeInsets {
        EdgeInsets(top: amplitude, leading: 0, bottom: amplitude, trailing: 0)
    }

    func draw(layout: Text.Layout, in context: inout GraphicsContext) {
        var index = 0
        for line in layout {
            for run in line {
                for glyph in run {
                    let angle = (Double(index) / max(wavelength, 1) - time * speed) * 2 * .pi
                    var copy = context
                    copy.translateBy(x: 0, y: CGFloat(sin(angle)) * amplitude)
                    copy.draw(glyph)
                    index += 1
                }
            }
        }
    }
}

#Preview {
    if #available(iOS 18, macOS 15, visionOS 2, *) {
        WaveText("Making waves in SwiftUI")
            .font(.title.bold())
            .foregroundStyle(.blue)
            .padding()
    }
}
```

---

## Count Up (`count-up`)

A number counts up from zero, and from old to new value on change.

- API: `CountUpText(value: …)`
- Component: Text
- Trigger: Appear / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/CountUpText.swift
- Files: Sources/MotionLibrary/Text/CountUpText.swift

### Usage

```swift
CountUpText(value: revenue, prefix: "$")
    .font(.largeTitle.bold())

CountUpText(value: 98.6, fractionDigits: 1, suffix: "%", duration: 2)
```

### How it works

Create a SwiftUI CountUpText view (iOS 17+) with value, duration (1.4 s), fractionDigits (0), prefix and suffix. Keep a @State shown value starting at 0; on appear and whenever value changes, set shown = value inside withAnimation(.easeOut(duration:)). Render through a private view that conforms to both View and Animatable (animatableData = the number) so SwiftUI calls body on every frame with the interpolated value, and format it with value.formatted(.number.precision(.fractionLength(fractionDigits))) between prefix and suffix. Use .monospacedDigit() so the width doesn't jitter, and give it the final formatted value as its accessibility label. With Reduce Motion on, jump straight to the value.

### Source

```swift
import SwiftUI

/// A number that counts up from zero on appear, and counts from the old value
/// to the new one whenever `value` changes.
public struct CountUpText: View {
    private let value: Double
    private let duration: Double
    private let fractionDigits: Int
    private let prefix: String
    private let suffix: String

    @State private var shown: Double = 0
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - value: The number to count to.
    ///   - duration: Length of the count, in seconds.
    ///   - fractionDigits: Digits after the decimal point.
    ///   - prefix: Text before the number, e.g. "$".
    ///   - suffix: Text after the number, e.g. "%".
    public init(
        value: Double,
        duration: Double = 1.4,
        fractionDigits: Int = 0,
        prefix: String = "",
        suffix: String = ""
    ) {
        self.value = value
        self.duration = duration
        self.fractionDigits = fractionDigits
        self.prefix = prefix
        self.suffix = suffix
    }

    public var body: some View {
        CountingLabel(value: shown, fractionDigits: fractionDigits, prefix: prefix, suffix: suffix)
            .monospacedDigit()
            .onAppear { count(to: value) }
            .onChange(of: value) { _, newValue in count(to: newValue) }
            .accessibilityElement(children: .ignore)
            .accessibilityLabel(CountingLabel.format(value, fractionDigits: fractionDigits, prefix: prefix, suffix: suffix))
    }

    private func count(to target: Double) {
        // Reduce Motion: jump straight to the value.
        withAnimation(reduceMotion ? nil : .easeOut(duration: duration)) {
            shown = target
        }
    }
}

/// Re-renders the formatted number for every animation frame.
private struct CountingLabel: View, Animatable {
    var value: Double
    let fractionDigits: Int
    let prefix: String
    let suffix: String

    nonisolated var animatableData: Double {
        get { value }
        set { value = newValue }
    }

    var body: some View {
        Text(Self.format(value, fractionDigits: fractionDigits, prefix: prefix, suffix: suffix))
    }

    static func format(_ value: Double, fractionDigits: Int, prefix: String, suffix: String) -> String {
        prefix + value.formatted(.number.precision(.fractionLength(max(0, fractionDigits)))) + suffix
    }
}

#Preview {
    VStack(spacing: 12) {
        CountUpText(value: 12_480)
            .font(.largeTitle.bold())
        CountUpText(value: 98.6, fractionDigits: 1, suffix: "%")
            .font(.title)
    }
    .padding()
}
```

---

## Split Text (`split-text`)

Letters rise into place one after another with a small overshoot.

- API: `SplitText("…")`
- Component: Text
- Trigger: Appear / Value change
- Requires: iOS 18+ · macOS 15+ · visionOS 2+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/SplitText.swift
- Files: Sources/MotionLibrary/Text/SplitText.swift

### Usage

```swift
if #available(iOS 18, *) {
    SplitText("Letters take the stage")
        .font(.largeTitle.bold())
}
```

### How it works

Create a SwiftUI SplitText view for iOS 18+ (mark it @available(iOS 18, macOS 15, visionOS 2, *)) with duration per letter (0.5 s), stagger (0.035 s) and distance (18 pt). Apply a TextRenderer that is also Animatable, with a progress value 0…1 as animatableData. In draw(layout:in:), count all glyphs, then for glyph i compute start = spread × i / (count − 1), where spread is the share of the total time taken by the delays, and local = clamp((progress − start) / (1 − spread), 0, 1). Copy the GraphicsContext, set opacity to min(1, local × 2), translate it down by (1 − backOut(local)) × distance (backOut: ease-out with ~1.7 overshoot) and draw the glyph. Set displayPadding at the bottom to distance. In .task(id: string), reset progress to 0 without animation, then animate it to 1 with linear timing over duration + stagger × (count − 1). With Reduce Motion on, use zero distance (a fade).

### Source

```swift
import SwiftUI

/// Letters rise into place one after another with a small overshoot, drawn
/// with a `TextRenderer` so the text keeps its normal layout and wrapping.
/// Plays on appear and again when the string changes.
@available(iOS 18, macOS 15, visionOS 2, *)
public struct SplitText: View {
    private let string: String
    private let duration: Double
    private let stagger: Double
    private let distance: CGFloat

    @State private var progress: Double = 0
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - string: The text to show.
    ///   - duration: How long each letter takes to land, in seconds.
    ///   - stagger: Delay between consecutive letters, in seconds.
    ///   - distance: How far below its place each letter starts, in points.
    public init(_ string: String, duration: Double = 0.5, stagger: Double = 0.035, distance: CGFloat = 18) {
        self.string = string
        self.duration = duration
        self.stagger = stagger
        self.distance = distance
    }

    public var body: some View {
        let glyphs = max(1, string.count)
        let total = duration + stagger * Double(glyphs - 1)
        Text(string)
            .textRenderer(SplitRenderer(
                progress: progress,
                spread: total > 0 ? stagger * Double(glyphs - 1) / total : 0,
                distance: reduceMotion ? 0 : distance
            ))
            .task(id: string) {
                var reset = Transaction()
                reset.disablesAnimations = true
                withTransaction(reset) { progress = 0 }
                try? await Task.sleep(for: .milliseconds(16))
                withAnimation(.linear(duration: reduceMotion ? 0.3 : total)) { progress = 1 }
            }
    }
}

@available(iOS 18, macOS 15, visionOS 2, *)
struct SplitRenderer: TextRenderer, Animatable {
    /// 0 = nothing shown, 1 = every letter in place.
    var progress: Double
    /// Fraction of the timeline taken up by the delays between letters.
    var spread: Double
    var distance: CGFloat

    nonisolated var animatableData: Double {
        get { progress }
        set { progress = newValue }
    }

    var displayPadding: EdgeInsets {
        EdgeInsets(top: 0, leading: 0, bottom: distance, trailing: 0)
    }

    func draw(layout: Text.Layout, in context: inout GraphicsContext) {
        let count = layout.flatMap { $0 }.reduce(0) { $0 + $1.count }
        var index = 0
        for line in layout {
            for run in line {
                for glyph in run {
                    let start = count > 1 ? spread * Double(index) / Double(count - 1) : 0
                    let local = min(1, max(0, (progress - start) / max(0.0001, 1 - spread)))
                    var copy = context
                    copy.opacity = min(1, local * 2)
                    copy.translateBy(x: 0, y: (1 - Self.backOut(local)) * distance)
                    copy.draw(glyph)
                    index += 1
                }
            }
        }
    }

    /// Ease-out with a small overshoot.
    static func backOut(_ t: Double) -> Double {
        let c1 = 1.70158, c3 = c1 + 1
        return 1 + c3 * pow(t - 1, 3) + c1 * pow(t - 1, 2)
    }
}

#Preview {
    if #available(iOS 18, macOS 15, visionOS 2, *) {
        SplitText("Letters take the stage")
            .font(.largeTitle.bold())
            .padding()
    }
}
```

---

## Rotating Text (`rotating-text`)

Words take turns: the current one slides up and out as the next rises in.

- API: `RotatingText([…])`
- Component: Text
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/RotatingText.swift
- Files: Sources/MotionLibrary/Text/RotatingText.swift

### Usage

```swift
HStack {
    Text("We")
    RotatingText(["design", "build", "ship"])
        .foregroundStyle(.blue)
}
.font(.largeTitle.bold())
```

### How it works

Create a SwiftUI RotatingText view (iOS 17+) that cycles through an array of words every interval (2 s). Keep a @State index and show Text(words[index]).id(index) inside a ZStack with .fixedSize() and .clipped(). Give the text an asymmetric transition: insertion .move(edge: .bottom) combined with .opacity, removal .move(edge: .top) combined with .opacity. Drive it from .task with a loop of Task.sleep followed by withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { index += 1 }, so the width also springs to fit each word. Expose all words joined by commas as the accessibility label. With Reduce Motion on, use a plain opacity transition.

### Source

```swift
import SwiftUI

/// Cycles through a list of words: the current word slides up and out while
/// the next one rises in, and the width springs to fit.
public struct RotatingText: View {
    private let words: [String]
    private let interval: Double

    @State private var index = 0
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - words: The words to cycle through.
    ///   - interval: Seconds each word stays on screen.
    public init(_ words: [String], interval: Double = 2) {
        self.words = words
        self.interval = interval
    }

    public var body: some View {
        ZStack {
            if !words.isEmpty {
                Text(words[index % words.count])
                    .id(index)
                    .transition(reduceMotion ? .opacity : .asymmetric(
                        insertion: .move(edge: .bottom).combined(with: .opacity),
                        removal: .move(edge: .top).combined(with: .opacity)
                    ))
            }
        }
        .fixedSize()
        .clipped()
        .task {
            guard words.count > 1 else { return }
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(interval))
                guard !Task.isCancelled else { return }
                withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
                    index = (index + 1) % words.count
                }
            }
        }
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(words.joined(separator: ", "))
    }
}

#Preview {
    HStack(spacing: 6) {
        Text("We")
        RotatingText(["design", "build", "ship"])
            .foregroundStyle(.blue)
        Text("apps.")
    }
    .font(.title.bold())
    .padding()
}
```

---

## Glitch (`glitch`)

Every few seconds the view tears: an RGB split, sliced rows and jitter.

- API: `.glitch()`
- Component: Text
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/Glitch.swift
- Files: Sources/MotionLibrary/Text/Glitch.swift

### Usage

```swift
Text("SYSTEM FAILURE")
    .font(.title.monospaced().bold())
    .glitch()

Image("logo")
    .glitch(intensity: 2, interval: 4)
```

### How it works

Create a SwiftUI .glitch(isActive:intensity:interval:) view modifier (iOS 17+) that works on any view. Use TimelineView(.animation(minimumInterval: 1/24)) and derive everything from the clock: glitch during the first 0.35 s of every interval (2 s); within that window pick new pseudo-random values 24 times a second from a cheap integer hash of (frame, salt). While glitching: (1) behind the content, draw two silhouettes of it, content.brightness(1).colorMultiply(.cyan) offset left and content.brightness(1).colorMultiply(.red) offset right by 2–6 pt × intensity, at 85 % opacity; (2) in an overlay, draw a copy of the content offset sideways by up to ±12 pt, masked to a horizontal strip at a random height (10–35 % of the view tall); (3) jitter the whole view by up to ±1.5 pt. Outside the window, show the content untouched. With Reduce Motion on, don't glitch at all.

### Source

```swift
import SwiftUI

/// Digital glitch: every couple of seconds the view splits into red and cyan
/// copies, and horizontal slices jump sideways for a fraction of a second.
/// Works on any view (text, images, logos).
struct GlitchModifier: ViewModifier {
    var isActive: Bool
    var intensity: CGFloat
    var interval: Double

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        // Reduce Motion: no glitching at all.
        let running = isActive && !reduceMotion
        TimelineView(.animation(minimumInterval: 1.0 / 24, paused: !running)) { timeline in
            let state = running
                ? GlitchState(time: timeline.date.timeIntervalSinceReferenceDate, interval: interval)
                : .calm
            content
                // Colour fringes: solid cyan and red silhouettes (brightness 1
                // turns any colour white first) to the left and right.
                .background {
                    ZStack {
                        content.brightness(1).colorMultiply(.cyan).offset(x: -state.split * intensity)
                        content.brightness(1).colorMultiply(.red).offset(x: state.split * intensity)
                    }
                    .opacity(state.split == 0 ? 0 : 0.85)
                    .accessibilityHidden(true)
                }
                // A slice of the view displaced sideways.
                .overlay {
                    GeometryReader { geometry in
                        let height = geometry.size.height
                        content
                            .offset(x: state.sliceOffset * intensity)
                            .mask {
                                Rectangle()
                                    .frame(height: height * state.sliceHeight)
                                    .offset(y: height * (state.sliceY - 0.5))
                            }
                            .frame(width: geometry.size.width, height: height)
                    }
                    .opacity(state.sliceOffset == 0 ? 0 : 1)
                    .accessibilityHidden(true)
                }
                .offset(x: state.jitter * intensity)
        }
    }
}

/// The glitch values for one moment, derived from the clock so the effect
/// needs no state.
private struct GlitchState {
    var split: CGFloat = 0
    var sliceOffset: CGFloat = 0
    var sliceY: CGFloat = 0.5
    var sliceHeight: CGFloat = 0.2
    var jitter: CGFloat = 0

    static let calm = GlitchState()

    init() {}

    init(time: Double, interval: Double) {
        let cycle = max(0.4, interval)
        let phase = time.truncatingRemainder(dividingBy: cycle)
        // Glitch for the first 0.35 s of every cycle.
        guard phase < 0.35 else { return }
        // Change the random values 24 times a second.
        let step = Int(time * 24)
        split = 2 + 4 * Self.noise(step, 1)
        if Self.noise(step, 2) > 0.35 {
            sliceOffset = (Self.noise(step, 3) - 0.5) * 24
            sliceY = 0.15 + 0.7 * Self.noise(step, 4)
            sliceHeight = 0.1 + 0.25 * Self.noise(step, 5)
        }
        jitter = (Self.noise(step, 6) - 0.5) * 3
    }

    /// Cheap hash to 0..<1.
    static func noise(_ step: Int, _ salt: Int) -> CGFloat {
        var x = UInt64(bitPattern: Int64(step &* 7919 &+ salt &* 104_729))
        x = (x ^ (x >> 33)) &* 0xff51_afd7_ed55_8ccd
        x = (x ^ (x >> 33)) &* 0xc4ce_b9fe_1a85_ec53
        x ^= x >> 33
        return CGFloat(x % 10_000) / 10_000
    }
}

public extension View {
    /// Makes the view glitch every `interval` seconds: an RGB split, displaced
    /// slices and a little jitter.
    ///
    /// ```swift
    /// Text("SYSTEM FAILURE")
    ///     .font(.title.monospaced().bold())
    ///     .glitch()
    /// ```
    ///
    /// - Parameters:
    ///   - isActive: Turns the effect on and off.
    ///   - intensity: Multiplies every offset (1 = default strength).
    ///   - interval: Seconds between glitch bursts.
    func glitch(isActive: Bool = true, intensity: CGFloat = 1, interval: Double = 2) -> some View {
        modifier(GlitchModifier(isActive: isActive, intensity: intensity, interval: interval))
    }
}

#Preview {
    Text("SYSTEM FAILURE")
        .font(.largeTitle.monospaced().bold())
        .glitch()
        .padding()
}
```

---

## Circular Text (`circular-text`)

Text wrapped around a circle that slowly turns, like a rotating badge.

- API: `CircularText("…")`
- Component: Text
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/CircularText.swift
- Files: Sources/MotionLibrary/Text/CircularText.swift

### Usage

```swift
ZStack {
    CircularText("SWIFTUI • MOTION • LIBRARY • ", radius: 60)
        .font(.caption.monospaced().bold())
    Image(systemName: "sparkles").font(.largeTitle)
}
```

### How it works

Create a SwiftUI CircularText view (iOS 17+) with text, radius (60 pt) and speed (a multiplier, default 1, on 20° per second; negative turns the other way). Split the string into characters and place each one in a ZStack as Text(character).offset(y: −radius).rotationEffect(.degrees(index / count × 360)), so letters stand evenly spaced around the circle. Wrap the ZStack in TimelineView(.animation) and rotate it by time × 20 × speed degrees (modulo 360). Frame it to 2 × radius plus 12 pt padding. Use the full text as a single accessibility label. With Reduce Motion on (or speed 0), pause the timeline.

### Source

```swift
import SwiftUI

/// Text laid out around a circle that slowly turns, like a rotating badge or
/// stamp. Put an icon in the middle with a `ZStack`.
public struct CircularText: View {
    private let characters: [String]
    private let text: String
    private let radius: CGFloat
    private let speed: Double

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - text: The text to wrap around the circle. Add a separator such as
    ///     " • " at the end so the start and end don't touch.
    ///   - radius: Radius of the circle the letters stand on, in points.
    ///   - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast.
    ///     Negative turns counter-clockwise, 0 stops it.
    public init(_ text: String, radius: CGFloat = 60, speed: Double = 1) {
        self.text = text
        self.characters = text.map(String.init)
        self.radius = radius
        self.speed = speed * 20
    }

    public var body: some View {
        TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in
            let angle = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * speed
            ZStack {
                ForEach(characters.indices, id: \.self) { index in
                    Text(characters[index])
                        .offset(y: -radius)
                        .rotationEffect(.degrees(Double(index) / Double(max(1, characters.count)) * 360))
                }
            }
            .rotationEffect(.degrees(angle.truncatingRemainder(dividingBy: 360)))
        }
        .frame(width: radius * 2, height: radius * 2)
        .padding(12)
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(text)
    }
}

#Preview {
    ZStack {
        CircularText("SWIFTUI • MOTION • LIBRARY • ")
            .font(.system(size: 13, weight: .bold, design: .monospaced))
        Image(systemName: "sparkles")
            .font(.largeTitle)
    }
    .padding()
}
```

---

## Focus Text (`focus-text`)

Focus hops from word to word: sharp and bracketed, the rest blurred.

- API: `FocusText("…")`
- Component: Text
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/FocusText.swift
- Files: Sources/MotionLibrary/Text/FocusText.swift

### Usage

```swift
FocusText("Motion made simple")
    .font(.title.bold())

FocusText("Plan Build Ship", interval: 0.8, bracketColor: .pink)
```

### How it works

Create a SwiftUI FocusText view (iOS 17+) that splits a sentence into words in an HStack (10 pt spacing). A @State focus index advances every interval (1.2 s) in a .task loop with withAnimation(.spring(response: 0.45, dampingFraction: 0.8)). Unfocused words get blur radius 3 and opacity 0.55; the focused word is sharp. Behind the focused word only, draw four L-shaped corner brackets (Canvas, 2.5 pt round strokes, arms 8 pt, 2 pt outside the word's padded frame) and give them .matchedGeometryEffect(id: "focus", in: namespace) so the brackets glide and resize from word to word. Use the sentence as the accessibility label. With Reduce Motion on, use a short ease instead of the spring.

### Source

```swift
import SwiftUI

/// Focus moves from word to word: the current word is sharp and framed by
/// corner brackets that glide to it, while the others are blurred.
public struct FocusText: View {
    private let words: [String]
    private let text: String
    private let interval: Double
    private let blur: CGFloat
    private let bracketColor: Color

    @State private var focus = 0
    @Namespace private var namespace
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - text: The sentence; it is split into words at spaces.
    ///   - interval: Seconds the focus stays on each word.
    ///   - blur: Blur radius of the unfocused words.
    ///   - bracketColor: Colour of the corner brackets.
    public init(_ text: String, interval: Double = 1.2, blur: CGFloat = 3, bracketColor: Color = .accentColor) {
        self.text = text
        self.words = text.split(separator: " ").map(String.init)
        self.interval = interval
        self.blur = blur
        self.bracketColor = bracketColor
    }

    public var body: some View {
        HStack(spacing: 10) {
            ForEach(words.indices, id: \.self) { index in
                let focused = index == focus
                Text(words[index])
                    .padding(.horizontal, 6)
                    .padding(.vertical, 2)
                    .blur(radius: focused ? 0 : blur)
                    .opacity(focused ? 1 : 0.55)
                    .background {
                        if focused {
                            FocusBrackets(color: bracketColor)
                                .matchedGeometryEffect(id: "focus", in: namespace)
                        }
                    }
            }
        }
        .task {
            guard words.count > 1 else { return }
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(interval))
                guard !Task.isCancelled else { return }
                withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(response: 0.45, dampingFraction: 0.8)) {
                    focus = (focus + 1) % words.count
                }
            }
        }
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(text)
    }
}

/// Four L-shaped corners.
private struct FocusBrackets: View {
    let color: Color

    var body: some View {
        Canvas { context, size in
            let arm: CGFloat = min(8, size.width / 3, size.height / 3)
            var path = Path()
            for (x, y, dx, dy) in [(0.0, 0.0, 1.0, 1.0), (size.width, 0, -1, 1), (0, size.height, 1, -1), (size.width, size.height, -1, -1)] {
                path.move(to: CGPoint(x: x + dx * arm, y: y))
                path.addLine(to: CGPoint(x: x, y: y))
                path.addLine(to: CGPoint(x: x, y: y + dy * arm))
            }
            context.stroke(path, with: .color(color), style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round))
        }
        .padding(-2)
        .allowsHitTesting(false)
    }
}

#Preview {
    FocusText("Motion made simple")
        .font(.title.bold())
        .padding()
}
```

---

## Highlighter (`highlighter`)

A marker pen swipes across the text, or underlines it, left to right.

- API: `.highlighter()`
- Component: Text
- Trigger: Appear / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/Highlighter.swift
- Files: Sources/MotionLibrary/Text/Highlighter.swift

### Usage

```swift
Text("the important part")
    .highlighter()

Text("Pricing")
    .highlighter(isActive: isSelected, color: .pink, style: .underline)
```

### How it works

Create a SwiftUI .highlighter(isActive:color:style:duration:) view modifier (iOS 17+) with two styles. In a background GeometryReader, draw a rounded rectangle 6 pt wider than the text: for .marker, 55 % of the text height, yellow at 45 % opacity, rotated −1.2° and sitting over the lower half; for .underline, a full-opacity capsule about 10 % of the height (at least 3 pt) at the baseline. Scale it horizontally from 0 to 1 with anchor .leading, animated with easeOut(0.6 s) on appear and whenever isActive changes, so it swipes on from the left and back off. Disable hit testing and hide it from accessibility. With Reduce Motion on, switch it without animation.

### Source

```swift
import SwiftUI

/// The look of a `.highlighter()` stroke.
public enum HighlighterStyle: Sendable {
    /// A translucent marker band behind the lower part of the text.
    case marker
    /// A thick line under the text.
    case underline
}

/// A highlighter pen swipes across the text from left to right, and back off
/// again when deactivated.
struct HighlighterModifier: ViewModifier {
    var isActive: Bool
    var color: Color
    var style: HighlighterStyle
    var duration: Double

    @State private var drawn = false
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        content
            .background(alignment: .bottomLeading) {
                GeometryReader { geometry in
                    let size = geometry.size
                    let band: CGFloat = style == .marker ? size.height * 0.55 : max(3, size.height * 0.1)
                    RoundedRectangle(cornerRadius: style == .marker ? 3 : band / 2)
                        .fill(color.opacity(style == .marker ? 0.45 : 1))
                        .frame(width: size.width + 6, height: band)
                        // Slightly tilted, like a hand-drawn stroke.
                        .rotationEffect(.degrees(style == .marker ? -1.2 : 0))
                        .scaleEffect(x: drawn ? 1 : 0, anchor: .leading)
                        .offset(x: -3, y: size.height - band * (style == .marker ? 0.9 : 0.6))
                }
                .allowsHitTesting(false)
                .accessibilityHidden(true)
            }
            .onAppear { update(isActive) }
            .onChange(of: isActive) { _, newValue in update(newValue) }
    }

    private func update(_ active: Bool) {
        // Reduce Motion: fade in place of the swipe.
        withAnimation(reduceMotion ? nil : .easeOut(duration: duration)) {
            drawn = active
        }
    }
}

public extension View {
    /// Draws a highlighter stroke across the view while `isActive` is true.
    ///
    /// ```swift
    /// Text("the important part")
    ///     .highlighter()
    ///
    /// Text("Pricing")
    ///     .highlighter(isActive: isSelected, color: .pink, style: .underline)
    /// ```
    func highlighter(
        isActive: Bool = true,
        color: Color = .yellow,
        style: HighlighterStyle = .marker,
        duration: Double = 0.6
    ) -> some View {
        modifier(HighlighterModifier(isActive: isActive, color: color, style: style, duration: duration))
    }
}

#Preview {
    VStack(spacing: 20) {
        Text("Read the important part")
            .font(.title2.bold())
            .highlighter()
        Text("Underlined")
            .font(.title2.bold())
            .highlighter(color: .pink, style: .underline)
    }
    .padding()
}
```

---

## Morphing Text (`morphing-text`)

Words melt into each other with a gooey, liquid morph.

- API: `MorphingText([…])`
- Component: Text
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/MorphingText.swift
- Files: Sources/MotionLibrary/Text/MorphingText.swift

### Usage

```swift
MorphingText(["Design", "Build", "Ship"])
    .font(.system(size: 44, weight: .black, design: .rounded))
```

### How it works

Create a SwiftUI MorphingText(_ words:interval:morphDuration:color:) view (iOS 17+) that cycles through words with a gooey morph. Size it with hidden copies of every word in a ZStack and draw in an overlay Canvas padded out by 12 pt. In TimelineView(.animation), word index = Int(time / interval) % count; the morph runs over the last morphDuration (0.9 s) of each interval (2.2 s) with smoothstep progress p. In the Canvas, add .alphaThreshold(min: 0.3, color: color) then .blur(radius: 1.5), and inside drawLayer draw the outgoing word (resolved symbol, tagged by index) with its own .blur(radius: 6 × p) and opacity min(1, 2(1 − p)), and the incoming word with blur 6 × (1 − p) and opacity min(1, 2p), both centred; keeping both opaque through the middle stops the blob thinning out below the threshold. The threshold turns the overlapping blurry glyphs into one liquid shape. With Reduce Motion on, cross-fade plain Text views on a TimelineView(.periodic). Set the accessibility label to the words.

### Source

```swift
import SwiftUI

/// Cycles through words with a gooey morph: the outgoing word melts into the
/// incoming one. Both are blurred inside a `Canvas` and cut back to sharp
/// edges with an alpha threshold, so overlapping strokes fuse like liquid.
public struct MorphingText: View {
    private let words: [String]
    private let interval: Double
    private let morphDuration: Double
    private let color: Color

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - words: The words to cycle through, in order.
    ///   - interval: Seconds each word stays, including the morph.
    ///   - morphDuration: Seconds the morph takes.
    ///   - color: Fill colour. The alpha threshold needs a solid colour.
    public init(_ words: [String], interval: Double = 2.2, morphDuration: Double = 0.9, color: Color = .primary) {
        self.words = words.isEmpty ? [""] : words
        self.interval = max(interval, morphDuration + 0.1)
        self.morphDuration = morphDuration
        self.color = color
    }

    public var body: some View {
        // Hidden copies of every word size the view to the widest one.
        ZStack {
            ForEach(words.indices, id: \.self) { index in
                Text(words[index]).hidden()
            }
        }
        .overlay {
            if reduceMotion {
                // Reduce Motion: a plain cross-fade.
                TimelineView(.periodic(from: .now, by: interval)) { timeline in
                    let index = wordIndex(at: timeline.date)
                    Text(words[index])
                        .foregroundStyle(color)
                        .id(index)
                        .transition(.opacity)
                        .animation(.easeInOut(duration: 0.4), value: index)
                }
            } else {
                TimelineView(.animation) { timeline in
                    morph(at: timeline.date)
                }
            }
        }
        .accessibilityElement()
        .accessibilityLabel(words.joined(separator: ", "))
    }

    private func wordIndex(at date: Date) -> Int {
        Int(date.timeIntervalSinceReferenceDate / interval) % words.count
    }

    @ViewBuilder
    private func morph(at date: Date) -> some View {
        let time = date.timeIntervalSinceReferenceDate
        let current = wordIndex(at: date)
        let next = (current + 1) % words.count
        let local = time.truncatingRemainder(dividingBy: interval)
        // 0 while the word rests, then 0 → 1 over the last `morphDuration`.
        let raw = (local - (interval - morphDuration)) / morphDuration
        let progress = min(1, max(0, raw))
        let eased = progress * progress * (3 - 2 * progress)

        if progress <= 0 {
            // At rest: crisp, plain text.
            Text(words[current]).foregroundStyle(color)
        } else {
            morphCanvas(current: current, next: next, eased: eased)
        }
    }

    private func morphCanvas(current: Int, next: Int, eased: Double) -> some View {
        Canvas { context, size in
            context.addFilter(.alphaThreshold(min: 0.3, color: color))
            context.addFilter(.blur(radius: 1.5))
            context.drawLayer { layer in
                let centre = CGPoint(x: size.width / 2, y: size.height / 2)
                // Each word is blurred more the further it is from fully shown,
                // so mid-morph they overlap as soft blobs that the threshold fuses.
                // Both stay fully opaque through the middle so the blob never
                // thins out below the threshold and vanishes.
                if eased < 1, let outgoing = layer.resolveSymbol(id: current) {
                    var copy = layer
                    copy.addFilter(.blur(radius: 6 * eased))
                    copy.opacity = min(1, (1 - eased) * 2)
                    copy.draw(outgoing, at: centre)
                }
                if eased > 0, let incoming = layer.resolveSymbol(id: next) {
                    var copy = layer
                    copy.addFilter(.blur(radius: 6 * (1 - eased)))
                    copy.opacity = min(1, eased * 2)
                    copy.draw(incoming, at: centre)
                }
            }
        } symbols: {
            ForEach(words.indices, id: \.self) { index in
                Text(words[index]).tag(index)
            }
        }
        .padding(-12)
        .accessibilityHidden(true)
    }
}

#Preview {
    MorphingText(["Design", "Build", "Ship"])
        .font(.system(size: 44, weight: .black, design: .rounded))
        .padding()
}
```

---

## Neon Flicker (`neon`)

A glowing neon tube that stutters on and glitches now and then.

- API: `.neon(color:flicker:)`
- Component: Text
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/NeonFlicker.swift
- Files: Sources/MotionLibrary/Text/NeonFlicker.swift

### Usage

```swift
Text("OPEN")
    .font(.system(size: 48, weight: .bold, design: .rounded))
    .neon(color: .pink)
```

### How it works

Create a SwiftUI .neon(color:flicker:) view modifier (iOS 17+) for text or symbols on a dark background. Draw the content white when lit (or the colour at 55 % opacity when it drops out) and stack three shadows in the colour: radius 2 (opacity = level), 8 (0.9 × level) and 18 (0.7 × level), with overall opacity 0.35 + 0.65 × level. With flicker on, drive level from TimelineView(.animation) over a 6 s cycle: a stuttering switch-on with dips to 0.1 (0–0.08 s), 0.2 (0.14–0.18 s), 0.15 (0.26–0.30 s) and 0.5 (0.38–0.40 s), a glitch at 4.20–4.45 s (0.3, 0.15, 0.6), otherwise a faint hum of 0.94 + 0.06 × sin(time × 40). With Reduce Motion on, keep a steady glow with no flicker.

### Source

```swift
import SwiftUI

/// A neon tube: a white-hot core with layered coloured glow. With `flicker`
/// on, it stutters on like a tube warming up, then glitches now and again.
struct NeonModifier: ViewModifier {
    var color: Color
    var flicker: Bool

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        // Reduce Motion: a steady glow (flashing can also be uncomfortable).
        if flicker && !reduceMotion {
            TimelineView(.animation) { timeline in
                let level = Self.brightness(at: timeline.date.timeIntervalSinceReferenceDate)
                glow(content, level: level)
            }
        } else {
            glow(content, level: 1)
        }
    }

    private func glow(_ content: Content, level: Double) -> some View {
        content
            // A white-hot core when lit; the bare coloured glass when it drops out.
            .foregroundStyle(level > 0.55 ? Color.white : color.opacity(0.55))
            .shadow(color: color.opacity(level), radius: 2)
            .shadow(color: color.opacity(0.9 * level), radius: 8)
            .shadow(color: color.opacity(0.7 * level), radius: 18)
            .opacity(0.35 + 0.65 * level)
    }

    /// 1 = fully lit, lower = dimmed. A 6 s cycle: a stuttering switch-on,
    /// a steady stretch, then a short double glitch.
    static func brightness(at time: Double) -> Double {
        let t = time.truncatingRemainder(dividingBy: 6)
        let dips: [(start: Double, end: Double, level: Double)] = [
            (0.00, 0.08, 0.1), (0.14, 0.18, 0.2), (0.26, 0.30, 0.15), (0.38, 0.40, 0.5),
            (4.20, 4.24, 0.3), (4.30, 4.36, 0.15), (4.42, 4.45, 0.6),
        ]
        for dip in dips where t >= dip.start && t < dip.end {
            return dip.level
        }
        // A barely visible hum keeps the steady part alive.
        return 0.94 + 0.06 * sin(time * 40)
    }
}

public extension View {
    /// Turns text or symbols into a glowing neon sign.
    ///
    /// ```swift
    /// Text("OPEN")
    ///     .font(.system(size: 48, weight: .bold, design: .rounded))
    ///     .neon(color: .pink)
    /// ```
    ///
    /// - Parameters:
    ///   - color: The colour of the glow.
    ///   - flicker: Stutter on and glitch now and again, like a real tube.
    func neon(color: Color = .pink, flicker: Bool = true) -> some View {
        modifier(NeonModifier(color: color, flicker: flicker))
    }
}

#Preview {
    VStack(spacing: 24) {
        Text("OPEN").font(.system(size: 52, weight: .bold, design: .rounded)).neon()
        Image(systemName: "bolt.fill").font(.system(size: 40)).neon(color: .cyan, flicker: false)
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .background(Color(white: 0.08))
}
```

---

## Karaoke Text (`karaoke-text`)

A line fills with colour left to right as progress goes from 0 to 1.

- API: `KaraokeText(_:progress:)`
- Component: Text
- Trigger: Value change / Scroll
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/KaraokeText.swift
- Files: Sources/MotionLibrary/Text/KaraokeText.swift

### Usage

```swift
KaraokeText("Never gonna stop the music", progress: playback, highlight: .pink)
    .font(.title2.bold())
```

### How it works

Create a SwiftUI KaraokeText(_:progress:highlight:base:feather:) view (iOS 17+): a single-line Text in the base colour (secondary at 50 %) with an overlay of the same Text in the highlight colour, masked (alignment .leading) by an HStack in a GeometryReader: a Rectangle of width max(0, (width + feather) × progress − feather) followed by a LinearGradient black → clear of width feather (14 pt), so progress 0 shows nothing, 1 shows everything and the leading edge is soft. progress is clamped to 0…1 and changes to it animate because the frame width animates. Hide the overlay from accessibility.

### Source

```swift
import SwiftUI

/// A line of text that fills with colour from left to right as `progress`
/// goes from 0 to 1, with a soft leading edge, like karaoke lyrics or a
/// reading progress highlight. Drive `progress` from playback time, a scroll
/// position or an animation.
public struct KaraokeText: View {
    private let string: String
    private let progress: Double
    private let highlight: Color
    private let base: Color
    private let feather: CGFloat

    /// - Parameters:
    ///   - string: The text. It's laid out on one line.
    ///   - progress: 0 = nothing filled, 1 = all filled. Animatable.
    ///   - highlight: The fill colour.
    ///   - base: The colour of the unfilled text.
    ///   - feather: Width of the soft edge, in points.
    public init(_ string: String, progress: Double, highlight: Color = .accentColor, base: Color = .secondary.opacity(0.5), feather: CGFloat = 14) {
        self.string = string
        self.progress = progress
        self.highlight = highlight
        self.base = base
        self.feather = feather
    }

    public var body: some View {
        Text(string)
            .lineLimit(1)
            .foregroundStyle(base)
            .overlay {
                Text(string)
                    .lineLimit(1)
                    .foregroundStyle(highlight)
                    .mask(alignment: .leading) {
                        GeometryReader { geometry in
                            let p = min(1, max(0, progress))
                            // The solid part grows from -feather so 0 shows nothing
                            // and 1 shows everything, edge included.
                            HStack(spacing: 0) {
                                Rectangle()
                                    .frame(width: max(0, (geometry.size.width + feather) * p - feather))
                                LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing)
                                    .frame(width: feather)
                            }
                        }
                    }
                    .accessibilityHidden(true)
            }
    }
}

#Preview {
    struct Demo: View {
        @State private var progress = 0.0
        var body: some View {
            KaraokeText("Never gonna stop the music", progress: progress, highlight: .pink)
                .font(.title2.bold())
                .onAppear {
                    withAnimation(.linear(duration: 3).repeatForever(autoreverses: false)) { progress = 1 }
                }
                .padding()
        }
    }
    return Demo()
}
```

---

## Weight Wave (`weight-wave`)

Letters swell from thin to black and back in a travelling wave.

- API: `WeightWaveText(_:)`
- Component: Text
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/WeightWaveText.swift
- Files: Sources/MotionLibrary/Text/WeightWaveText.swift

### Usage

```swift
WeightWaveText("Variable weight")
    .font(.system(size: 36, design: .rounded))
```

### How it works

Create a SwiftUI WeightWaveText(_:speed:wavelength:) view (iOS 17+) that lays the characters out in an HStack(spacing: 0) inside TimelineView(.animation). Each character gets .fontWeight from the nine weights ultraLight…black, indexed by level = (sin(2π × (index / wavelength − time × speed)) + 1) / 2 (wavelength 8 letters, 0.6 waves/s × speed, a multiplier defaulting to 1), so a swell of bold travels through the word. The font comes from the environment. With Reduce Motion on, pause the timeline and use .regular. Combine into one accessibility element labelled with the string.

### Source

```swift
import SwiftUI

/// Letters swell from thin to black and back in a travelling wave, like a
/// variable font being played. Uses the font from the environment.
public struct WeightWaveText: View {
    private let string: String
    private let speed: Double
    private let wavelength: Double

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    private static let weights: [Font.Weight] = [
        .ultraLight, .thin, .light, .regular, .medium, .semibold, .bold, .heavy, .black,
    ]

    /// - Parameters:
    ///   - string: The text to show.
    ///   - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast.
    ///   - wavelength: Letters per wave.
    public init(_ string: String, speed: Double = 1, wavelength: Double = 8) {
        self.string = string
        self.speed = speed * 0.6
        self.wavelength = max(1, wavelength)
    }

    public var body: some View {
        let characters = Array(string)
        TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in
            let time = timeline.date.timeIntervalSinceReferenceDate
            HStack(spacing: 0) {
                ForEach(characters.indices, id: \.self) { index in
                    Text(String(characters[index]))
                        .fontWeight(reduceMotion ? .regular : weight(index: index, time: time))
                }
            }
        }
        .accessibilityElement()
        .accessibilityLabel(string)
    }

    private func weight(index: Int, time: Double) -> Font.Weight {
        let phase = (Double(index) / wavelength - time * speed) * 2 * .pi
        let level = (sin(phase) + 1) / 2
        let weights = Self.weights
        return weights[min(weights.count - 1, Int(level * Double(weights.count)))]
    }
}

#Preview {
    WeightWaveText("Variable weight")
        .font(.system(size: 36, design: .rounded))
        .padding()
}
```

---

## Exploding Text (`exploding-text`)

Letters blast apart spinning and spring back into place.

- API: `ExplodingText(_:isExploded:)`
- Component: Text
- Trigger: Value change / Tap
- Requires: iOS 18+ · macOS 15+ · visionOS 2+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/ExplodingText.swift
- Files: Sources/MotionLibrary/Text/ExplodingText.swift

### Usage

```swift
ExplodingText("Kaboom!", isExploded: exploded)
    .font(.system(size: 44, weight: .heavy, design: .rounded))
    .onTapGesture { exploded.toggle() }
```

### How it works

Create a SwiftUI ExplodingText(_:isExploded:distance:) view (iOS 18+) using a TextRenderer that is Animatable over progress (0 = assembled, 1 = scattered). Its displayPadding is distance on every side (60 pt). In draw(layout:in:), for each glyph (counted across lines and runs) take three stable pseudo-random values r0, r1, r2 from a hash of its index; translate by (cos, sin)(r0 × 2π) × distance × (0.5 + 0.5 × r1) × progress, rotate around the glyph's typographic-bounds centre by (r2 − 0.5) × 540° × progress, and set opacity 1 − 0.7 × progress. Animate with .easeOut(0.45 s) when exploding and .spring(duration: 0.8, bounce: 0.35) when reassembling. With Reduce Motion on, use distance 0 (just a fade) and a 0.3 s ease. Keep the string as the accessibility label.

### Source

```swift
import SwiftUI

/// Letters blast apart in random directions, spinning, and spring back into
/// place, drawn with a `TextRenderer` so the text keeps its normal layout.
@available(iOS 18, macOS 15, visionOS 2, *)
public struct ExplodingText: View {
    private let string: String
    private let isExploded: Bool
    private let distance: CGFloat

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - string: The text to show.
    ///   - isExploded: `true` scatters the letters, `false` reassembles them.
    ///   - distance: How far letters fly, in points.
    public init(_ string: String, isExploded: Bool, distance: CGFloat = 60) {
        self.string = string
        self.isExploded = isExploded
        self.distance = distance
    }

    public var body: some View {
        Text(string)
            .textRenderer(ExplodeRenderer(
                progress: isExploded ? 1 : 0,
                distance: reduceMotion ? 0 : distance
            ))
            // Out fast, back on a bouncy spring. Reduce Motion: a fade only.
            .animation(
                reduceMotion ? .easeInOut(duration: 0.3)
                    : isExploded ? .easeOut(duration: 0.45) : .spring(duration: 0.8, bounce: 0.35),
                value: isExploded
            )
            .accessibilityLabel(string)
    }
}

@available(iOS 18, macOS 15, visionOS 2, *)
struct ExplodeRenderer: TextRenderer, Animatable {
    /// 0 = assembled, 1 = fully scattered.
    var progress: Double
    var distance: CGFloat

    nonisolated var animatableData: Double {
        get { progress }
        set { progress = newValue }
    }

    var displayPadding: EdgeInsets {
        EdgeInsets(top: distance, leading: distance, bottom: distance, trailing: distance)
    }

    func draw(layout: Text.Layout, in context: inout GraphicsContext) {
        var index = 0
        for line in layout {
            for run in line {
                for glyph in run {
                    let random = Self.random(index)
                    let angle = random.0 * 2 * .pi
                    let reach = distance * (0.5 + 0.5 * random.1)
                    let bounds = glyph.typographicBounds.rect
                    var copy = context
                    copy.opacity = 1 - 0.7 * min(1, max(0, progress))
                    copy.translateBy(x: cos(angle) * reach * progress, y: sin(angle) * reach * progress)
                    // Spin around the glyph's own centre.
                    copy.translateBy(x: bounds.midX, y: bounds.midY)
                    copy.rotate(by: .degrees((random.2 - 0.5) * 540 * progress))
                    copy.translateBy(x: -bounds.midX, y: -bounds.midY)
                    copy.draw(glyph)
                    index += 1
                }
            }
        }
    }

    /// Three stable pseudo-random values in 0..<1 for a glyph index.
    static func random(_ index: Int) -> (Double, Double, Double) {
        func hash(_ n: Int) -> Double {
            var x = UInt64(truncatingIfNeeded: n &* 2_654_435_761)
            x = (x ^ (x >> 31)) &* 0x9E37_79B9_7F4A_7C15
            x ^= x >> 29
            return Double(x % 10_000) / 10_000
        }
        return (hash(index * 3 + 1), hash(index * 3 + 2), hash(index * 3 + 3))
    }
}

@available(iOS 18, macOS 15, visionOS 2, *)
private struct ExplodingTextPreview: View {
    @State private var exploded = false

    var body: some View {
        ExplodingText("Kaboom!", isExploded: exploded)
            .font(.system(size: 44, weight: .heavy, design: .rounded))
            .onTapGesture { exploded.toggle() }
            .padding(60)
    }
}

#Preview {
    if #available(iOS 18, macOS 15, visionOS 2, *) {
        ExplodingTextPreview()
    }
}
```

---

## Liquid Fill Text (`liquid-fill-text`)

A wavy water line rises inside the letters as progress grows.

- API: `LiquidFillText(_:progress:)`
- Component: Text
- Trigger: Value change / Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/LiquidFillText.swift
- Files: Sources/MotionLibrary/Text/LiquidFillText.swift

### Usage

```swift
LiquidFillText("\(Int(level * 100))%", progress: level, color: .cyan)
    .font(.system(size: 64, weight: .black, design: .rounded))
```

### How it works

Create a SwiftUI LiquidFillText(_:progress:color:waveHeight:) view (iOS 17+). Draw the Text in the colour at 15 % opacity as an empty glass, then overlay a TimelineView(.animation) with a ZStack of two wave shapes, masked by the same Text: a back wave in the colour at 45 % (phase = time × 2.2 + 1.3, amplitude 0.8 × waveHeight) and a front wave in the full colour (phase = time × 3, amplitude waveHeight, default 6 pt). The wave Shape has level, phase and amplitude, animates level, and fills everything below y = baseline + amplitude × sin(x / width × 3π + phase). A line of text has descender room below the glyphs and space above the caps, so fill between bottom = maxY − 0.2 × height + 2 × amplitude and top = minY + 0.12 × height − 2 × amplitude: baseline = bottom − (bottom − top) × level, so 0 is empty and 1 full. Clamp progress to 0…1 and animate level changes with .spring(duration: 0.9, bounce: 0.25). With Reduce Motion on, pause the timeline (the level still animates). Hide the overlay from accessibility; the label is the text.

### Source

```swift
import SwiftUI

/// Text that fills with liquid: a wavy water line rises inside the glyphs as
/// `progress` goes from 0 to 1, with a second, lighter wave behind it.
///
/// ```swift
/// LiquidFillText("72%", progress: 0.72, color: .cyan)
///     .font(.system(size: 64, weight: .black, design: .rounded))
/// ```
public struct LiquidFillText: View {
    private let text: String
    private let progress: Double
    private let color: Color
    private let waveHeight: CGFloat

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - text: The string to fill.
    ///   - progress: Fill level, 0 (empty) to 1 (full). Changes animate.
    ///   - color: Colour of the liquid.
    ///   - waveHeight: Height of the waves, in points.
    public init(_ text: String, progress: Double, color: Color = .blue, waveHeight: CGFloat = 6) {
        self.text = text
        self.progress = min(1, max(0, progress))
        self.color = color
        self.waveHeight = waveHeight
    }

    public var body: some View {
        Text(text)
            .foregroundStyle(color.opacity(0.15))
            .overlay {
                TimelineView(.animation(paused: reduceMotion)) { timeline in
                    let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate
                    ZStack {
                        LiquidWave(level: progress, phase: time * 2.2 + 1.3, amplitude: waveHeight * 0.8)
                            .fill(color.opacity(0.45))
                        LiquidWave(level: progress, phase: time * 3, amplitude: waveHeight)
                            .fill(color)
                    }
                    .animation(.spring(duration: 0.9, bounce: 0.25), value: progress)
                }
                .mask { Text(text) }
                .accessibilityHidden(true)
            }
            .accessibilityLabel(text)
    }
}

/// A sine-wave water line at `level` (0 = bottom, 1 = top), filled below.
struct LiquidWave: Shape {
    var level: Double
    var phase: Double
    var amplitude: CGFloat

    nonisolated var animatableData: Double {
        get { level }
        set { level = newValue }
    }

    nonisolated func path(in rect: CGRect) -> Path {
        // A line of text has room for descenders below the glyphs and accents
        // above them; fill between roughly the baseline and the cap height,
        // going a wave's height past both so 0 is empty and 1 full.
        let bottom = rect.maxY - rect.height * 0.2 + amplitude * 2
        let top = rect.minY + rect.height * 0.12 - amplitude * 2
        let baseline = bottom - (bottom - top) * level
        var path = Path()
        path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
        let steps = max(8, Int(rect.width / 4))
        for step in 0...steps {
            let x = rect.minX + rect.width * CGFloat(step) / CGFloat(steps)
            let angle = Double(x / max(rect.width, 1)) * 2 * .pi * 1.5 + phase
            path.addLine(to: CGPoint(x: x, y: baseline + amplitude * sin(angle)))
        }
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
        path.closeSubpath()
        return path
    }
}

#Preview {
    struct Demo: View {
        @State private var progress = 0.3
        var body: some View {
            LiquidFillText("72%", progress: progress, color: .cyan)
                .font(.system(size: 80, weight: .black, design: .rounded))
                .onTapGesture { progress = progress > 0.8 ? 0.1 : progress + 0.3 }
        }
    }
    return Demo()
}
```

---

## Pressable (`pressable`)

Button squashes and darkens on press, then springs back with a light haptic.

- API: `.buttonStyle(.pressable)`
- Component: Button
- Trigger: Press
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/PressableButtonStyle.swift
- Files: Sources/MotionLibrary/Button/PressableButtonStyle.swift, Sources/MotionLibrary/Support/Haptics.swift

### Usage

```swift
Button("Continue") { }
    .buttonStyle(.pressable)

Button("Big squish") { }
    .buttonStyle(.pressable(scale: 0.85))
```

### How it works

Create a SwiftUI ButtonStyle called PressableButtonStyle (iOS 17+). While pressed, the label scales to 0.94 and its brightness drops slightly (-0.06); on release it springs back using .spring(response: 0.3, dampingFraction: 0.6). Fire a light impact haptic with .sensoryFeedback only when the press begins. Expose it as .buttonStyle(.pressable) and .buttonStyle(.pressable(scale:haptics:)) via a static extension on ButtonStyle.

### Source

```swift
import SwiftUI

/// A button style that squashes slightly and darkens while pressed, then
/// springs back. Adds a light haptic on press (iOS).
///
/// ```swift
/// Button("Continue") { }
///     .buttonStyle(.pressable)
///
/// Button("Big squish") { }
///     .buttonStyle(.pressable(scale: 0.85))
/// ```
public struct PressableButtonStyle: ButtonStyle {
    public var scale: CGFloat
    public var haptics: Bool

    public init(scale: CGFloat = 0.94, haptics: Bool = true) {
        self.scale = scale
        self.haptics = haptics
    }

    public func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .scaleEffect(configuration.isPressed ? scale : 1)
            .brightness(configuration.isPressed ? -0.06 : 0)
            .animation(.spring(response: 0.3, dampingFraction: 0.6), value: configuration.isPressed)
            .haptic(.lightImpact, trigger: configuration.isPressed) { _, isPressed in
                haptics && isPressed
            }
    }
}

public extension ButtonStyle where Self == PressableButtonStyle {
    static var pressable: PressableButtonStyle { PressableButtonStyle() }

    static func pressable(scale: CGFloat = 0.94, haptics: Bool = true) -> PressableButtonStyle {
        PressableButtonStyle(scale: scale, haptics: haptics)
    }
}

#Preview {
    Button {
    } label: {
        Text("Press me")
            .font(.headline)
            .foregroundStyle(.white)
            .padding(.horizontal, 28)
            .padding(.vertical, 14)
            .background(Capsule().fill(Color.accentColor))
    }
    .buttonStyle(.pressable)
    .padding()
}

// MARK: - Haptics.swift
// Needed by this effect: save it as its own file next to the one above.

import SwiftUI

/// The haptics the package plays. `SensoryFeedback` and `sensoryFeedback`
/// need visionOS 26, but the package supports visionOS 1, so call sites use
/// this enum and `.haptic(…)`, which does nothing on visionOS (it has no
/// haptics anyway).
enum Haptic: Equatable {
    case success
    case error
    case lightImpact

    #if !os(visionOS)
    var feedback: SensoryFeedback {
        switch self {
        case .success: .success
        case .error: .error
        case .lightImpact: .impact(weight: .light)
        }
    }
    #endif
}

extension View {
    /// Plays `haptic` every time `trigger` changes.
    func haptic<T: Equatable>(_ haptic: Haptic, trigger: T) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(haptic.feedback, trigger: trigger)
        #endif
    }

    /// Plays `haptic` when `trigger` changes and `condition` returns true.
    func haptic<T: Equatable>(_ haptic: Haptic, trigger: T, condition: @escaping (T, T) -> Bool) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(haptic.feedback, trigger: trigger, condition: condition)
        #endif
    }

    /// Plays the haptic returned by `haptic` (if any) when `trigger` changes.
    func haptic<T: Equatable>(trigger: T, _ haptic: @escaping (T, T) -> Haptic?) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(trigger: trigger) { old, new in haptic(old, new)?.feedback }
        #endif
    }
}
```

---

## Tap Ripple (`ripple`)

A circular ripple expands from the exact tap location.

- API: `.ripple()`
- Component: Button
- Trigger: Tap
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/Ripple.swift
- Files: Sources/MotionLibrary/Button/Ripple.swift

### Usage

```swift
Text("Tap")
    .frame(width: 200, height: 60)
    .background(.indigo, in: RoundedRectangle(cornerRadius: 12))
    .ripple(color: .white, cornerRadius: 12)
```

### How it works

Create a SwiftUI .ripple() view modifier (iOS 17+) that shows a Material-style ripple. Use a simultaneous SpatialTapGesture so it doesn't block the view's own button action. Each tap appends a ripple at the tap location; each ripple is a translucent circle (white, 35% opacity) sized to 2× the view's diagonal that scales from ~0 to 1 while fading to 0 with easeOut over 0.7 s, then is removed. Ripples are drawn in an overlay clipped to a rounded rectangle with a configurable corner radius and never intercept touches. Multiple quick taps should produce overlapping ripples.

### Source

```swift
import SwiftUI

/// Material-style ripple that expands from the exact point the user tapped.
struct RippleModifier: ViewModifier {
    var color: Color
    var cornerRadius: CGFloat
    var duration: Double

    @State private var ripples: [Ripple] = []

    private struct Ripple: Identifiable {
        let id = UUID()
        let location: CGPoint
    }

    func body(content: Content) -> some View {
        content
            .overlay {
                GeometryReader { geometry in
                    let size = geometry.size
                    // Large enough to cover the view from any tap point.
                    let diameter = 2 * hypot(size.width, size.height)
                    ZStack {
                        ForEach(ripples) { ripple in
                            RippleCircle(color: color, diameter: diameter, duration: duration)
                                .position(ripple.location)
                        }
                    }
                    .frame(width: size.width, height: size.height)
                }
                .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
                .allowsHitTesting(false)
            }
            .simultaneousGesture(
                SpatialTapGesture().onEnded { value in
                    let ripple = Ripple(location: value.location)
                    ripples.append(ripple)
                    DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.1) {
                        ripples.removeAll { $0.id == ripple.id }
                    }
                }
            )
    }
}

private struct RippleCircle: View {
    let color: Color
    let diameter: CGFloat
    let duration: Double

    @State private var expanded = false

    var body: some View {
        Circle()
            .fill(color)
            .frame(width: diameter, height: diameter)
            .scaleEffect(expanded ? 1 : 0.01)
            .opacity(expanded ? 0 : 0.35)
            .onAppear {
                withAnimation(.easeOut(duration: duration)) {
                    expanded = true
                }
            }
    }
}

public extension View {
    /// Adds a ripple that starts at the tap location.
    ///
    /// ```swift
    /// Button("Tap") { }
    ///     .padding()
    ///     .background(.blue, in: RoundedRectangle(cornerRadius: 12))
    ///     .ripple(cornerRadius: 12)
    /// ```
    func ripple(
        color: Color = .white,
        cornerRadius: CGFloat = 12,
        duration: Double = 0.7
    ) -> some View {
        modifier(RippleModifier(color: color, cornerRadius: cornerRadius, duration: duration))
    }
}

#Preview {
    Text("Tap anywhere")
        .font(.headline)
        .foregroundStyle(.white)
        .frame(width: 240, height: 80)
        .background(Color.indigo, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
        .ripple(cornerRadius: 16)
        .padding()
}
```

---

## Error Shake (`shake`)

Horizontal "no" shake for wrong input, triggered by a value change.

- API: `.shake(trigger:)`
- Component: Button
- Trigger: Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/Shake.swift
- Files: Sources/MotionLibrary/Button/Shake.swift

### Usage

```swift
@State private var attempts = 0

SecureField("Password", text: $password)
    .shake(trigger: attempts)
    .sensoryFeedback(.error, trigger: attempts)

// On a failed login:
attempts += 1
```

### How it works

Create a SwiftUI .shake(trigger:) view modifier (iOS 17+) for error feedback. Every time the Equatable trigger changes, the view shakes horizontally using keyframeAnimator on an x offset: -10, +10, -7, +7, -3 points (≈0.07 s each, cubic keyframes) and then settles back to 0 with a bouncy SpringKeyframe. Amplitude should be configurable. Pair it with .sensoryFeedback(.error, trigger:) for haptics.

### Source

```swift
import SwiftUI

/// Horizontal "no" shake, e.g. for a wrong password. Plays every time
/// `trigger` changes.
struct ShakeModifier<Trigger: Equatable>: ViewModifier {
    var trigger: Trigger
    var amplitude: CGFloat

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        // Reduce Motion: no shake (pair it with a colour or message change).
        let scale: CGFloat = reduceMotion ? 0 : 1
        return content.keyframeAnimator(initialValue: CGFloat(0), trigger: trigger) { view, offset in
            view.offset(x: offset * scale)
        } keyframes: { _ in
            KeyframeTrack {
                CubicKeyframe(-amplitude, duration: 0.07)
                CubicKeyframe(amplitude, duration: 0.07)
                CubicKeyframe(-amplitude * 0.7, duration: 0.07)
                CubicKeyframe(amplitude * 0.7, duration: 0.07)
                CubicKeyframe(-amplitude * 0.3, duration: 0.06)
                SpringKeyframe(0, duration: 0.2, spring: .bouncy)
            }
        }
    }
}

public extension View {
    /// Shakes the view horizontally whenever `trigger` changes.
    ///
    /// ```swift
    /// @State private var attempts = 0
    ///
    /// SecureField("Password", text: $password)
    ///     .shake(trigger: attempts)
    /// // on failure: attempts += 1
    /// ```
    func shake<Trigger: Equatable>(trigger: Trigger, amplitude: CGFloat = 10) -> some View {
        modifier(ShakeModifier(trigger: trigger, amplitude: amplitude))
    }
}

#Preview {
    struct Demo: View {
        @State private var attempts = 0
        var body: some View {
            Button("Wrong password") { attempts += 1 }
                .buttonStyle(.borderedProminent)
                .tint(.red)
                .shake(trigger: attempts)
                .haptic(.error, trigger: attempts)
        }
    }
    return Demo()
}
```

---

## Magnetic Button (`magnetic`)

The button drifts toward a nearby pointer or dragging finger, then springs home.

- API: `.magnetic()`
- Component: Button
- Trigger: Hover / Drag
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/Magnetic.swift
- Files: Sources/MotionLibrary/Button/Magnetic.swift

### Usage

```swift
Button("Get started") { }
    .buttonStyle(.borderedProminent)
    .magnetic()

Image(systemName: "plus.circle.fill")
    .font(.system(size: 44))
    .magnetic(strength: 0.5, radius: 40)
```

### How it works

Create a SwiftUI .magnetic(strength:radius:) view modifier (iOS 17+) that pulls a view toward the pointer. Pad the view by `radius` (60 pt) to enlarge its tracking area, give it a rectangular contentShape, measure the padded size with a GeometryReader background, then remove the padding again with .padding(-radius). In onContinuousHover (pointer on iPad, Mac and visionOS) and in a simultaneous DragGesture(minimumDistance: 0) (touch), offset the original content by (location − centre) × strength (0.3) using an interactiveSpring(response 0.25, damping 0.7). When hover ends or the drag ends, spring back to zero with spring(response 0.45, damping 0.45) for a little overshoot. With Reduce Motion on, don't move.

### Source

```swift
import SwiftUI

/// Pulls the view toward the pointer while it hovers nearby (iPad pointer,
/// Mac, visionOS), or toward the finger while it drags on touch screens, then
/// springs back to rest.
struct MagneticModifier: ViewModifier {
    var strength: CGFloat
    var radius: CGFloat

    @State private var offset: CGSize = .zero
    /// Size of the padded tracking area.
    @State private var areaSize: CGSize = .zero
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        content
            .offset(reduceMotion ? .zero : offset)
            // Grow the tracking area by `radius` so the pull starts before the
            // pointer reaches the view.
            .padding(radius)
            .contentShape(Rectangle())
            .background {
                GeometryReader { proxy in
                    Color.clear
                        .onAppear { areaSize = proxy.size }
                        .onChange(of: proxy.size) { _, newSize in areaSize = newSize }
                }
            }
            .onContinuousHover(coordinateSpace: .local) { phase in
                switch phase {
                case let .active(location):
                    attract(to: location)
                case .ended:
                    release()
                }
            }
            .simultaneousGesture(
                DragGesture(minimumDistance: 0, coordinateSpace: .local)
                    .onChanged { attract(to: $0.location) }
                    .onEnded { _ in release() }
            )
            .padding(-radius)
    }

    private func attract(to location: CGPoint) {
        // `location` is in the padded tracking area, whose centre is the view's centre.
        let centre = CGPoint(x: areaSize.width / 2, y: areaSize.height / 2)
        withAnimation(.interactiveSpring(response: 0.25, dampingFraction: 0.7)) {
            offset = CGSize(
                width: (location.x - centre.x) * strength,
                height: (location.y - centre.y) * strength
            )
        }
    }

    private func release() {
        withAnimation(.spring(response: 0.45, dampingFraction: 0.45)) {
            offset = .zero
        }
    }
}

public extension View {
    /// Makes the view drift toward a nearby pointer (or a dragging finger) and
    /// spring back when it leaves.
    ///
    /// ```swift
    /// Button("Get started") { }
    ///     .buttonStyle(.borderedProminent)
    ///     .magnetic()
    /// ```
    ///
    /// - Parameters:
    ///   - strength: Fraction of the pointer's distance from the centre that
    ///     the view moves (0…1).
    ///   - radius: How far outside the view, in points, the pull starts.
    func magnetic(strength: CGFloat = 0.3, radius: CGFloat = 60) -> some View {
        modifier(MagneticModifier(strength: strength, radius: radius))
    }
}

#Preview {
    Button("Get started") { }
        .buttonStyle(.borderedProminent)
        .controlSize(.large)
        .magnetic()
        .padding(80)
}
```

---

## Hold to Confirm (`hold-to-confirm`)

Liquid fills the button while you hold; the action runs only when it's full.

- API: `HoldToConfirmButton("…") { }`
- Component: Button
- Trigger: Press
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/HoldToConfirmButton.swift
- Files: Sources/MotionLibrary/Button/HoldToConfirmButton.swift, Sources/MotionLibrary/Support/Haptics.swift

### Usage

```swift
HoldToConfirmButton("Hold to delete") {
    deleteAccount()
}

HoldToConfirmButton(duration: 2, color: .orange) {
    launch()
} label: {
    Label("Hold to launch", systemImage: "flame")
}
```

### How it works

Create a SwiftUI HoldToConfirmButton (iOS 17+) for destructive actions. A capsule with a 15% tint of color (red) shows the label in color. While pressed (onLongPressGesture(minimumDuration:perform:onPressingChanged:)), animate a progress value linearly from its current value to 1 over the remaining duration (1.2 s); releasing early animates it back to 0 with easeOut(0.35). Draw the liquid as a Shape filled from the bottom up to progress, with a sine-wave top edge (4 pt amplitude, phase driven by TimelineView) that flattens when empty or full. Mask a stack of the liquid plus a white copy of the label with the same shape, so the submerged part of the text turns white. On completion, run the action, swap the label for a checkmark with a spring, play a success haptic and reset after 1.2 s. Scale to 0.97 while pressing.

### Source

```swift
import SwiftUI

/// A capsule button that fills with liquid while it is held and runs its
/// action only when the fill reaches the top. Letting go early drains it.
/// Good for destructive or irreversible actions.
public struct HoldToConfirmButton<Label: View>: View {
    private let duration: Double
    private let color: Color
    private let action: () -> Void
    private let label: Label

    @State private var progress: CGFloat = 0
    @State private var isPressing = false
    @State private var isConfirmed = false
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - duration: Seconds the user must hold.
    ///   - color: Colour of the liquid. The resting background is a tint
    ///     of it and the label is drawn in it, turning white where submerged.
    ///   - action: Runs once the hold completes.
    ///   - label: The button's content.
    public init(
        duration: Double = 1.2,
        color: Color = .red,
        action: @escaping () -> Void,
        @ViewBuilder label: () -> Label
    ) {
        self.duration = duration
        self.color = color
        self.action = action
        self.label = label()
    }

    @available(*, deprecated, renamed: "init(duration:color:action:label:)")
    public init(
        duration: Double = 1.2,
        fillColor: Color,
        action: @escaping () -> Void,
        @ViewBuilder label: () -> Label
    ) {
        self.init(duration: duration, color: fillColor, action: action, label: label)
    }

    public var body: some View {
        TimelineView(.animation(paused: reduceMotion || (!isPressing && progress == 0))) { timeline in
            let liquid = LiquidFill(
                progress: progress,
                phase: reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * 6,
                waveHeight: progress > 0 && progress < 1 ? 4 : 0
            )
            // The label sizes the button; the capsule and liquid only decorate it.
            content
                .foregroundStyle(color)
                .background(Capsule().fill(color.opacity(0.15)))
                .overlay {
                    // The liquid, with the label redrawn in white where it's submerged.
                    ZStack {
                        liquid.fill(color)
                        content.foregroundStyle(.white)
                    }
                    .mask(liquid)
                }
                .clipShape(Capsule())
        }
        .scaleEffect(isPressing ? 0.97 : 1)
        .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isPressing)
        .contentShape(Capsule())
        .onLongPressGesture(minimumDuration: duration) {
            confirm()
        } onPressingChanged: { pressing in
            guard !isConfirmed else { return }
            isPressing = pressing
            if pressing {
                withAnimation(.linear(duration: duration * (1 - progress))) { progress = 1 }
            } else if progress < 1 {
                withAnimation(.easeOut(duration: 0.35)) { progress = 0 }
            }
        }
        .haptic(.success, trigger: isConfirmed) { _, confirmed in confirmed }
        .accessibilityElement(children: .combine)
        .accessibilityAddTraits(.isButton)
        .accessibilityHint("Press and hold to confirm")
        .accessibilityAction { action() }
    }

    /// The label (swapped for a checkmark once confirmed), padded to button size.
    private var content: some View {
        label
            .opacity(isConfirmed ? 0 : 1)
            .overlay {
                Image(systemName: "checkmark")
                    .fontWeight(.bold)
                    .scaleEffect(isConfirmed ? 1 : 0.3)
                    .opacity(isConfirmed ? 1 : 0)
            }
            .padding(.horizontal, 24)
            .padding(.vertical, 14)
    }

    private func confirm() {
        isPressing = false
        withAnimation(.spring(response: 0.35, dampingFraction: 0.6)) { isConfirmed = true }
        action()
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
            withAnimation(.easeInOut(duration: 0.4)) {
                isConfirmed = false
                progress = 0
            }
        }
    }
}

public extension HoldToConfirmButton where Label == Text {
    /// A hold-to-confirm button with a text label.
    ///
    /// ```swift
    /// HoldToConfirmButton("Hold to delete") { deleteAccount() }
    /// ```
    init(
        _ title: LocalizedStringKey,
        duration: Double = 1.2,
        color: Color = .red,
        action: @escaping () -> Void
    ) {
        self.init(duration: duration, color: color, action: action) {
            Text(title).font(.headline)
        }
    }

    /// A hold-to-confirm button whose label is a string you already have, shown as is.
    @_disfavoredOverload
    init<S: StringProtocol>(
        _ title: S,
        duration: Double = 1.2,
        color: Color = .red,
        action: @escaping () -> Void
    ) {
        self.init(duration: duration, color: color, action: action) {
            Text(title).font(.headline)
        }
    }

    @available(*, deprecated, renamed: "init(_:duration:color:action:)")
    init(
        _ title: LocalizedStringKey,
        duration: Double = 1.2,
        fillColor: Color,
        action: @escaping () -> Void
    ) {
        self.init(title, duration: duration, color: fillColor, action: action)
    }
}

/// A rectangle filled from the bottom up to `progress`, with a sine wave on top.
private struct LiquidFill: Shape {
    var progress: CGFloat
    var phase: Double
    var waveHeight: CGFloat

    nonisolated var animatableData: AnimatablePair<CGFloat, CGFloat> {
        get { AnimatablePair(progress, waveHeight) }
        set { progress = newValue.first; waveHeight = newValue.second }
    }

    nonisolated func path(in rect: CGRect) -> Path {
        // Overfill slightly so the wave crests are hidden when full.
        let level = rect.maxY - (rect.height + waveHeight * 2) * progress
        var path = Path()
        path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
        let steps = 24
        for step in 0...steps {
            let x = rect.minX + rect.width * CGFloat(step) / CGFloat(steps)
            let angle = Double(step) / Double(steps) * .pi * 3 + phase
            path.addLine(to: CGPoint(x: x, y: level + CGFloat(sin(angle)) * waveHeight))
        }
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
        path.closeSubpath()
        return path
    }
}

#Preview {
    HoldToConfirmButton("Hold to delete") { }
        .padding()
}

// MARK: - Haptics.swift
// Needed by this effect: save it as its own file next to the one above.

import SwiftUI

/// The haptics the package plays. `SensoryFeedback` and `sensoryFeedback`
/// need visionOS 26, but the package supports visionOS 1, so call sites use
/// this enum and `.haptic(…)`, which does nothing on visionOS (it has no
/// haptics anyway).
enum Haptic: Equatable {
    case success
    case error
    case lightImpact

    #if !os(visionOS)
    var feedback: SensoryFeedback {
        switch self {
        case .success: .success
        case .error: .error
        case .lightImpact: .impact(weight: .light)
        }
    }
    #endif
}

extension View {
    /// Plays `haptic` every time `trigger` changes.
    func haptic<T: Equatable>(_ haptic: Haptic, trigger: T) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(haptic.feedback, trigger: trigger)
        #endif
    }

    /// Plays `haptic` when `trigger` changes and `condition` returns true.
    func haptic<T: Equatable>(_ haptic: Haptic, trigger: T, condition: @escaping (T, T) -> Bool) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(haptic.feedback, trigger: trigger, condition: condition)
        #endif
    }

    /// Plays the haptic returned by `haptic` (if any) when `trigger` changes.
    func haptic<T: Equatable>(trigger: T, _ haptic: @escaping (T, T) -> Haptic?) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(trigger: trigger) { old, new in haptic(old, new)?.feedback }
        #endif
    }
}
```

---

## Loading → Success (`status-button`)

The button morphs into a spinner, then a green checkmark or a red shake.

- API: `StatusButton("…", status:) { }`
- Component: Button
- Trigger: Tap / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/StatusButton.swift
- Files: Sources/MotionLibrary/Button/StatusButton.swift, Sources/MotionLibrary/Support/CheckmarkShape.swift, Sources/MotionLibrary/Support/Haptics.swift

### Usage

```swift
@State private var status = StatusButton.Status.idle

StatusButton("Pay now", status: status) {
    status = .loading
    Task {
        status = await pay() ? .success : .failure
        try? await Task.sleep(for: .seconds(1.5))
        status = .idle
    }
}
```

### How it works

Create a SwiftUI StatusButton (iOS 17+) driven by a status enum: idle, loading, success, failure. It is a 50 pt tall capsule Button. Idle shows the title at its natural width. For any other status the frame width animates to 50 pt, turning the capsule into a circle (spring response 0.45, damping 0.75), and the title shrinks and fades. Loading shows a white 70% arc spinning forever. Success turns the fill green and draws a white checkmark path with trim from 0 to 1 (easeOut 0.35 s, 0.15 s delay). Failure turns the fill red, scales in an xmark and shakes horizontally with a GeometryEffect (8 pt, three swings). Play success and error haptics with sensoryFeedback, disable the button when not idle, and expose the status as the accessibility value.

### Source

```swift
import SwiftUI

/// A capsule button that morphs to show the result of its action: it shrinks
/// into a spinning circle while loading, then turns green with a drawn
/// checkmark on success, or red with a shake on failure.
///
/// You own the status, so it works with any async work:
///
/// ```swift
/// @State private var status = StatusButton.Status.idle
///
/// StatusButton("Pay now", status: status) {
///     status = .loading
///     Task {
///         status = await pay() ? .success : .failure
///         try? await Task.sleep(for: .seconds(1.5))
///         status = .idle
///     }
/// }
/// ```
public struct StatusButton: View {
    /// The phases a `StatusButton` moves through.
    public enum Status: Equatable, Sendable {
        case idle
        case loading
        case success
        case failure
    }

    private let title: Text
    private let status: Status
    private let color: Color
    private let action: () -> Void

    @State private var spin = false
    @State private var shakes = 0
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    private let height: CGFloat = 50

    /// - Parameters:
    ///   - title: The button's label, looked up in your app's strings.
    ///   - status: The current phase; change it to morph the button.
    ///   - color: Background while idle or loading.
    ///   - action: Runs on tap (only while idle).
    public init(
        _ title: LocalizedStringKey,
        status: Status,
        color: Color = .accentColor,
        action: @escaping () -> Void
    ) {
        self.title = Text(title)
        self.status = status
        self.color = color
        self.action = action
    }

    /// A button whose label is a string you already have, shown as is.
    @_disfavoredOverload
    public init<S: StringProtocol>(
        _ title: S,
        status: Status,
        color: Color = .accentColor,
        action: @escaping () -> Void
    ) {
        self.title = Text(title)
        self.status = status
        self.color = color
        self.action = action
    }

    @available(*, deprecated, renamed: "init(_:status:color:action:)")
    public init(_ title: LocalizedStringKey, status: Status, tint: Color, action: @escaping () -> Void) {
        self.init(title, status: status, color: tint, action: action)
    }

    public var body: some View {
        Button(action: action) {
            ZStack {
                title
                    .font(.headline)
                    .lineLimit(1)
                    .fixedSize()
                    .padding(.horizontal, 28)
                    .opacity(status == .idle ? 1 : 0)
                    .scaleEffect(status == .idle ? 1 : 0.6)

                if status == .loading {
                    Circle()
                        .trim(from: 0, to: 0.7)
                        .stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round))
                        .frame(width: 22, height: 22)
                        .rotationEffect(.degrees(spin ? 360 : 0))
                        .onAppear {
                            spin = false
                            withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) {
                                spin = true
                            }
                        }
                        .transition(.scale(scale: 0.4).combined(with: .opacity))
                }

                CheckmarkShape()
                    .trim(from: 0, to: status == .success ? 1 : 0)
                    .stroke(.white, style: StrokeStyle(lineWidth: 3.5, lineCap: .round, lineJoin: .round))
                    .frame(width: 20, height: 16)
                    .animation(status == .success ? .easeOut(duration: 0.35).delay(0.15) : nil, value: status)

                Image(systemName: "xmark")
                    .font(.system(size: 18, weight: .bold))
                    .scaleEffect(status == .failure ? 1 : 0.3)
                    .opacity(status == .failure ? 1 : 0)
            }
            .foregroundStyle(.white)
            .frame(width: status == .idle ? nil : height, height: height)
            .background(Capsule().fill(background))
            .contentShape(Capsule())
        }
        .buttonStyle(StatusPressStyle())
        .disabled(status != .idle)
        .modifier(StatusShake(shakes: CGFloat(shakes)))
        .animation(.spring(response: 0.45, dampingFraction: 0.75), value: status)
        .onChange(of: status) { _, newValue in
            if newValue == .failure, !reduceMotion {
                withAnimation(.linear(duration: 0.4)) { shakes += 1 }
            }
        }
        .haptic(trigger: status) { _, newValue in
            switch newValue {
            case .success: .success
            case .failure: .error
            default: nil
            }
        }
        .accessibilityLabel(title)
        .accessibilityValue(accessibilityStatus)
    }

    private var background: Color {
        switch status {
        case .idle, .loading: color
        case .success: .green
        case .failure: .red
        }
    }

    private var accessibilityStatus: Text {
        switch status {
        case .idle: Text("")
        case .loading: Text("Loading")
        case .success: Text("Done")
        case .failure: Text("Failed")
        }
    }
}

private struct StatusPressStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .scaleEffect(configuration.isPressed ? 0.95 : 1)
            .animation(.spring(response: 0.25, dampingFraction: 0.6), value: configuration.isPressed)
    }
}

/// The old name of `StatusButton.Status`.
@available(*, deprecated, renamed: "StatusButton.Status")
public typealias MotionButtonStatus = StatusButton.Status

/// Horizontal shake, one full shake per unit of `shakes`.
private struct StatusShake: GeometryEffect {
    var shakes: CGFloat

    nonisolated var animatableData: CGFloat {
        get { shakes }
        set { shakes = newValue }
    }

    func effectValue(size: CGSize) -> ProjectionTransform {
        ProjectionTransform(CGAffineTransform(translationX: 8 * sin(shakes * .pi * 6), y: 0))
    }
}

#Preview {
    struct Demo: View {
        @State private var status = StatusButton.Status.idle

        var body: some View {
            StatusButton("Pay now", status: status) {
                status = .loading
                DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { status = .success }
                DispatchQueue.main.asyncAfter(deadline: .now() + 2.8) { status = .idle }
            }
        }
    }
    return Demo()
}

// MARK: - CheckmarkShape.swift
// Needed by this effect: save it as its own file next to the one above.

// Shared by StatusButton and AnimatedCheckmark.

import SwiftUI

/// A checkmark drawn left to right, sized to its frame.
struct CheckmarkShape: Shape {
    nonisolated func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: rect.minX, y: rect.midY))
        path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.38, y: rect.maxY))
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
        return path
    }
}

// MARK: - Haptics.swift
// Needed by this effect: save it as its own file next to the one above.

import SwiftUI

/// The haptics the package plays. `SensoryFeedback` and `sensoryFeedback`
/// need visionOS 26, but the package supports visionOS 1, so call sites use
/// this enum and `.haptic(…)`, which does nothing on visionOS (it has no
/// haptics anyway).
enum Haptic: Equatable {
    case success
    case error
    case lightImpact

    #if !os(visionOS)
    var feedback: SensoryFeedback {
        switch self {
        case .success: .success
        case .error: .error
        case .lightImpact: .impact(weight: .light)
        }
    }
    #endif
}

extension View {
    /// Plays `haptic` every time `trigger` changes.
    func haptic<T: Equatable>(_ haptic: Haptic, trigger: T) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(haptic.feedback, trigger: trigger)
        #endif
    }

    /// Plays `haptic` when `trigger` changes and `condition` returns true.
    func haptic<T: Equatable>(_ haptic: Haptic, trigger: T, condition: @escaping (T, T) -> Bool) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(haptic.feedback, trigger: trigger, condition: condition)
        #endif
    }

    /// Plays the haptic returned by `haptic` (if any) when `trigger` changes.
    func haptic<T: Equatable>(trigger: T, _ haptic: @escaping (T, T) -> Haptic?) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(trigger: trigger) { old, new in haptic(old, new)?.feedback }
        #endif
    }
}
```

---

## Glow Border (`glow-border`)

A rainbow border runs around the view with a soft glow behind it.

- API: `.glowBorder()`
- Component: Button
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/GlowBorder.swift
- Files: Sources/MotionLibrary/Button/GlowBorder.swift

### Usage

```swift
Text("Upgrade to Pro")
    .padding()
    .background(.black, in: RoundedRectangle(cornerRadius: 14))
    .glowBorder(cornerRadius: 14)

CardView()
    .glowBorder(colors: [.orange, .yellow], cornerRadius: 24, lineWidth: 3)
```

### How it works

Create a SwiftUI .glowBorder(colors:cornerRadius:lineWidth:glowRadius:duration:) view modifier (iOS 17+). In a non-interactive overlay, use TimelineView(.animation) to compute a rotation angle of (time / duration) turns (3 s per turn). Build an AngularGradient from the palette plus its first colour again (pink, purple, blue, cyan, mint by default) starting at that angle. Stroke a continuous RoundedRectangle with it twice: once at 2× line width, blurred by glowRadius (8) at 80% opacity for the glow, and once crisp at lineWidth (2). With Reduce Motion on, pause the rotation.

### Source

```swift
import SwiftUI

/// A gradient border that keeps rotating around the view, with a soft glow
/// behind it. Draws attention to a primary action or a highlighted card.
struct GlowBorderModifier: ViewModifier {
    var colors: [Color]
    var cornerRadius: CGFloat
    var lineWidth: CGFloat
    var glowRadius: CGFloat
    var duration: Double

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        content
            .overlay {
                TimelineView(.animation(paused: reduceMotion)) { timeline in
                    let turns = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate / duration
                    let gradient = AngularGradient(
                        colors: colors + [colors.first ?? .clear],
                        center: .center,
                        angle: .degrees(turns.truncatingRemainder(dividingBy: 1) * 360)
                    )
                    let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
                    ZStack {
                        shape.stroke(gradient, lineWidth: lineWidth * 2)
                            .blur(radius: glowRadius)
                            .opacity(0.8)
                        shape.stroke(gradient, lineWidth: lineWidth)
                    }
                }
                .allowsHitTesting(false)
            }
    }
}

public extension View {
    /// Adds a rotating gradient border with a glow.
    ///
    /// ```swift
    /// Text("Upgrade to Pro")
    ///     .padding()
    ///     .background(.black, in: RoundedRectangle(cornerRadius: 14))
    ///     .glowBorder(cornerRadius: 14)
    /// ```
    func glowBorder(
        colors: [Color] = [.pink, .purple, .blue, .cyan, .mint],
        cornerRadius: CGFloat = 14,
        lineWidth: CGFloat = 2,
        glowRadius: CGFloat = 8,
        duration: Double = 3
    ) -> some View {
        modifier(GlowBorderModifier(
            colors: colors,
            cornerRadius: cornerRadius,
            lineWidth: lineWidth,
            glowRadius: glowRadius,
            duration: duration
        ))
    }
}

#Preview {
    Text("Upgrade to Pro")
        .font(.headline)
        .foregroundStyle(.white)
        .padding(.horizontal, 28)
        .padding(.vertical, 16)
        .background(.black, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
        .glowBorder()
        .padding(40)
}
```

---

## Shine (`shine`)

A slanted glint of light sweeps across the button, on cue or on a beat.

- API: `.shine(every:) / .shine(trigger:)`
- Component: Button
- Trigger: Value change / Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/Shine.swift
- Files: Sources/MotionLibrary/Button/Shine.swift

### Usage

```swift
PremiumBadge()
    .shine(every: 3)

Button("Upgrade") { purchases += 1 }
    .buttonStyle(.borderedProminent)
    .shine(trigger: purchases)
```

### How it works

Create two SwiftUI modifiers (iOS 17+): .shine(trigger:color:duration:) and .shine(every:color:duration:). Both overlay a "band": a horizontal LinearGradient clear → white 70 % → clear, 30 % of the view's width (at least 24 pt), twice the view's height, rotated 20°, whose x offset goes from −1.5 × band (fully off the left) at progress 0 to width + 1.5 × band (off the right) at progress 1. Mask the band with the content so only the view's own pixels shine, and disable hit testing. The trigger version runs progress 0 → 1 with keyframeAnimator(initialValue: 0, trigger:) and one CubicKeyframe over duration (0.75 s), so the band is hidden at rest. The looping version uses TimelineView(.animation) with progress = (time mod interval) / duration, clamped to 1. With Reduce Motion on, skip the band.

### Source

```swift
import SwiftUI

/// A slanted glint of light that sweeps across the view once, either every
/// time `trigger` changes or on a fixed interval.
struct ShineModifier<Trigger: Equatable>: ViewModifier {
    var trigger: Trigger
    var color: Color
    var duration: Double

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        // Reduce Motion: no sweep.
        let enabled = !reduceMotion
        let color = color
        return content
            .keyframeAnimator(initialValue: 0.0, trigger: trigger) { view, progress in
                view.overlay {
                    if enabled {
                        ShineBand(progress: progress, color: color).mask { view }
                    }
                }
            } keyframes: { _ in
                // 0 and 1 are both off the view, so the band is hidden at rest.
                CubicKeyframe(1, duration: duration)
            }
    }
}

/// Repeats the glint every `interval` seconds.
struct LoopingShineModifier: ViewModifier {
    var interval: Double
    var color: Color
    var duration: Double

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        content.overlay {
            if !reduceMotion {
                TimelineView(.animation) { timeline in
                    let time = timeline.date.timeIntervalSinceReferenceDate
                    let phase = time.truncatingRemainder(dividingBy: max(interval, duration))
                    ShineBand(progress: min(1, phase / duration), color: color)
                }
                .mask { content }
            }
        }
    }
}

/// The band itself: a skewed white gradient that travels left to right as
/// `progress` goes from 0 to 1.
private struct ShineBand: View {
    var progress: Double
    var color: Color

    var body: some View {
        GeometryReader { geometry in
            let width = geometry.size.width
            let band = max(24, width * 0.3)
            LinearGradient(colors: [.clear, color, .clear], startPoint: .leading, endPoint: .trailing)
                .frame(width: band, height: geometry.size.height * 2)
                .rotationEffect(.degrees(20))
                .offset(x: -band * 1.5 + (width + band * 3) * progress, y: -geometry.size.height / 2)
        }
        .allowsHitTesting(false)
        .accessibilityHidden(true)
    }
}

public extension View {
    /// Sweeps a glint of light across the view every time `trigger` changes.
    ///
    /// ```swift
    /// Button("Upgrade") { purchases += 1 }
    ///     .buttonStyle(.borderedProminent)
    ///     .shine(trigger: purchases)
    /// ```
    func shine<Trigger: Equatable>(
        trigger: Trigger,
        color: Color = .white.opacity(0.7),
        duration: Double = 0.75
    ) -> some View {
        modifier(ShineModifier(trigger: trigger, color: color, duration: duration))
    }

    /// Sweeps a glint of light across the view every `interval` seconds.
    ///
    /// ```swift
    /// PremiumBadge()
    ///     .shine(every: 3)
    /// ```
    func shine(
        every interval: Double,
        color: Color = .white.opacity(0.7),
        duration: Double = 0.75
    ) -> some View {
        modifier(LoopingShineModifier(interval: interval, color: color, duration: duration))
    }
}

#Preview {
    Text("Go Premium")
        .font(.headline)
        .foregroundStyle(.white)
        .padding(.horizontal, 24)
        .padding(.vertical, 12)
        .background(Capsule().fill(Color.orange.gradient))
        .shine(every: 2)
        .padding()
}
```

---

## Jelly (`jelly`)

A rubbery squash-and-stretch wobble on every tap.

- API: `.jelly(trigger:)`
- Component: Button
- Trigger: Tap / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/Jelly.swift
- Files: Sources/MotionLibrary/Button/Jelly.swift

### Usage

```swift
Button("Boing") { taps += 1 }
    .buttonStyle(.borderedProminent)
    .jelly(trigger: taps)
```

### How it works

Create a SwiftUI .jelly(trigger:amount:) view modifier (iOS 17+) using keyframeAnimator(initialValue:trigger:) on a struct with x and y scale (both 1), applied with scaleEffect(x:y:). With a = amount (0.25): the x track goes 1 + a (0.1 s), 1 − 0.8a (0.12 s), 1 + 0.5a (0.12 s) with cubic keyframes, then a SpringKeyframe(.bouncy) back to 1 over 0.4 s; the y track does the opposite (1 − a, 1 + 0.8a, 1 − 0.5a, spring to 1), so the view squashes wide and flat, then stretches tall and thin, then settles. With Reduce Motion on, use a = 0.

### Source

```swift
import SwiftUI

private struct JellyScale {
    var x: CGFloat = 1
    var y: CGFloat = 1
}

/// A rubbery squash-and-stretch wobble that plays every time `trigger`
/// changes: wide and flat, then tall and thin, settling back with a spring.
struct JellyModifier<Trigger: Equatable>: ViewModifier {
    var trigger: Trigger
    var amount: CGFloat

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        // Reduce Motion: stay still.
        let a = reduceMotion ? 0 : amount
        return content.keyframeAnimator(initialValue: JellyScale(), trigger: trigger) { view, scale in
            view.scaleEffect(x: scale.x, y: scale.y)
        } keyframes: { _ in
            KeyframeTrack(\.x) {
                CubicKeyframe(1 + a, duration: 0.1)
                CubicKeyframe(1 - a * 0.8, duration: 0.12)
                CubicKeyframe(1 + a * 0.5, duration: 0.12)
                SpringKeyframe(1, duration: 0.4, spring: .bouncy)
            }
            KeyframeTrack(\.y) {
                CubicKeyframe(1 - a, duration: 0.1)
                CubicKeyframe(1 + a * 0.8, duration: 0.12)
                CubicKeyframe(1 - a * 0.5, duration: 0.12)
                SpringKeyframe(1, duration: 0.4, spring: .bouncy)
            }
        }
    }
}

public extension View {
    /// Wobbles the view like jelly every time `trigger` changes.
    ///
    /// ```swift
    /// Button("Boing") { taps += 1 }
    ///     .buttonStyle(.borderedProminent)
    ///     .jelly(trigger: taps)
    /// ```
    ///
    /// - Parameter amount: How far it squashes (0.25 = 25 %).
    func jelly<Trigger: Equatable>(trigger: Trigger, amount: CGFloat = 0.25) -> some View {
        modifier(JellyModifier(trigger: trigger, amount: amount))
    }
}

#Preview {
    struct Demo: View {
        @State private var taps = 0
        var body: some View {
            Button("Boing") { taps += 1 }
                .buttonStyle(.borderedProminent)
                .controlSize(.large)
                .jelly(trigger: taps)
        }
    }
    return Demo().padding(40)
}
```

---

## Click Spark (`click-spark`)

Short spark lines shoot out from the exact point you tapped.

- API: `.clickSpark()`
- Component: Button
- Trigger: Tap
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/ClickSpark.swift
- Files: Sources/MotionLibrary/Button/ClickSpark.swift

### Usage

```swift
Button("Launch") { }
    .buttonStyle(.borderedProminent)
    .clickSpark(color: .orange)
```

### How it works

Create a SwiftUI .clickSpark(color:count:radius:) view modifier (iOS 17+). Detect taps with .simultaneousGesture(SpatialTapGesture()) and append a spark (UUID + location) to a @State array, removing it 0.6 s later. In a hit-testing-disabled overlay, place each spark at its location: count (8) capsules 2.5 pt wide and 0.45 × radius tall, each rotated by index / count × 360°. On appear, animate with easeOut(0.45 s) from offset −0.3 × radius to −radius (outward), scaleEffect(y:) from 1 to 0.1 anchored at the top (so each line shrinks toward its outer tip) and opacity from 1 to 0. With Reduce Motion on, don't add sparks.

### Source

```swift
import SwiftUI

/// Short spark lines shoot out from the exact point the user tapped.
struct ClickSparkModifier: ViewModifier {
    var color: Color
    var count: Int
    var radius: CGFloat

    @State private var sparks: [Spark] = []
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    private struct Spark: Identifiable {
        let id = UUID()
        let location: CGPoint
    }

    func body(content: Content) -> some View {
        content
            .overlay {
                ZStack {
                    ForEach(sparks) { spark in
                        SparkLines(color: color, count: count, radius: radius)
                            .position(spark.location)
                    }
                }
                .allowsHitTesting(false)
                .accessibilityHidden(true)
            }
            .simultaneousGesture(
                SpatialTapGesture().onEnded { value in
                    // Reduce Motion: no sparks.
                    guard !reduceMotion else { return }
                    let spark = Spark(location: value.location)
                    sparks.append(spark)
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
                        sparks.removeAll { $0.id == spark.id }
                    }
                }
            )
    }
}

private struct SparkLines: View {
    let color: Color
    let count: Int
    let radius: CGFloat

    @State private var fired = false

    var body: some View {
        ZStack {
            ForEach(0..<max(1, count), id: \.self) { index in
                Capsule()
                    .fill(color)
                    .frame(width: 2.5, height: radius * 0.45)
                    // Shrinks toward its outer end as it flies.
                    .scaleEffect(y: fired ? 0.1 : 1, anchor: .top)
                    .offset(y: -(fired ? radius : radius * 0.3))
                    .rotationEffect(.degrees(Double(index) / Double(max(1, count)) * 360))
            }
        }
        .opacity(fired ? 0 : 1)
        .onAppear {
            withAnimation(.easeOut(duration: 0.45)) { fired = true }
        }
    }
}

public extension View {
    /// Shoots spark lines out of every tap point.
    ///
    /// ```swift
    /// Button("Launch") { }
    ///     .buttonStyle(.borderedProminent)
    ///     .clickSpark(color: .orange)
    /// ```
    func clickSpark(color: Color = .orange, count: Int = 8, radius: CGFloat = 28) -> some View {
        modifier(ClickSparkModifier(color: color, count: count, radius: radius))
    }
}

#Preview {
    RoundedRectangle(cornerRadius: 16)
        .fill(.black)
        .frame(width: 260, height: 160)
        .overlay(Text("Click anywhere").foregroundStyle(.white))
        .clickSpark()
}
```

---

## Like Button (`like-button`)

The heart pops in as a ring bursts open and coloured dots fly out.

- API: `LikeButton(isLiked:)`
- Component: Button
- Trigger: Tap
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/LikeButton.swift
- Files: Sources/MotionLibrary/Button/LikeButton.swift, Sources/MotionLibrary/Support/Haptics.swift

### Usage

```swift
LikeButton(isLiked: $post.isLiked)

LikeButton(isLiked: $liked, size: 40, color: .red)
```

### How it works

Create a SwiftUI LikeButton (iOS 17+) bound to isLiked, with size (28 pt), color (pink) and dotColors. It's a plain Button showing Image(systemName: isLiked ? "heart.fill" : "heart"); tapping toggles and, on like, increments a counter used as a keyframeAnimator trigger over a struct {heart, ring, dots}. Tracks: heart drops to 0.2 instantly, holds 0.15 s, then springs to 1 with a bouncy overshoot; ring goes 0 → 1 over 0.3 s then back to 0; dots wait 0.15 s then go 0 → 1 over 0.55 s. Behind the heart draw (a) a circle strokeBorder in color whose diameter is 1.5 × size × ring and whose line width is 0.5 × size × (1 − ring), so it bursts open and thins out; (b) 7 pairs of small dots (0.16 × size) at angles i/7 × 360° ± 10°, at distance size × (0.6 + 0.55 × dots), scaling down to 10 % as they fly. Unliking only springs the heart back. Add a light haptic on like, and hide the burst for Reduce Motion.

### Source

```swift
import SwiftUI

private struct LikePop {
    var heart: CGFloat = 1
    var ring: CGFloat = 0
    var dots: CGFloat = 0
}

/// A Twitter-style like button: on like, a ring bursts open, the heart pops
/// in with an overshoot and a circle of coloured dots flies out. Unliking
/// just shrinks the heart back.
public struct LikeButton: View {
    @Binding private var isLiked: Bool
    private let size: CGFloat
    private let color: Color
    private let dotColors: [Color]

    @State private var likes = 0
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - isLiked: Whether the item is liked; tapping toggles it.
    ///   - size: Point size of the heart.
    ///   - color: Colour of the filled heart and the ring.
    ///   - dotColors: Colours cycled through by the burst dots.
    public init(
        isLiked: Binding<Bool>,
        size: CGFloat = 28,
        color: Color = .pink,
        dotColors: [Color] = [.pink, .orange, .yellow, .mint, .cyan, .purple]
    ) {
        _isLiked = isLiked
        self.size = size
        self.color = color
        self.dotColors = dotColors
    }

    public var body: some View {
        let showBurst = !reduceMotion
        let size = size, color = color, dotColors = dotColors
        return Button {
            isLiked.toggle()
            if isLiked { likes += 1 }
        } label: {
            Image(systemName: isLiked ? "heart.fill" : "heart")
                .font(.system(size: size))
                .foregroundStyle(isLiked ? color : Color.secondary)
                .frame(width: size * 1.6, height: size * 1.6)
                .contentShape(Rectangle())
                .keyframeAnimator(initialValue: LikePop(), trigger: likes) { heart, pop in
                    heart
                        .scaleEffect(pop.heart)
                        .background {
                            if showBurst { LikeBurst(pop: pop, size: size, color: color, dotColors: dotColors) }
                        }
                } keyframes: { _ in
                    KeyframeTrack(\.heart) {
                        CubicKeyframe(0.2, duration: 0.05)
                        CubicKeyframe(0.2, duration: 0.15)
                        SpringKeyframe(1, duration: 0.5, spring: .bouncy(duration: 0.4, extraBounce: 0.25))
                    }
                    KeyframeTrack(\.ring) {
                        LinearKeyframe(0.01, duration: 0.01)
                        CubicKeyframe(1, duration: 0.3)
                        LinearKeyframe(0, duration: 0.01)
                    }
                    KeyframeTrack(\.dots) {
                        LinearKeyframe(0, duration: 0.15)
                        CubicKeyframe(1, duration: 0.55)
                        LinearKeyframe(0, duration: 0.01)
                    }
                }
        }
        .buttonStyle(.plain)
        .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isLiked)
        .haptic(.lightImpact, trigger: likes)
        .accessibilityLabel(isLiked ? "Unlike" : "Like")
    }
}

/// The ring and the dots behind the heart.
private struct LikeBurst: View {
    let pop: LikePop
    let size: CGFloat
    let color: Color
    let dotColors: [Color]

    var body: some View {
        ZStack {
            // The ring grows and its stroke thins out to nothing.
            if pop.ring > 0 {
                Circle()
                    .strokeBorder(color, lineWidth: max(0.1, size * 0.5 * (1 - pop.ring)))
                    .frame(width: size * 1.5 * pop.ring, height: size * 1.5 * pop.ring)
            }
            // Seven pairs of dots fly outward and shrink.
            if pop.dots > 0 {
                ForEach(0..<14, id: \.self) { index in
                    dot(index)
                }
            }
        }
        .allowsHitTesting(false)
        .accessibilityHidden(true)
    }

    private func dot(_ index: Int) -> some View {
        let pair = index % 2
        let base = Double(index / 2) / 7 * 2 * .pi - .pi / 2
        let angle = base + (pair == 0 ? -0.18 : 0.18)
        let reach: CGFloat = 0.6 + 0.55 * pop.dots
        let distance: CGFloat = size * reach * (pair == 0 ? 1 : 0.85)
        let fill: Color = dotColors.isEmpty ? color : dotColors[index % dotColors.count]
        return Circle()
            .fill(fill)
            .frame(width: size * 0.16, height: size * 0.16)
            .scaleEffect(1 - pop.dots * 0.9)
            .offset(x: CGFloat(cos(angle)) * distance, y: CGFloat(sin(angle)) * distance)
    }
}

#Preview {
    struct Demo: View {
        @State private var liked = false
        var body: some View {
            LikeButton(isLiked: $liked, size: 40)
        }
    }
    return Demo().padding(60)
}

// MARK: - Haptics.swift
// Needed by this effect: save it as its own file next to the one above.

import SwiftUI

/// The haptics the package plays. `SensoryFeedback` and `sensoryFeedback`
/// need visionOS 26, but the package supports visionOS 1, so call sites use
/// this enum and `.haptic(…)`, which does nothing on visionOS (it has no
/// haptics anyway).
enum Haptic: Equatable {
    case success
    case error
    case lightImpact

    #if !os(visionOS)
    var feedback: SensoryFeedback {
        switch self {
        case .success: .success
        case .error: .error
        case .lightImpact: .impact(weight: .light)
        }
    }
    #endif
}

extension View {
    /// Plays `haptic` every time `trigger` changes.
    func haptic<T: Equatable>(_ haptic: Haptic, trigger: T) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(haptic.feedback, trigger: trigger)
        #endif
    }

    /// Plays `haptic` when `trigger` changes and `condition` returns true.
    func haptic<T: Equatable>(_ haptic: Haptic, trigger: T, condition: @escaping (T, T) -> Bool) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(haptic.feedback, trigger: trigger, condition: condition)
        #endif
    }

    /// Plays the haptic returned by `haptic` (if any) when `trigger` changes.
    func haptic<T: Equatable>(trigger: T, _ haptic: @escaping (T, T) -> Haptic?) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(trigger: trigger) { old, new in haptic(old, new)?.feedback }
        #endif
    }
}
```

---

## Animated Symbol (`animated-symbol`)

SF Symbols that keep moving: bounce on a beat, pulse, variable colour.

- API: `AnimatedSymbol("…", style:)`
- Component: Button
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/AnimatedSymbol.swift
- Files: Sources/MotionLibrary/Button/AnimatedSymbol.swift

### Usage

```swift
AnimatedSymbol("bell.fill")                      // bounces every 1.5 s
AnimatedSymbol("heart.fill", style: .pulse)
AnimatedSymbol("wifi", style: .variableColor)
AnimatedSymbol("mic.fill", style: .scaleUp, isActive: isRecording)
```

### How it works

Create a SwiftUI AnimatedSymbol view (iOS 17+) wrapping Image(systemName:) with a style enum: .bounce, .pulse, .variableColor and .scaleUp, plus isActive and interval (1.5 s). For .bounce, apply .symbolEffect(.bounce, value: beats) and increment beats in a .task(id: isActive) loop every interval. For the others, use the indefinite effects .symbolEffect(.pulse, isActive:), .symbolEffect(.variableColor.iterative.reversing, isActive:) and .symbolEffect(.scale.up, isActive:). The view inherits font and foregroundStyle like any Image. With Reduce Motion on, treat it as inactive.

### Source

```swift
import SwiftUI

/// An SF Symbol that keeps playing a symbol effect: a bounce on a beat, or a
/// continuous pulse, variable-colour cycle or scale-up.
public struct AnimatedSymbol: View {
    /// Which symbol effect to play.
    public enum Style: CaseIterable, Sendable {
        /// Bounces once every `interval` seconds.
        case bounce
        /// Fades the symbol's layers in and out.
        case pulse
        /// Lights up the symbol's layers one after another (wifi, speaker…).
        case variableColor
        /// Grows the symbol slightly and holds it.
        case scaleUp
    }

    private let systemName: String
    private let style: Style
    private let isActive: Bool
    private let interval: Double

    @State private var beats = 0
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - systemName: The SF Symbol name.
    ///   - style: The effect to play.
    ///   - isActive: Pauses the effect when false.
    ///   - interval: Seconds between bounces (`.bounce` only).
    public init(_ systemName: String, style: Style = .bounce, isActive: Bool = true, interval: Double = 1.5) {
        self.systemName = systemName
        self.style = style
        self.isActive = isActive
        self.interval = interval
    }

    public var body: some View {
        // Reduce Motion: the symbol stays still.
        let active = isActive && !reduceMotion
        let image = Image(systemName: systemName)
        Group {
            switch style {
            case .bounce:
                image.symbolEffect(.bounce, value: beats)
            case .pulse:
                image.symbolEffect(.pulse, isActive: active)
            case .variableColor:
                image.symbolEffect(.variableColor.iterative.reversing, isActive: active)
            case .scaleUp:
                image.symbolEffect(.scale.up, isActive: active)
            }
        }
        .task(id: active) {
            guard style == .bounce, active else { return }
            while !Task.isCancelled {
                beats += 1
                try? await Task.sleep(for: .seconds(max(0.3, interval)))
            }
        }
    }
}

#Preview {
    HStack(spacing: 24) {
        AnimatedSymbol("bell.fill")
        AnimatedSymbol("heart.fill", style: .pulse)
        AnimatedSymbol("wifi", style: .variableColor)
        AnimatedSymbol("mic.fill", style: .scaleUp)
    }
    .font(.largeTitle)
    .padding()
}
```

---

## Liquid Toggle (`liquid-toggle`)

A switch whose knob stretches like a drop of liquid as it slides.

- API: `.toggleStyle(.liquid)`
- Component: Button
- Trigger: Tap
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/LiquidToggleStyle.swift
- Files: Sources/MotionLibrary/Button/LiquidToggleStyle.swift

### Usage

```swift
Toggle("Wi-Fi", isOn: $wifi)
    .toggleStyle(.liquid)

Toggle("Dark mode", isOn: $dark)
    .toggleStyle(.liquid(color: .purple))
```

### How it works

Create a SwiftUI LiquidToggleStyle (iOS 17+) with a color (green) and static accessors .liquid and .liquid(color:). makeBody puts the label, a Spacer and a 52 × 32 pt switch in an HStack. The switch is a plain Button that toggles configuration.isOn inside withAnimation(.spring(response: 0.35, dampingFraction: 0.7)); it shows a ZStack aligned .trailing when on and .leading when off, with a capsule track (tint when on, gray 35 % when off) and a white capsule knob (26 pt tall, 3 pt padding, small shadow). The knob's width runs a keyframeAnimator triggered by isOn: CubicKeyframe to 1.5 × 26 pt over 0.12 s, then SpringKeyframe(.bouncy) back to 26 over 0.35 s, so it stretches as it slides and snaps back round. Give it an "On"/"Off" accessibility value. With Reduce Motion on, don't stretch and use a short ease.

### Source

```swift
import SwiftUI

/// A switch whose knob stretches like a drop of liquid as it slides across,
/// then springs back to a circle.
///
/// ```swift
/// Toggle("Wi-Fi", isOn: $isOn)
///     .toggleStyle(.liquid)
///
/// Toggle("Dark mode", isOn: $dark)
///     .toggleStyle(.liquid(color: .purple))
/// ```
public struct LiquidToggleStyle: ToggleStyle {
    /// Colour of the track while on.
    public var color: Color

    public init(color: Color = .green) {
        self.color = color
    }

    @available(*, deprecated, renamed: "init(color:)")
    public init(tint: Color) {
        self.color = tint
    }

    @available(*, deprecated, renamed: "color")
    public var tint: Color {
        get { color }
        set { color = newValue }
    }

    public func makeBody(configuration: Configuration) -> some View {
        HStack {
            configuration.label
            Spacer(minLength: 12)
            LiquidSwitch(isOn: configuration.$isOn, tint: color)
        }
    }
}

private struct LiquidSwitch: View {
    @Binding var isOn: Bool
    let tint: Color

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    private let width: CGFloat = 52
    private let height: CGFloat = 32
    private let knob: CGFloat = 26

    var body: some View {
        let knob = knob
        // Reduce Motion: no stretch.
        let stretch = reduceMotion ? knob : knob * 1.5
        return Button {
            withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(response: 0.35, dampingFraction: 0.7)) {
                isOn.toggle()
            }
        } label: {
            ZStack(alignment: isOn ? .trailing : .leading) {
                Capsule()
                    .fill(isOn ? tint : Color.gray.opacity(0.35))
                Capsule()
                    .fill(.white)
                    .shadow(color: .black.opacity(0.2), radius: 2, y: 1)
                    .keyframeAnimator(initialValue: knob, trigger: isOn) { view, knobWidth in
                        view.frame(width: knobWidth, height: knob)
                    } keyframes: { _ in
                        CubicKeyframe(stretch, duration: 0.12)
                        SpringKeyframe(knob, duration: 0.35, spring: .bouncy)
                    }
                    .padding(3)
            }
            .frame(width: width, height: height)
            .contentShape(Capsule())
        }
        .buttonStyle(.plain)
        .accessibilityValue(isOn ? "On" : "Off")
    }
}

public extension ToggleStyle where Self == LiquidToggleStyle {
    /// A switch with a stretchy, liquid knob.
    static var liquid: LiquidToggleStyle { LiquidToggleStyle() }

    /// A switch with a stretchy, liquid knob in the given colour.
    static func liquid(color: Color) -> LiquidToggleStyle { LiquidToggleStyle(color: color) }

    @available(*, deprecated, renamed: "liquid(color:)")
    static func liquid(tint: Color) -> LiquidToggleStyle { LiquidToggleStyle(color: tint) }
}

#Preview {
    struct Demo: View {
        @State private var on = true
        var body: some View {
            Toggle("Notifications", isOn: $on)
                .toggleStyle(.liquid)
                .frame(width: 240)
        }
    }
    return Demo().padding()
}
```

---

## Gooey Menu (`gooey-menu`)

A round button splits into a fan of buttons like a drop of liquid.

- API: `GooeyMenu(symbols:action:)`
- Component: Button
- Trigger: Tap
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/GooeyMenu.swift
- Files: Sources/MotionLibrary/Button/GooeyMenu.swift

### Usage

```swift
GooeyMenu(symbols: ["camera.fill", "photo.fill", "mic.fill"], color: .indigo) { index in
    attach(index)
}
```

### How it works

Create a SwiftUI GooeyMenu(symbols:color:distance:action:) view (iOS 17+). Frame: width 2 × distance + 60, height distance + 66 (distance 76). Behind the buttons, a Canvas adds .alphaThreshold(min: 0.5, color: color) and .blur(radius: 7) and, in drawLayer, draws resolved symbols at the main button's centre (bottom centre, 8 pt up): a 56 pt Circle (tag −1) and one 44 pt Circle per item (tags 0…n−1) offset to its fan position, so the circles fuse like liquid while they separate. Item i of n sits at angle π + π × (i + 1) / (n + 1) on a half-circle of radius distance above the button, or at the centre when closed. Each item animates with .spring(duration: 0.55, bounce: 0.35) delayed 0.04 s × its order (reversed when closing). On top of the canvas, place real Buttons with white SF Symbols at the same offsets (opacity 0 and no hit testing when closed) and the main button with a "plus" that rotates 45° when open. Tapping an item calls action(index) and closes the menu. With Reduce Motion on, hide the goo, draw plain circles and fade items in place.

### Source

```swift
import SwiftUI

/// A round button that splits into a fan of smaller buttons like a drop of
/// liquid, and swallows them back up. The blobs are blurred inside a `Canvas`
/// and cut back with an alpha threshold, so they stretch and merge as they
/// separate (metaballs).
public struct GooeyMenu: View {
    private let symbols: [String]
    private let color: Color
    private let distance: CGFloat
    private let action: (Int) -> Void

    @State private var isOpen = false
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    private let mainSize: CGFloat = 56
    private let itemSize: CGFloat = 44

    /// - Parameters:
    ///   - symbols: SF Symbol names, one per menu item.
    ///   - color: Colour of the button and the goo.
    ///   - distance: How far items travel from the main button, in points.
    ///   - action: Called with the index of the tapped item. The menu closes.
    public init(symbols: [String], color: Color = .accentColor, distance: CGFloat = 76, action: @escaping (Int) -> Void) {
        self.symbols = symbols
        self.color = color
        self.distance = distance
        self.action = action
    }

    public var body: some View {
        let width = distance * 2 + itemSize + 16
        let height = distance + itemSize / 2 + mainSize / 2 + 16
        ZStack(alignment: .bottom) {
            Canvas { context, _ in
                context.addFilter(.alphaThreshold(min: 0.5, color: color))
                context.addFilter(.blur(radius: 7))
                context.drawLayer { layer in
                    for id in [-1] + Array(symbols.indices) {
                        if let blob = layer.resolveSymbol(id: id) {
                            layer.draw(blob, at: CGPoint(x: width / 2, y: height - 8 - mainSize / 2))
                        }
                    }
                }
            } symbols: {
                Circle().frame(width: mainSize, height: mainSize).tag(-1)
                ForEach(symbols.indices, id: \.self) { index in
                    Circle()
                        .frame(width: itemSize, height: itemSize)
                        .offset(offset(index))
                        .animation(itemAnimation(index), value: isOpen)
                        .tag(index)
                }
            }
            .frame(width: width, height: height)
            .opacity(reduceMotion ? 0 : 1)
            .accessibilityHidden(true)

            // The real, crisp buttons sit on top of the goo.
            ForEach(symbols.indices, id: \.self) { index in
                Button {
                    action(index)
                    isOpen = false
                } label: {
                    Image(systemName: symbols[index])
                        .font(.system(size: 17, weight: .semibold))
                        .foregroundStyle(.white)
                        .frame(width: itemSize, height: itemSize)
                        .background {
                            // Reduce Motion: plain circles that fade in place.
                            if reduceMotion { Circle().fill(color) }
                        }
                        .contentShape(Circle())
                }
                .buttonStyle(.plain)
                .offset(offset(index))
                .opacity(isOpen ? 1 : 0)
                .animation(itemAnimation(index), value: isOpen)
                .allowsHitTesting(isOpen)
                .padding(.bottom, 8 + (mainSize - itemSize) / 2)
            }

            Button {
                isOpen.toggle()
            } label: {
                Image(systemName: "plus")
                    .font(.system(size: 22, weight: .bold))
                    .foregroundStyle(.white)
                    .rotationEffect(.degrees(isOpen ? 45 : 0))
                    .frame(width: mainSize, height: mainSize)
                    .background {
                        if reduceMotion { Circle().fill(color) }
                    }
                    .contentShape(Circle())
            }
            .buttonStyle(.plain)
            .padding(.bottom, 8)
            .animation(.spring(duration: 0.4, bounce: 0.4), value: isOpen)
            .accessibilityLabel(isOpen ? "Close menu" : "Open menu")
        }
        .frame(width: width, height: height)
    }

    /// Items fan out along the upper half-circle.
    private func offset(_ index: Int) -> CGSize {
        guard isOpen || reduceMotion else { return .zero }
        let angle = Double.pi + Double.pi * Double(index + 1) / Double(symbols.count + 1)
        return CGSize(width: cos(angle) * distance, height: sin(angle) * distance)
    }

    private func itemAnimation(_ index: Int) -> Animation {
        if reduceMotion { return .easeInOut(duration: 0.25) }
        let order = isOpen ? index : symbols.count - 1 - index
        return .spring(duration: 0.55, bounce: 0.35).delay(Double(order) * 0.04)
    }
}

#Preview {
    GooeyMenu(symbols: ["camera.fill", "photo.fill", "mic.fill", "doc.fill"], color: .indigo) { index in
        print("tapped", index)
    }
    .padding()
}
```

---

## Slide to Confirm (`slide-to-confirm`)

Drag the knob all the way across to run the action; let go early and it springs back.

- API: `SlideToConfirm(_:action:)`
- Component: Button
- Trigger: Drag
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/SlideToConfirm.swift
- Files: Sources/MotionLibrary/Button/SlideToConfirm.swift, Sources/MotionLibrary/Support/Haptics.swift

### Usage

```swift
SlideToConfirm("Slide to pay", color: .green) {
    pay()
}
.frame(width: 280)
```

### How it works

Create a SwiftUI SlideToConfirm(_ title:color:resetAfter:action:) control (iOS 17+), 56 pt tall. In a GeometryReader ZStack(alignment: .leading): a Capsule track (secondary 15 %), the title (headline, secondary) whose opacity is 1 − progress × 1.6, a Capsule fill of width offset + knob + 8 in the colour at 35 %, and a 48 pt knob Circle (inset 4 pt) with a white "chevron.right.2" (or "checkmark" once confirmed, using contentTransition(.symbolEffect(.replace))) and a coloured shadow. A DragGesture on the knob sets offset = clamp(translation.width, 0, maxOffset). On release past 85 % of maxOffset: snap to the end with a 0.3 s spring, mark confirmed, call action and play a success haptic; then after resetAfter (1.5 s, or never if nil) spring back. Otherwise spring back with .spring(duration: 0.45, bounce: 0.35). Expose it to accessibility as a button whose action confirms.

### Source

```swift
import SwiftUI

/// A "slide to confirm" control: drag the knob all the way across to run the
/// action. Let go early and it springs back. The label fades as you slide and
/// the track fills behind the knob.
public struct SlideToConfirm: View {
    private let title: Text
    private let color: Color
    private let resetAfter: Double?
    private let action: () -> Void

    @State private var offset: CGFloat = 0
    @State private var isConfirmed = false
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    private let height: CGFloat = 56
    private let inset: CGFloat = 4

    /// - Parameters:
    ///   - title: The label inside the track.
    ///   - color: Knob and fill colour.
    ///   - resetAfter: Seconds after confirming before the control resets;
    ///     `nil` keeps it confirmed.
    ///   - action: Runs once the knob reaches the end.
    public init(_ title: LocalizedStringKey, color: Color = .accentColor, resetAfter: Double? = 1.5, action: @escaping () -> Void) {
        self.title = Text(title)
        self.color = color
        self.resetAfter = resetAfter
        self.action = action
    }

    /// A slider whose label is a string you already have, shown as is.
    @_disfavoredOverload
    public init<S: StringProtocol>(_ title: S, color: Color = .accentColor, resetAfter: Double? = 1.5, action: @escaping () -> Void) {
        self.title = Text(title)
        self.color = color
        self.resetAfter = resetAfter
        self.action = action
    }

    public var body: some View {
        GeometryReader { geometry in
            let knob = height - inset * 2
            let maxOffset = max(0, geometry.size.width - knob - inset * 2)
            let progress = maxOffset > 0 ? offset / maxOffset : 0

            ZStack(alignment: .leading) {
                Capsule().fill(Color.secondary.opacity(0.15))

                title
                    .font(.headline)
                    .foregroundStyle(.secondary)
                    .frame(maxWidth: .infinity)
                    .padding(.leading, knob * 0.6)
                    .opacity(1 - progress * 1.6)

                // The fill grows with the knob.
                Capsule()
                    .fill(color.opacity(0.35))
                    .frame(width: offset + knob + inset * 2)

                Circle()
                    .fill(color)
                    .overlay {
                        Image(systemName: isConfirmed ? "checkmark" : "chevron.right.2")
                            .font(.system(size: 18, weight: .bold))
                            .foregroundStyle(.white)
                            .contentTransition(.symbolEffect(.replace))
                    }
                    .frame(width: knob, height: knob)
                    .shadow(color: color.opacity(0.4), radius: 6, y: 2)
                    .padding(inset)
                    .offset(x: offset)
                    .gesture(
                        DragGesture()
                            .onChanged { value in
                                guard !isConfirmed else { return }
                                offset = min(maxOffset, max(0, value.translation.width))
                            }
                            .onEnded { _ in
                                guard !isConfirmed else { return }
                                if offset > maxOffset * 0.85 {
                                    confirm(maxOffset)
                                } else {
                                    withAnimation(reduceMotion ? .easeOut(duration: 0.2) : .spring(duration: 0.45, bounce: 0.35)) {
                                        offset = 0
                                    }
                                }
                            }
                    )
            }
        }
        .frame(height: height)
        .haptic(.success, trigger: isConfirmed) { _, new in new }
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(title)
        .accessibilityAddTraits(.isButton)
        .accessibilityAction { confirm(nil) }
    }

    private func confirm(_ maxOffset: CGFloat?) {
        withAnimation(.spring(duration: 0.3)) {
            if let maxOffset { offset = maxOffset }
            isConfirmed = true
        }
        action()
        guard let resetAfter else { return }
        Task { @MainActor in
            try? await Task.sleep(for: .seconds(resetAfter))
            withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
                offset = 0
                isConfirmed = false
            }
        }
    }
}

#Preview {
    SlideToConfirm("Slide to pay", color: .green) {}
        .frame(width: 280)
        .padding()
}

// MARK: - Haptics.swift
// Needed by this effect: save it as its own file next to the one above.

import SwiftUI

/// The haptics the package plays. `SensoryFeedback` and `sensoryFeedback`
/// need visionOS 26, but the package supports visionOS 1, so call sites use
/// this enum and `.haptic(…)`, which does nothing on visionOS (it has no
/// haptics anyway).
enum Haptic: Equatable {
    case success
    case error
    case lightImpact

    #if !os(visionOS)
    var feedback: SensoryFeedback {
        switch self {
        case .success: .success
        case .error: .error
        case .lightImpact: .impact(weight: .light)
        }
    }
    #endif
}

extension View {
    /// Plays `haptic` every time `trigger` changes.
    func haptic<T: Equatable>(_ haptic: Haptic, trigger: T) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(haptic.feedback, trigger: trigger)
        #endif
    }

    /// Plays `haptic` when `trigger` changes and `condition` returns true.
    func haptic<T: Equatable>(_ haptic: Haptic, trigger: T, condition: @escaping (T, T) -> Bool) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(haptic.feedback, trigger: trigger, condition: condition)
        #endif
    }

    /// Plays the haptic returned by `haptic` (if any) when `trigger` changes.
    func haptic<T: Equatable>(trigger: T, _ haptic: @escaping (T, T) -> Haptic?) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(trigger: trigger) { old, new in haptic(old, new)?.feedback }
        #endif
    }
}
```

---

## Download Button (`download-button`)

A capsule shrinks into a progress ring, then pops into a checkmark.

- API: `DownloadButton(progress:) { }`
- Component: Button
- Trigger: Tap / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/DownloadButton.swift
- Files: Sources/MotionLibrary/Button/DownloadButton.swift, Sources/MotionLibrary/Support/Haptics.swift

### Usage

```swift
DownloadButton(progress: download.progress) {
    download.start()
}
```

### How it works

Create a SwiftUI DownloadButton(_ title:progress:color:action:) (iOS 17+) with three phases from progress: nil = idle, 0..<1 = loading, ≥ 1 = done. It's a ZStack whose frame is 150 × 44 when idle and 44 × 44 otherwise, over a Capsule filled with the colour (idle), secondary 12 % (loading) or green (done). Idle shows a white Label(title, systemImage: "arrow.down") with a scale 0.8 + opacity transition; loading shows a Circle trimmed to progress, stroked 4 pt with round caps, rotated −90°, padded 3 pt, and a small "stop.fill"; done shows a white "checkmark" with a scale-from-0.2 + opacity transition. Animate phase changes with .spring(duration: 0.5, bounce: 0.35) so the capsule visibly shrinks into the ring, and play a success haptic when it finishes. The action only runs from the idle phase. With Reduce Motion on, use a 0.25 s ease instead of the spring.

### Source

```swift
import SwiftUI

/// A button that morphs through a download: a labelled capsule shrinks into
/// a circle whose ring fills with progress, then pops into a checkmark.
public struct DownloadButton: View {
    private let title: Text
    private let progress: Double?
    private let color: Color
    private let action: () -> Void

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    private enum Phase: Equatable { case idle, loading, done }

    /// - Parameters:
    ///   - title: The label shown before the download starts.
    ///   - progress: `nil` = not started, 0..<1 = downloading, 1 = finished.
    ///   - color: Tint of the button and the ring.
    ///   - action: Called when the idle button is tapped.
    public init(_ title: LocalizedStringKey = "Download", progress: Double?, color: Color = .accentColor, action: @escaping () -> Void) {
        self.title = Text(title)
        self.progress = progress
        self.color = color
        self.action = action
    }

    /// A download button whose label is a string you already have, shown as is.
    @_disfavoredOverload
    public init<S: StringProtocol>(_ title: S, progress: Double?, color: Color = .accentColor, action: @escaping () -> Void) {
        self.title = Text(title)
        self.progress = progress
        self.color = color
        self.action = action
    }

    private var phase: Phase {
        guard let progress else { return .idle }
        return progress >= 1 ? .done : .loading
    }

    public var body: some View {
        let size: CGFloat = 44
        Button {
            if phase == .idle { action() }
        } label: {
            ZStack {
                Capsule()
                    .fill(phase == .loading ? Color.secondary.opacity(0.12) : (phase == .done ? Color.green : color))

                if phase == .loading {
                    Circle()
                        .trim(from: 0, to: progress ?? 0)
                        .stroke(color, style: StrokeStyle(lineWidth: 4, lineCap: .round))
                        .rotationEffect(.degrees(-90))
                        .padding(3)
                        .animation(.easeOut(duration: 0.2), value: progress)
                    Image(systemName: "stop.fill")
                        .font(.system(size: 12))
                        .foregroundStyle(color)
                }

                if phase == .idle {
                    Label { title } icon: { Image(systemName: "arrow.down") }
                        .font(.headline)
                        .foregroundStyle(.white)
                        .fixedSize()
                        .transition(.opacity.combined(with: .scale(scale: 0.8)))
                }

                if phase == .done {
                    Image(systemName: "checkmark")
                        .font(.system(size: 18, weight: .bold))
                        .foregroundStyle(.white)
                        .transition(.scale(scale: 0.2).combined(with: .opacity))
                }
            }
            .frame(width: phase == .idle ? 150 : size, height: size)
            .contentShape(Capsule())
        }
        .buttonStyle(.plain)
        .animation(reduceMotion ? .easeInOut(duration: 0.25) : .spring(duration: 0.5, bounce: 0.35), value: phase)
        .haptic(.success, trigger: phase) { _, new in new == .done }
        .accessibilityLabel(phase == .idle ? title : Text(phase == .done ? "Downloaded" : "Downloading"))
        .accessibilityValue(phase == .loading ? "\(Int((progress ?? 0) * 100)) percent" : "")
    }
}

#Preview {
    struct Demo: View {
        @State private var progress: Double?
        var body: some View {
            DownloadButton(progress: progress) {
                Task {
                    progress = 0
                    while let p = progress, p < 1 {
                        try? await Task.sleep(for: .milliseconds(60))
                        progress = min(1, p + 0.03)
                    }
                    try? await Task.sleep(for: .seconds(1.2))
                    progress = nil
                }
            }
            .padding()
        }
    }
    return Demo()
}

// MARK: - Haptics.swift
// Needed by this effect: save it as its own file next to the one above.

import SwiftUI

/// The haptics the package plays. `SensoryFeedback` and `sensoryFeedback`
/// need visionOS 26, but the package supports visionOS 1, so call sites use
/// this enum and `.haptic(…)`, which does nothing on visionOS (it has no
/// haptics anyway).
enum Haptic: Equatable {
    case success
    case error
    case lightImpact

    #if !os(visionOS)
    var feedback: SensoryFeedback {
        switch self {
        case .success: .success
        case .error: .error
        case .lightImpact: .impact(weight: .light)
        }
    }
    #endif
}

extension View {
    /// Plays `haptic` every time `trigger` changes.
    func haptic<T: Equatable>(_ haptic: Haptic, trigger: T) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(haptic.feedback, trigger: trigger)
        #endif
    }

    /// Plays `haptic` when `trigger` changes and `condition` returns true.
    func haptic<T: Equatable>(_ haptic: Haptic, trigger: T, condition: @escaping (T, T) -> Bool) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(haptic.feedback, trigger: trigger, condition: condition)
        #endif
    }

    /// Plays the haptic returned by `haptic` (if any) when `trigger` changes.
    func haptic<T: Equatable>(trigger: T, _ haptic: @escaping (T, T) -> Haptic?) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(trigger: trigger) { old, new in haptic(old, new)?.feedback }
        #endif
    }
}
```

---

## Star Rating (`star-rating`)

Stars fill one after another with a springy pop and a burst of sparks.

- API: `StarRating(rating:)`
- Component: Button
- Trigger: Tap / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/StarRating.swift
- Files: Sources/MotionLibrary/Button/StarRating.swift, Sources/MotionLibrary/Support/Haptics.swift

### Usage

```swift
StarRating(rating: $rating)
```

### How it works

Create a SwiftUI StarRating(rating: Binding<Int>, maximum:color:size:) control (iOS 17+): an HStack (spacing size × 0.25) of SF Symbols "star.fill" (filled, yellow) or "star" (secondary 50 %), size 28 pt. Tapping star i sets rating = i. Each filled star runs keyframeAnimator(initialValue: 1, trigger: rating) on its scale: hold 1 for (i − 1) × 0.06 s, SpringKeyframe to 1.4 (0.15 s, .snappy), then to 1 (0.4 s, .bouncy), so they pop in sequence. Behind the tapped star, eight 4 pt dots at 45° steps fly out from size × 0.3 to size × 0.95 over 0.45 s while fading (after the same delay). Play a light haptic on change. Make the whole control one adjustable accessibility element ("Rating", "3 of 5"). With Reduce Motion on, skip the pops and sparks.

### Source

```swift
import SwiftUI

/// A star rating control. Tapping a star fills the stars up to it one after
/// another, each with a springy pop, and the tapped star bursts a ring of
/// sparks.
public struct StarRating: View {
    @Binding private var rating: Int
    private let maximum: Int
    private let color: Color
    private let size: CGFloat

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - rating: The number of filled stars, 0…maximum.
    ///   - maximum: How many stars to show.
    ///   - color: Colour of filled stars and sparks.
    ///   - size: Point size of each star.
    public init(rating: Binding<Int>, maximum: Int = 5, color: Color = .yellow, size: CGFloat = 28) {
        _rating = rating
        self.maximum = max(1, maximum)
        self.color = color
        self.size = size
    }

    public var body: some View {
        HStack(spacing: size * 0.25) {
            ForEach(1...maximum, id: \.self) { index in
                star(index)
            }
        }
        .haptic(.lightImpact, trigger: rating)
        .accessibilityElement()
        .accessibilityLabel("Rating")
        .accessibilityValue("\(rating) of \(maximum)")
        .accessibilityAdjustableAction { direction in
            switch direction {
            case .increment: rating = min(maximum, rating + 1)
            case .decrement: rating = max(0, rating - 1)
            @unknown default: break
            }
        }
    }

    private func star(_ index: Int) -> some View {
        let filled = index <= rating
        // Keyframes must have a positive duration, so the first star waits a hair.
        let delay = max(0.001, Double(index - 1) * 0.06)
        let animate = filled && !reduceMotion
        return Image(systemName: filled ? "star.fill" : "star")
            .font(.system(size: size))
            .foregroundStyle(filled ? color : Color.secondary.opacity(0.5))
            .keyframeAnimator(initialValue: 1.0, trigger: rating) { view, scale in
                view.scaleEffect(animate ? scale : 1)
            } keyframes: { _ in
                LinearKeyframe(1, duration: delay)
                SpringKeyframe(1.4, duration: 0.15, spring: .snappy)
                SpringKeyframe(1, duration: 0.4, spring: .bouncy)
            }
            .background {
                if index == rating && !reduceMotion {
                    StarSparks(color: color, size: size, trigger: rating, delay: delay)
                }
            }
            .contentShape(Rectangle())
            .onTapGesture { rating = index }
    }
}

/// Eight short dots that shoot out from the star and fade.
private struct StarSparks: View {
    let color: Color
    let size: CGFloat
    let trigger: Int
    let delay: Double

    var body: some View {
        ZStack {
            ForEach(0..<8, id: \.self) { spark in
                Circle()
                    .fill(color)
                    .frame(width: 4, height: 4)
                    .keyframeAnimator(initialValue: SparkValues(), trigger: trigger) { view, value in
                        view
                            .offset(y: -value.distance)
                            .rotationEffect(.degrees(Double(spark) * 45))
                            .opacity(value.opacity)
                    } keyframes: { _ in
                        KeyframeTrack(\.distance) {
                            LinearKeyframe(size * 0.3, duration: delay)
                            CubicKeyframe(size * 0.95, duration: 0.45)
                        }
                        KeyframeTrack(\.opacity) {
                            LinearKeyframe(0, duration: delay)
                            LinearKeyframe(1, duration: 0.05)
                            LinearKeyframe(0, duration: 0.4)
                        }
                    }
            }
        }
        .allowsHitTesting(false)
        .accessibilityHidden(true)
    }
}

private struct SparkValues {
    var distance: CGFloat = 0
    var opacity: Double = 0
}

#Preview {
    struct Demo: View {
        @State private var rating = 3
        var body: some View {
            StarRating(rating: $rating).padding()
        }
    }
    return Demo()
}

// MARK: - Haptics.swift
// Needed by this effect: save it as its own file next to the one above.

import SwiftUI

/// The haptics the package plays. `SensoryFeedback` and `sensoryFeedback`
/// need visionOS 26, but the package supports visionOS 1, so call sites use
/// this enum and `.haptic(…)`, which does nothing on visionOS (it has no
/// haptics anyway).
enum Haptic: Equatable {
    case success
    case error
    case lightImpact

    #if !os(visionOS)
    var feedback: SensoryFeedback {
        switch self {
        case .success: .success
        case .error: .error
        case .lightImpact: .impact(weight: .light)
        }
    }
    #endif
}

extension View {
    /// Plays `haptic` every time `trigger` changes.
    func haptic<T: Equatable>(_ haptic: Haptic, trigger: T) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(haptic.feedback, trigger: trigger)
        #endif
    }

    /// Plays `haptic` when `trigger` changes and `condition` returns true.
    func haptic<T: Equatable>(_ haptic: Haptic, trigger: T, condition: @escaping (T, T) -> Bool) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(haptic.feedback, trigger: trigger, condition: condition)
        #endif
    }

    /// Plays the haptic returned by `haptic` (if any) when `trigger` changes.
    func haptic<T: Equatable>(trigger: T, _ haptic: @escaping (T, T) -> Haptic?) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(trigger: trigger) { old, new in haptic(old, new)?.feedback }
        #endif
    }
}
```

---

## Bouncy Tab Bar (`bouncy-tab-bar`)

A pill glides to the selected tab and its icon squashes and hops.

- API: `BouncyTabBar(selection:items:)`
- Component: Button
- Trigger: Tap / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/BouncyTabBar.swift
- Files: Sources/MotionLibrary/Button/BouncyTabBar.swift, Sources/MotionLibrary/Support/Haptics.swift

### Usage

```swift
BouncyTabBar(selection: $tab, items: [
    .init("house.fill", "Home"),
    .init("magnifyingglass", "Search"),
    .init("person.fill", "Profile"),
])
```

### How it works

Create a SwiftUI BouncyTabBar(selection: Binding<Int>, items:color:) (iOS 17+) where each item is an SF Symbol and a title. Lay the tabs out in an HStack (spacing 4, padding 6) on a .regularMaterial Capsule with a soft shadow. Each tab is a plain Button with a VStack of the 18 pt semibold symbol and a caption2 title, tinted with the colour when selected (secondary otherwise), and a Capsule at 15 % of the colour behind the selected tab, shared with matchedGeometryEffect so it glides; selection changes use .spring(duration: 0.4, bounce: 0.3). The selected icon runs keyframeAnimator(trigger: selection), anchored at the bottom: scaleX 1.25 → 0.9 → spring 1, scaleY 0.75 → 1.12 → spring 1, y 0 → −7 → spring 0 (0.08 s, 0.14 s, 0.3 s .bouncy). Play a light haptic. With Reduce Motion on, skip the hop.

### Source

```swift
import SwiftUI

/// A floating tab bar: a pill glides to the selected tab and its icon does a
/// little squash-and-hop.
public struct BouncyTabBar: View {
    /// One tab: an SF Symbol and a short title.
    public struct Item: Hashable, Sendable {
        public var symbol: String
        public var title: String

        public init(_ symbol: String, _ title: String) {
            self.symbol = symbol
            self.title = title
        }
    }

    @Binding private var selection: Int
    private let items: [Item]
    private let color: Color

    @Namespace private var namespace
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - selection: Index of the selected tab.
    ///   - items: The tabs, in order.
    ///   - color: Tint of the selected tab.
    public init(selection: Binding<Int>, items: [Item], color: Color = .accentColor) {
        _selection = selection
        self.items = items
        self.color = color
    }

    public var body: some View {
        HStack(spacing: 4) {
            ForEach(items.indices, id: \.self) { index in
                tab(index)
            }
        }
        .padding(6)
        .background(.regularMaterial, in: Capsule())
        .shadow(color: .black.opacity(0.12), radius: 12, y: 4)
        .haptic(.lightImpact, trigger: selection)
    }

    private func tab(_ index: Int) -> some View {
        let selected = index == selection
        let bounce = selected && !reduceMotion
        return Button {
            withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.4, bounce: 0.3)) {
                selection = index
            }
        } label: {
            VStack(spacing: 2) {
                Image(systemName: items[index].symbol)
                    .font(.system(size: 18, weight: .semibold))
                    .keyframeAnimator(initialValue: HopValues(), trigger: selection) { view, value in
                        view
                            .scaleEffect(x: bounce ? value.scaleX : 1, y: bounce ? value.scaleY : 1, anchor: .bottom)
                            .offset(y: bounce ? value.y : 0)
                    } keyframes: { _ in
                        KeyframeTrack(\.scaleX) {
                            CubicKeyframe(1.25, duration: 0.08)
                            CubicKeyframe(0.9, duration: 0.14)
                            SpringKeyframe(1, duration: 0.3, spring: .bouncy)
                        }
                        KeyframeTrack(\.scaleY) {
                            CubicKeyframe(0.75, duration: 0.08)
                            CubicKeyframe(1.12, duration: 0.14)
                            SpringKeyframe(1, duration: 0.3, spring: .bouncy)
                        }
                        KeyframeTrack(\.y) {
                            LinearKeyframe(0, duration: 0.08)
                            CubicKeyframe(-7, duration: 0.14)
                            SpringKeyframe(0, duration: 0.3, spring: .bouncy)
                        }
                    }
                Text(items[index].title)
                    .font(.caption2.weight(.semibold))
            }
            .foregroundStyle(selected ? color : Color.secondary)
            .frame(maxWidth: .infinity)
            .padding(.vertical, 6)
            .background {
                if selected {
                    Capsule()
                        .fill(color.opacity(0.15))
                        .matchedGeometryEffect(id: "pill", in: namespace)
                }
            }
            .contentShape(Capsule())
        }
        .buttonStyle(.plain)
        .accessibilityLabel(items[index].title)
        .accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton)
    }
}

private struct HopValues {
    var scaleX: CGFloat = 1
    var scaleY: CGFloat = 1
    var y: CGFloat = 0
}

#Preview {
    struct Demo: View {
        @State private var tab = 0
        var body: some View {
            BouncyTabBar(selection: $tab, items: [
                .init("house.fill", "Home"),
                .init("magnifyingglass", "Search"),
                .init("heart.fill", "Saved"),
                .init("person.fill", "Profile"),
            ])
            .padding()
        }
    }
    return Demo()
}

// MARK: - Haptics.swift
// Needed by this effect: save it as its own file next to the one above.

import SwiftUI

/// The haptics the package plays. `SensoryFeedback` and `sensoryFeedback`
/// need visionOS 26, but the package supports visionOS 1, so call sites use
/// this enum and `.haptic(…)`, which does nothing on visionOS (it has no
/// haptics anyway).
enum Haptic: Equatable {
    case success
    case error
    case lightImpact

    #if !os(visionOS)
    var feedback: SensoryFeedback {
        switch self {
        case .success: .success
        case .error: .error
        case .lightImpact: .impact(weight: .light)
        }
    }
    #endif
}

extension View {
    /// Plays `haptic` every time `trigger` changes.
    func haptic<T: Equatable>(_ haptic: Haptic, trigger: T) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(haptic.feedback, trigger: trigger)
        #endif
    }

    /// Plays `haptic` when `trigger` changes and `condition` returns true.
    func haptic<T: Equatable>(_ haptic: Haptic, trigger: T, condition: @escaping (T, T) -> Bool) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(haptic.feedback, trigger: trigger, condition: condition)
        #endif
    }

    /// Plays the haptic returned by `haptic` (if any) when `trigger` changes.
    func haptic<T: Equatable>(trigger: T, _ haptic: @escaping (T, T) -> Haptic?) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(trigger: trigger) { old, new in haptic(old, new)?.feedback }
        #endif
    }
}
```

---

## Pull Cord (`pull-cord`)

A lamp cord toggle: pull the knob down, it clicks and springs back swinging.

- API: `PullCord(isOn:)`
- Component: Button
- Trigger: Drag / Tap
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/PullCord.swift
- Files: Sources/MotionLibrary/Button/PullCord.swift, Sources/MotionLibrary/Support/Haptics.swift

### Usage

```swift
HStack(alignment: .top) {
    Image(systemName: lightsOn ? "lightbulb.max.fill" : "lightbulb")
    PullCord(isOn: $lightsOn)
}
```

### How it works

Create a SwiftUI PullCord(isOn:length:color:) toggle (iOS 17+). A VStack(spacing: 0) of a 2 pt wide Rectangle (height length + stretch, default length 70) and a 16 × 26 Capsule knob, yellow with a yellow shadow (radius 8) when on. Rotate the whole cord with .rotationEffect(swing, anchor: .top) inside a 60 pt wide frame with room below to stretch. A DragGesture(minimumDistance: 0) sets stretch = 1.6c × (1 − e^(−pull / 1.6c)) with c = 0.45 × length (rubber-band resistance) and swing = −translation.width / 6 clamped to ±18°. On release, toggle when stretch passed c or the drag was a tap (moved < 4 pt), play a light haptic, then spring stretch back to 0 with .spring(duration: 0.55, bounce: 0.6) and swing back with .spring(duration: 0.9, bounce: 0.7). With Reduce Motion on, no swing and an ease-out return. Make it one accessibility element with the button trait, value On/Off and an action that toggles.

### Source

```swift
import SwiftUI

/// A toggle you pull like a lamp cord: drag the knob down, it stretches with
/// rubber-band resistance, and past the catch point it clicks, flips the
/// value and springs back up, swinging. A tap also toggles it.
///
/// ```swift
/// PullCord(isOn: $lightsOn)
/// ```
public struct PullCord: View {
    @Binding private var isOn: Bool
    private let length: CGFloat
    private let color: Color

    @State private var stretch: CGFloat = 0
    @State private var swing: Double = 0
    @State private var clicks = 0
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - isOn: The value the cord toggles.
    ///   - length: Length of the cord at rest, in points.
    ///   - color: Colour of the cord and knob.
    public init(isOn: Binding<Bool>, length: CGFloat = 70, color: Color = .primary) {
        _isOn = isOn
        self.length = length
        self.color = color
    }

    private var catchPoint: CGFloat { length * 0.45 }

    public var body: some View {
        VStack(spacing: 0) {
            Rectangle()
                .fill(color.opacity(0.7))
                .frame(width: 2, height: length + stretch)
            Capsule()
                .fill(isOn ? Color.yellow : color)
                .overlay(Capsule().strokeBorder(color.opacity(0.4), lineWidth: 1))
                .frame(width: 16, height: 26)
                .shadow(color: isOn ? .yellow.opacity(0.7) : .clear, radius: 8)
        }
        .rotationEffect(.degrees(swing), anchor: .top)
        .frame(width: 60, height: length + 26 + catchPoint * 1.6, alignment: .top)
        .contentShape(Rectangle())
        .gesture(
            DragGesture(minimumDistance: 0)
                .onChanged { value in
                    // Rubber band: easy at first, then stiffer.
                    let pull = max(0, value.translation.height)
                    stretch = catchPoint * 1.6 * (1 - exp(-pull / (catchPoint * 1.6)))
                    swing = reduceMotion ? 0 : Double(-value.translation.width / 6).clamped(to: -18...18)
                }
                .onEnded { value in
                    let pulled = stretch > catchPoint || (abs(value.translation.height) < 4 && abs(value.translation.width) < 4)
                    if pulled { toggle() }
                    withAnimation(reduceMotion ? .easeOut(duration: 0.2) : .spring(duration: 0.55, bounce: 0.6)) {
                        stretch = 0
                    }
                    if reduceMotion {
                        swing = 0
                    } else {
                        // Swing out a little the other way, then settle.
                        withAnimation(.spring(duration: 0.9, bounce: 0.7)) { swing = 0 }
                    }
                }
        )
        .haptic(.lightImpact, trigger: clicks)
        .accessibilityElement()
        .accessibilityLabel("Pull cord")
        .accessibilityValue(isOn ? "On" : "Off")
        .accessibilityAddTraits(.isButton)
        .accessibilityAction { toggle() }
    }

    private func toggle() {
        isOn.toggle()
        clicks += 1
    }
}

private extension Double {
    func clamped(to range: ClosedRange<Double>) -> Double { Swift.min(range.upperBound, Swift.max(range.lowerBound, self)) }
}

#Preview {
    struct Demo: View {
        @State private var on = false
        var body: some View {
            ZStack(alignment: .top) {
                (on ? Color.yellow.opacity(0.15) : Color.black.opacity(0.05))
                PullCord(isOn: $on)
            }
            .frame(width: 200, height: 220)
        }
    }
    return Demo()
}

// MARK: - Haptics.swift
// Needed by this effect: save it as its own file next to the one above.

import SwiftUI

/// The haptics the package plays. `SensoryFeedback` and `sensoryFeedback`
/// need visionOS 26, but the package supports visionOS 1, so call sites use
/// this enum and `.haptic(…)`, which does nothing on visionOS (it has no
/// haptics anyway).
enum Haptic: Equatable {
    case success
    case error
    case lightImpact

    #if !os(visionOS)
    var feedback: SensoryFeedback {
        switch self {
        case .success: .success
        case .error: .error
        case .lightImpact: .impact(weight: .light)
        }
    }
    #endif
}

extension View {
    /// Plays `haptic` every time `trigger` changes.
    func haptic<T: Equatable>(_ haptic: Haptic, trigger: T) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(haptic.feedback, trigger: trigger)
        #endif
    }

    /// Plays `haptic` when `trigger` changes and `condition` returns true.
    func haptic<T: Equatable>(_ haptic: Haptic, trigger: T, condition: @escaping (T, T) -> Bool) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(haptic.feedback, trigger: trigger, condition: condition)
        #endif
    }

    /// Plays the haptic returned by `haptic` (if any) when `trigger` changes.
    func haptic<T: Equatable>(trigger: T, _ haptic: @escaping (T, T) -> Haptic?) -> some View {
        #if os(visionOS)
        self
        #else
        sensoryFeedback(trigger: trigger) { old, new in haptic(old, new)?.feedback }
        #endif
    }
}
```

---

## Staggered Entrance (`staggered-appear`)

List items fade and rise into place one after another.

- API: `.staggeredAppear(index:)`
- Component: Card & Group
- Trigger: Appear
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/StaggeredAppear.swift
- Files: Sources/MotionLibrary/Card/StaggeredAppear.swift

### Usage

```swift
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
    CardView(item)
        .staggeredAppear(index: index)
}
```

### How it works

Create a SwiftUI .staggeredAppear(index:) view modifier (iOS 17+) for list and grid entrances. Each item starts at opacity 0, offset 24 pt down and scale 0.96, then on appear animates to its resting state with .spring(response: 0.5, dampingFraction: 0.8), delayed by index × 0.08 s so the items cascade in. Delay per item and travel distance are configurable.

### Source

```swift
import SwiftUI

/// Items fade, rise and settle into place one after another when they first
/// appear.
struct StaggeredAppearModifier: ViewModifier {
    var index: Int
    var delayPerItem: Double
    var distance: CGFloat

    @State private var visible = false
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        content
            .opacity(visible ? 1 : 0)
            // Reduce Motion: fade only.
            .offset(y: visible || reduceMotion ? 0 : distance)
            .scaleEffect(visible || reduceMotion ? 1 : 0.96)
            .onAppear {
                withAnimation(.spring(response: 0.5, dampingFraction: 0.8).delay(Double(index) * delayPerItem)) {
                    visible = true
                }
            }
    }
}

public extension View {
    /// Staggered entrance. Give each item in a list its position.
    ///
    /// ```swift
    /// ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
    ///     CardView(item)
    ///         .staggeredAppear(index: index)
    /// }
    /// ```
    func staggeredAppear(index: Int, delayPerItem: Double = 0.08, distance: CGFloat = 24) -> some View {
        modifier(StaggeredAppearModifier(index: index, delayPerItem: delayPerItem, distance: distance))
    }
}

#Preview {
    VStack(spacing: 12) {
        ForEach(0..<5, id: \.self) { index in
            RoundedRectangle(cornerRadius: 12)
                .fill(Color.accentColor.opacity(0.2 + Double(index) * 0.15))
                .frame(height: 56)
                .staggeredAppear(index: index)
        }
    }
    .padding()
}
```

---

## 3D Tilt (`tilt`)

Card tilts in 3D toward the finger with a moving glare, then springs flat.

- API: `.tiltOnDrag()`
- Component: Card & Group
- Trigger: Drag
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/Tilt.swift
- Files: Sources/MotionLibrary/Card/Tilt.swift

### Usage

```swift
CardView()
    .tiltOnDrag(maxAngle: 15, cornerRadius: 20)
```

### How it works

Create a SwiftUI .tiltOnDrag() view modifier (iOS 17+). While the user drags on the view, convert the drag translation into a normalised -1…1 value per axis (120 pt = full tilt) and apply two rotation3DEffects (x axis from vertical drag, y axis from horizontal drag) up to a max angle of ~12°, plus a 1.03 scale. Overlay a white glare gradient whose start/end points follow the tilt and which is only visible while dragging, clipped to the card's corner radius. Use .interactiveSpring while dragging and a bouncy spring back to flat on release.

### Source

```swift
import SwiftUI

/// 3D card tilt that follows the finger, with a moving glare, then springs
/// back flat on release.
struct TiltModifier: ViewModifier {
    var maxAngle: Double
    var glare: Bool
    var cornerRadius: CGFloat

    /// Normalised drag, each axis in -1...1.
    @State private var tilt: CGSize = .zero
    @State private var isDragging = false
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// Translation (in points) that maps to the maximum angle.
    private let range: CGFloat = 120

    func body(content: Content) -> some View {
        content
            .overlay {
                if glare {
                    LinearGradient(
                        colors: [.white.opacity(isDragging ? 0.35 : 0), .clear],
                        startPoint: UnitPoint(x: 0.5 - tilt.width * 0.5, y: 0.5 - tilt.height * 0.5),
                        endPoint: UnitPoint(x: 0.5 + tilt.width * 0.5, y: 0.5 + tilt.height * 0.5)
                    )
                    .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
                    .allowsHitTesting(false)
                }
            }
            // Reduce Motion: keep the glare and the lift, drop the rotation.
            .rotation3DEffect(.degrees(Double(-tilt.height) * angle), axis: (x: 1, y: 0, z: 0))
            .rotation3DEffect(.degrees(Double(tilt.width) * angle), axis: (x: 0, y: 1, z: 0))
            .scaleEffect(isDragging ? 1.03 : 1)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        withAnimation(.interactiveSpring()) {
                            isDragging = true
                            tilt = CGSize(
                                width: clamp(value.translation.width / range),
                                height: clamp(value.translation.height / range)
                            )
                        }
                    }
                    .onEnded { _ in
                        withAnimation(.spring(response: 0.5, dampingFraction: 0.55)) {
                            isDragging = false
                            tilt = .zero
                        }
                    }
            )
    }

    private var angle: Double { reduceMotion ? 0 : maxAngle }

    private func clamp(_ value: CGFloat) -> CGFloat {
        min(1, max(-1, value))
    }
}

public extension View {
    /// Tilts the view in 3D while it is dragged.
    ///
    /// ```swift
    /// CardView()
    ///     .tiltOnDrag(maxAngle: 15, cornerRadius: 20)
    /// ```
    func tiltOnDrag(maxAngle: Double = 12, glare: Bool = true, cornerRadius: CGFloat = 16) -> some View {
        modifier(TiltModifier(maxAngle: maxAngle, glare: glare, cornerRadius: cornerRadius))
    }
}

#Preview {
    RoundedRectangle(cornerRadius: 20, style: .continuous)
        .fill(LinearGradient(colors: [.purple, .blue], startPoint: .topLeading, endPoint: .bottomTrailing))
        .frame(width: 220, height: 300)
        .tiltOnDrag(cornerRadius: 20)
        .padding(40)
}
```

---

## Scroll Reveal (`scroll-reveal`)

Rows scale up and come into focus as they enter the visible area.

- API: `.scrollReveal()`
- Component: Card & Group
- Trigger: Scroll
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/ScrollReveal.swift
- Files: Sources/MotionLibrary/Card/ScrollReveal.swift

### Usage

```swift
ScrollView {
    LazyVStack {
        ForEach(items) { item in
            Row(item).scrollReveal()
        }
    }
}
```

### How it works

Create a SwiftUI .scrollReveal() view modifier (iOS 17+) using scrollTransition(.interactive). When an item is fully inside the scroll view (phase.isIdentity) it is at full opacity, scale 1 and no blur; as it moves toward the top or bottom edge it interpolates to opacity 0.3, scale 0.85 and a 2 pt blur. Axis, minimum scale, minimum opacity and blur amount should be parameters.

### Source

```swift
import SwiftUI

public extension View {
    /// Items scale up and come into focus as they scroll into the visible
    /// area, and fade back out as they leave. Use inside a `ScrollView`.
    ///
    /// ```swift
    /// ScrollView {
    ///     LazyVStack {
    ///         ForEach(items) { item in
    ///             Row(item).scrollReveal()
    ///         }
    ///     }
    /// }
    /// ```
    func scrollReveal(
        axis: Axis = .vertical,
        minScale: CGFloat = 0.85,
        minOpacity: Double = 0.3,
        blur: CGFloat = 2
    ) -> some View {
        modifier(ScrollRevealModifier(axis: axis, minScale: minScale, minOpacity: minOpacity, blur: blur))
    }
}

struct ScrollRevealModifier: ViewModifier {
    var axis: Axis
    var minScale: CGFloat
    var minOpacity: Double
    var blur: CGFloat

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        // Reduce Motion: keep the fade, drop the scaling and blur.
        let minScale = reduceMotion ? 1 : minScale
        let blur = reduceMotion ? 0 : blur
        let minOpacity = minOpacity
        return content.scrollTransition(.interactive, axis: axis) { view, phase in
            view
                .opacity(phase.isIdentity ? 1 : minOpacity)
                .scaleEffect(phase.isIdentity ? 1 : minScale)
                .blur(radius: phase.isIdentity ? 0 : blur)
        }
    }
}

#Preview {
    ScrollView {
        LazyVStack(spacing: 12) {
            ForEach(0..<30, id: \.self) { index in
                RoundedRectangle(cornerRadius: 14)
                    .fill(Color.accentColor.gradient)
                    .frame(height: 80)
                    .overlay(Text("Row \(index)").foregroundStyle(.white))
                    .scrollReveal()
            }
        }
        .padding()
    }
}
```

---

## Flip Card (`flip-card`)

A two-sided card turns over in 3D with a spring.

- API: `FlipCard(isFlipped:) { } back: { }`
- Component: Card & Group
- Trigger: Tap / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/FlipCard.swift
- Files: Sources/MotionLibrary/Card/FlipCard.swift

### Usage

```swift
@State private var flipped = false

FlipCard(isFlipped: flipped) {
    QuestionView()
} back: {
    AnswerView()
}
.frame(width: 220, height: 300)
.onTapGesture { flipped.toggle() }
```

### How it works

Create a SwiftUI FlipCard<Front, Back> view (iOS 17+) with isFlipped and axis (horizontal or vertical) parameters and front/back view builders. Stack both faces in a ZStack and give each an Animatable view modifier holding the animated angle (0 or 180°). The back face uses angle − 180 so it reads correctly once turned. Each face applies rotation3DEffect around the y axis (or x for vertical) with perspective 0.5 and sets opacity to 1 only while |faceAngle| < 90°, so the faces swap exactly at the halfway point and the back never shows mirrored. Animate isFlipped changes with spring(response 0.6, damping 0.75). Hide the hidden face from accessibility. With Reduce Motion on, cross-fade instead of rotating.

### Source

```swift
import SwiftUI

/// A two-sided card that turns over in 3D, showing `back` when `isFlipped`
/// is true. The faces swap exactly at 90°, so the back never shows mirrored.
public struct FlipCard<Front: View, Back: View>: View {
    private let isFlipped: Bool
    private let axis: Axis
    private let front: Front
    private let back: Back

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - isFlipped: Shows the back when true.
    ///   - axis: `.horizontal` turns like a page (around the vertical axis),
    ///     `.vertical` tumbles top over bottom.
    public init(
        isFlipped: Bool,
        axis: Axis = .horizontal,
        @ViewBuilder front: () -> Front,
        @ViewBuilder back: () -> Back
    ) {
        self.isFlipped = isFlipped
        self.axis = axis
        self.front = front()
        self.back = back()
    }

    public var body: some View {
        if reduceMotion {
            // Cross-fade instead of turning.
            ZStack {
                front.opacity(isFlipped ? 0 : 1)
                back.opacity(isFlipped ? 1 : 0)
            }
            .animation(.easeInOut(duration: 0.3), value: isFlipped)
        } else {
            ZStack {
                front.modifier(FlipFace(angle: isFlipped ? 180 : 0, axis: axis, isBack: false))
                back.modifier(FlipFace(angle: isFlipped ? 180 : 0, axis: axis, isBack: true))
            }
            .animation(.spring(response: 0.6, dampingFraction: 0.75), value: isFlipped)
        }
    }
}

/// Rotates one face by `angle` and hides it while it faces away.
private struct FlipFace: ViewModifier, Animatable {
    var angle: Double
    var axis: Axis
    var isBack: Bool

    nonisolated var animatableData: Double {
        get { angle }
        set { angle = newValue }
    }

    func body(content: Content) -> some View {
        // The back starts already turned 180° so it reads correctly when shown.
        let faceAngle = isBack ? angle - 180 : angle
        let visible = abs(faceAngle) < 90
        content
            .opacity(visible ? 1 : 0)
            .accessibilityHidden(!visible)
            .rotation3DEffect(
                .degrees(faceAngle),
                axis: axis == .horizontal ? (x: 0, y: 1, z: 0) : (x: 1, y: 0, z: 0),
                perspective: 0.5
            )
    }
}

#Preview {
    struct Demo: View {
        @State private var flipped = false

        var body: some View {
            FlipCard(isFlipped: flipped) {
                RoundedRectangle(cornerRadius: 20).fill(.blue.gradient)
                    .overlay(Text("Front").font(.title.bold()).foregroundStyle(.white))
            } back: {
                RoundedRectangle(cornerRadius: 20).fill(.orange.gradient)
                    .overlay(Text("Back").font(.title.bold()).foregroundStyle(.white))
            }
            .frame(width: 200, height: 260)
            .onTapGesture { flipped.toggle() }
        }
    }
    return Demo()
}
```

---

## Swipe Card Stack (`swipe-stack`)

Tinder-style deck: drag the top card away and the pile moves up.

- API: `SwipeCardStack(items) { item in }`
- Component: Card & Group
- Trigger: Drag
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/SwipeCardStack.swift
- Files: Sources/MotionLibrary/Card/SwipeCardStack.swift

### Usage

```swift
SwipeCardStack(profiles) { profile, direction in
    direction == .right ? like(profile) : pass(profile)
} card: { profile in
    ProfileCard(profile)
        .frame(width: 300, height: 420)
}
```

### How it works

Create a generic SwiftUI SwipeCardStack (iOS 17+) over a collection of Identifiable items with a card view builder and an onSwipe(item, direction) callback. Show the top 3 cards from the current position (looping by default). Cards behind are scaled down 5% per level and moved down 12 pt per level. The top card follows a DragGesture: offset by the translation, rotate by translation.width / 20 degrees around the bottom anchor, and overlay a green (right) or red (left) wash, masked by the card, whose opacity grows to 25% at the threshold. On release, if the predicted end translation passes 110 pt, fly the card 600 pt off in that direction with easeIn(0.25), then reset the drag without animation, advance the position with a spring so the pile moves up, and call onSwipe. Otherwise spring back. Add Swipe left and Swipe right accessibility actions.

### Source

```swift
import SwiftUI

/// Which way a card left a `SwipeCardStack`.
public enum SwipeCardDirection: Sendable {
    case left
    case right
}

/// The old name of `SwipeCardDirection`.
@available(*, deprecated, renamed: "SwipeCardDirection")
public typealias SwipeDirection = SwipeCardDirection

/// A Tinder-style deck. The top card follows the finger and tilts; past a
/// threshold it flies off and the cards behind move up. Drag less and it
/// springs back.
public struct SwipeCardStack<Data: RandomAccessCollection, Card: View>: View where Data.Element: Identifiable {
    private let items: Data
    private let visibleCount: Int
    private let loops: Bool
    private let onSwipe: (Data.Element, SwipeCardDirection) -> Void
    private let card: (Data.Element) -> Card

    @State private var position = 0
    @State private var drag: CGSize = .zero
    @State private var flyingOff: SwipeCardDirection?
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    private let threshold: CGFloat = 110

    /// - Parameters:
    ///   - items: The cards, top card first.
    ///   - visibleCount: How many cards are drawn in the pile.
    ///   - loops: Starts over from the first card when the deck runs out.
    ///   - onSwipe: Called after a card leaves, with the direction it went.
    ///   - card: Builds the view for one item.
    public init(
        _ items: Data,
        visibleCount: Int = 3,
        loops: Bool = true,
        onSwipe: @escaping (Data.Element, SwipeCardDirection) -> Void = { _, _ in },
        @ViewBuilder card: @escaping (Data.Element) -> Card
    ) {
        self.items = items
        self.visibleCount = visibleCount
        self.loops = loops
        self.onSwipe = onSwipe
        self.card = card
    }

    public var body: some View {
        let elements = Array(items)
        ZStack {
            ForEach(Array(visibleCards(elements).enumerated()).reversed(), id: \.element.id) { depth, item in
                card(item)
                    .modifier(StackedCard(depth: depth, drag: depth == 0 ? drag : .zero, threshold: threshold))
                    .allowsHitTesting(depth == 0)
                    .gesture(depth == 0 ? dragGesture(for: item) : nil)
                    .accessibilityHidden(depth != 0)
                    .accessibilityActions {
                        Button("Swipe left") { swipe(item, .left) }
                        Button("Swipe right") { swipe(item, .right) }
                    }
            }
        }
    }

    private func visibleCards(_ elements: [Data.Element]) -> [Data.Element] {
        guard !elements.isEmpty else { return [] }
        let remaining = loops ? elements.count : max(0, elements.count - position)
        return (0..<min(visibleCount, remaining)).map { elements[(position + $0) % elements.count] }
    }

    private func dragGesture(for item: Data.Element) -> some Gesture {
        DragGesture()
            .onChanged { value in
                guard flyingOff == nil else { return }
                drag = value.translation
            }
            .onEnded { value in
                guard flyingOff == nil else { return }
                // Use the predicted end so a quick flick counts too.
                let x = value.predictedEndTranslation.width
                if abs(x) > threshold {
                    swipe(item, x > 0 ? .right : .left)
                } else {
                    withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) { drag = .zero }
                }
            }
    }

    private func swipe(_ item: Data.Element, _ direction: SwipeCardDirection) {
        flyingOff = direction
        let distance: CGFloat = direction == .right ? 600 : -600
        withAnimation(reduceMotion ? .easeOut(duration: 0.2) : .easeIn(duration: 0.25)) {
            drag = CGSize(width: distance, height: drag.height + 40)
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
            var transaction = Transaction()
            transaction.disablesAnimations = true
            withTransaction(transaction) { drag = .zero }
            withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { position += 1 }
            flyingOff = nil
            onSwipe(item, direction)
        }
    }
}

/// Places a card in the pile: deeper cards are smaller and lower; the top
/// card follows the drag and tilts away from it.
private struct StackedCard: ViewModifier {
    var depth: Int
    var drag: CGSize
    var threshold: CGFloat

    func body(content: Content) -> some View {
        content
            .overlay {
                // Green or red wash that grows as the card nears the threshold.
                let progress = min(1, abs(drag.width) / threshold)
                (drag.width > 0 ? Color.green : Color.red)
                    .opacity(depth == 0 ? Double(progress) * 0.25 : 0)
                    .mask { content }
                    .allowsHitTesting(false)
            }
            .scaleEffect(1 - CGFloat(depth) * 0.05)
            .offset(y: CGFloat(depth) * 12)
            .offset(drag)
            .rotationEffect(.degrees(Double(drag.width / 20)), anchor: .bottom)
    }
}

#Preview {
    struct Item: Identifiable {
        let id: Int
        let color: Color
    }
    let items = [Color.pink, .orange, .teal, .indigo].enumerated().map { Item(id: $0, color: $1) }
    return SwipeCardStack(items) { item in
        RoundedRectangle(cornerRadius: 20, style: .continuous)
            .fill(item.color.gradient)
            .frame(width: 220, height: 300)
            .overlay(Text("Card \(item.id + 1)").font(.title.bold()).foregroundStyle(.white))
    }
    .padding(40)
}
```

---

## Parallax Carousel (`parallax-carousel`)

Paging cards whose images slide slower than the cards, for depth.

- API: `ParallaxCarousel(items) { item in }`
- Component: Card & Group
- Trigger: Scroll
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/ParallaxCarousel.swift
- Files: Sources/MotionLibrary/Card/ParallaxCarousel.swift

### Usage

```swift
ParallaxCarousel(places) { place in
    Image(place.photo)
        .resizable()
        .scaledToFill()
}
.frame(height: 240)
```

### How it works

Create a generic SwiftUI ParallaxCarousel (iOS 17+) over Identifiable items with an optional selection binding. Use a horizontal ScrollView with a LazyHStack (14 pt spacing), .scrollTargetLayout(), .scrollTargetBehavior(.viewAligned), hidden indicators, .scrollPosition(id:anchor: .center), and horizontal contentMargins so each card (78% of the width) snaps to the centre with neighbours peeking. Draw each card's content wider than the card by the parallax factor (0.35), then use .visualEffect to read the card's frame in the .scrollView coordinate space and offset the content by −(distance of the card centre from the viewport centre) × parallax. Finally frame it to the card size and clip it with a continuous rounded rectangle (22 pt), so the image appears to move slower than its frame. With Reduce Motion on, set parallax to 0.

### Source

```swift
import SwiftUI

/// A paging carousel whose card contents slide slower than the cards
/// themselves, so images seem to sit deeper than the frame around them.
/// Neighbouring cards peek in from both sides.
public struct ParallaxCarousel<Data: RandomAccessCollection, Content: View>: View where Data.Element: Identifiable {
    private let items: Data
    private let selection: Binding<Data.Element.ID?>?
    private let cardWidth: CGFloat
    private let spacing: CGFloat
    private let cornerRadius: CGFloat
    private let parallax: CGFloat
    private let content: (Data.Element) -> Content

    @State private var internalSelection: Data.Element.ID?
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - items: One card per item.
    ///   - selection: Optional binding to the centred card; set it to scroll.
    ///   - cardWidth: Card width as a fraction of the carousel's width.
    ///   - spacing: Gap between cards.
    ///   - cornerRadius: Corner radius of each card.
    ///   - parallax: How much slower the content moves than its card (0…1).
    ///   - content: The card's content, usually an image. It is drawn wider
    ///     than the card so the parallax never reveals an edge.
    public init(
        _ items: Data,
        selection: Binding<Data.Element.ID?>? = nil,
        cardWidth: CGFloat = 0.78,
        spacing: CGFloat = 14,
        cornerRadius: CGFloat = 22,
        parallax: CGFloat = 0.35,
        @ViewBuilder content: @escaping (Data.Element) -> Content
    ) {
        self.items = items
        self.selection = selection
        self.cardWidth = cardWidth
        self.spacing = spacing
        self.cornerRadius = cornerRadius
        self.parallax = parallax
        self.content = content
    }

    public var body: some View {
        GeometryReader { geometry in
            let width = geometry.size.width * cardWidth
            let strength = reduceMotion ? 0 : parallax
            ScrollView(.horizontal) {
                LazyHStack(spacing: spacing) {
                    ForEach(items) { item in
                        content(item)
                            // Extra width on both sides for the content to slide into.
                            .frame(width: width * (1 + strength), height: geometry.size.height)
                            .visualEffect { view, proxy in
                                // How far this card's centre is from the carousel's centre.
                                let frame = proxy.frame(in: .scrollView(axis: .horizontal))
                                let viewport = proxy.bounds(of: .scrollView(axis: .horizontal))?.width ?? frame.width
                                let distance = frame.midX - viewport / 2
                                return view.offset(x: -distance * strength)
                            }
                            .frame(width: width, height: geometry.size.height)
                            .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
                            .id(item.id)
                    }
                }
                .scrollTargetLayout()
            }
            .contentMargins(.horizontal, (geometry.size.width - width) / 2, for: .scrollContent)
            .scrollTargetBehavior(.viewAligned)
            .scrollIndicators(.hidden)
            .scrollPosition(id: selection ?? $internalSelection, anchor: .center)
        }
    }
}

#Preview {
    struct Place: Identifiable {
        let id: Int
        let colors: [Color]
    }
    let places = [
        Place(id: 0, colors: [.orange, .pink]),
        Place(id: 1, colors: [.teal, .blue]),
        Place(id: 2, colors: [.purple, .indigo]),
        Place(id: 3, colors: [.mint, .green]),
    ]
    return ParallaxCarousel(places) { place in
        LinearGradient(colors: place.colors, startPoint: .topLeading, endPoint: .bottomTrailing)
            .overlay(Image(systemName: "mountain.2.fill").font(.system(size: 60)).foregroundStyle(.white.opacity(0.8)))
    }
    .frame(height: 220)
}
```

---

## Expandable Card (`expandable-card`)

Tap the header and the card springs open, un-blurring its detail.

- API: `ExpandableCard(isExpanded:) { } detail: { }`
- Component: Card & Group
- Trigger: Tap
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/ExpandableCard.swift
- Files: Sources/MotionLibrary/Card/ExpandableCard.swift

### Usage

```swift
@State private var isOpen = false

ExpandableCard(isExpanded: $isOpen) {
    Text("Shipping").font(.headline)
} detail: {
    Text("Arrives Thursday. Free returns within 30 days.")
}
```

### How it works

Create a SwiftUI ExpandableCard<Header, Detail> (iOS 17+) bound to isExpanded. A VStack holds a plain-style Button containing the header, a spacer and a chevron.down that rotates −180° when open, then, if expanded, the detail view. Toggle inside withAnimation(.spring(response 0.45, damping 0.8)) so the card's height springs. Insert the detail with a combined transition of opacity, an 8 pt upward offset and a custom blur transition (radius 6 → 0). The background is a continuous rounded rectangle (16 pt) filled with the background style whose shadow deepens when open. Expose Expanded/Collapsed as the accessibility value. With Reduce Motion on, use a short ease and an opacity transition only.

### Source

```swift
import SwiftUI

/// A card with a tappable header that expands to reveal its detail. The
/// height springs open, the chevron turns, and the detail un-blurs into place.
/// Stack several for an accordion.
public struct ExpandableCard<Header: View, Detail: View>: View {
    @Binding private var isExpanded: Bool
    private let cornerRadius: CGFloat
    private let header: Header
    private let detail: Detail

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - isExpanded: Whether the detail is showing.
    ///   - cornerRadius: Corner radius of the card.
    ///   - header: Always visible; tapping it toggles the card.
    ///   - detail: Shown below the header when expanded.
    public init(
        isExpanded: Binding<Bool>,
        cornerRadius: CGFloat = 16,
        @ViewBuilder header: () -> Header,
        @ViewBuilder detail: () -> Detail
    ) {
        _isExpanded = isExpanded
        self.cornerRadius = cornerRadius
        self.header = header()
        self.detail = detail()
    }

    public var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            Button {
                withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(response: 0.45, dampingFraction: 0.8)) {
                    isExpanded.toggle()
                }
            } label: {
                HStack {
                    header
                    Spacer(minLength: 8)
                    Image(systemName: "chevron.down")
                        .font(.footnote.weight(.semibold))
                        .foregroundStyle(.secondary)
                        .rotationEffect(.degrees(isExpanded ? -180 : 0))
                }
                .padding()
                .contentShape(Rectangle())
            }
            .buttonStyle(.plain)
            .accessibilityValue(isExpanded ? Text("Expanded") : Text("Collapsed"))

            if isExpanded {
                detail
                    .padding([.horizontal, .bottom])
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .transition(
                        reduceMotion
                            ? .opacity
                            : .opacity.combined(with: .offset(y: -8)).combined(with: BlurTransition.blur)
                    )
            }
        }
        .background(
            RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
                .fill(.background)
                .shadow(color: .black.opacity(isExpanded ? 0.12 : 0.06), radius: isExpanded ? 12 : 4, y: isExpanded ? 6 : 2)
        )
        .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
    }
}

/// Blurs a view while it is inserted or removed.
private struct BlurTransition: ViewModifier {
    var radius: CGFloat

    func body(content: Content) -> some View {
        content.blur(radius: radius)
    }

    static var blur: AnyTransition {
        .modifier(active: BlurTransition(radius: 6), identity: BlurTransition(radius: 0))
    }
}

#Preview {
    struct Demo: View {
        @State private var open = [true, false, false]

        var body: some View {
            VStack(spacing: 12) {
                ForEach(0..<3) { index in
                    ExpandableCard(isExpanded: $open[index]) {
                        Text("Question \(index + 1)").font(.headline)
                    } detail: {
                        Text("Answers expand smoothly with a spring, and the detail fades and un-blurs into place.")
                            .foregroundStyle(.secondary)
                    }
                }
            }
            .padding()
            .background(Color.gray.opacity(0.1))
        }
    }
    return Demo()
}
```

---

## Hero Expand (`hero-expand`)

A thumbnail grows into a full detail card, morphing with matchedGeometryEffect.

- API: `HeroGrid(items) { thumbnail } detail: { }`
- Component: Card & Group
- Trigger: Tap
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/HeroGrid.swift
- Files: Sources/MotionLibrary/Card/HeroGrid.swift

### Usage

```swift
HeroGrid(photos, columns: 3) { photo in
    Image(photo.thumbnail).resizable().scaledToFill().frame(height: 90)
} detail: { photo in
    PhotoDetail(photo)
}
```

### How it works

Create a generic SwiftUI HeroGrid (iOS 17+) over Identifiable items with columns (3), spacing (10), cornerRadius (14) and two view builders: thumbnail and detail. Keep @State selected: Item.ID? and a @Namespace. In a ZStack, show a LazyVGrid of flexible columns; for each item, if it isn't selected show a plain Button with the thumbnail clipped to a continuous rounded rectangle and .matchedGeometryEffect(id: item.id, in: namespace), otherwise show the thumbnail .hidden() to keep the slot. Dim the grid to 30 % while something is selected. Above it, if selected, show the detail in a Button sized maxWidth/maxHeight .infinity, clipped the same way, with the same matchedGeometryEffect id and zIndex 1. Open with withAnimation(.spring(response: 0.45, dampingFraction: 0.82)) and close by tapping the detail. Both views get an .opacity transition so their content cross-fades while the frame morphs. With Reduce Motion on, pass properties: [] to matchedGeometryEffect so it only cross-fades.

### Source

```swift
import SwiftUI

/// A grid of thumbnails where tapping one grows it into a detail card that
/// fills the grid, morphing frame and corners with `matchedGeometryEffect`.
/// Tapping the detail shrinks it back into its slot.
public struct HeroGrid<Item: Identifiable, Thumbnail: View, Detail: View>: View {
    private let items: [Item]
    private let columns: Int
    private let spacing: CGFloat
    private let cornerRadius: CGFloat
    private let thumbnail: (Item) -> Thumbnail
    private let detail: (Item) -> Detail

    @State private var selected: Item.ID?
    @Namespace private var namespace
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - items: The items to show.
    ///   - columns: Number of grid columns.
    ///   - spacing: Gap between cells, in points.
    ///   - cornerRadius: Corner radius of thumbnails and the detail card.
    ///   - thumbnail: The small cell for an item. Give it a background so the
    ///     morph has something to stretch.
    ///   - detail: The expanded card for an item.
    public init(
        _ items: [Item],
        columns: Int = 3,
        spacing: CGFloat = 10,
        cornerRadius: CGFloat = 14,
        @ViewBuilder thumbnail: @escaping (Item) -> Thumbnail,
        @ViewBuilder detail: @escaping (Item) -> Detail
    ) {
        self.items = items
        self.columns = columns
        self.spacing = spacing
        self.cornerRadius = cornerRadius
        self.thumbnail = thumbnail
        self.detail = detail
    }

    public var body: some View {
        // Reduce Motion: cross-fade in place of the morph.
        let morph: MatchedGeometryProperties = reduceMotion ? [] : .frame
        ZStack {
            LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: spacing), count: max(1, columns)), spacing: spacing) {
                ForEach(items) { item in
                    ZStack {
                        if selected != item.id {
                            Button { open(item.id) } label: {
                                thumbnail(item)
                                    .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
                            }
                            .buttonStyle(.plain)
                            .matchedGeometryEffect(id: item.id, in: namespace, properties: morph)
                            .transition(.opacity.animation(.easeInOut(duration: 0.15)))
                        } else {
                            // Keep the slot so the grid doesn't reflow.
                            thumbnail(item).hidden()
                        }
                    }
                }
            }
            .opacity(selected == nil ? 1 : 0.3)

            if let id = selected, let item = items.first(where: { $0.id == id }) {
                Button { close() } label: {
                    detail(item)
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                        .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
                }
                .buttonStyle(.plain)
                .matchedGeometryEffect(id: id, in: namespace, properties: morph)
                .transition(.opacity.animation(.easeInOut(duration: 0.15)))
                .zIndex(1)
            }
        }
    }

    private func open(_ id: Item.ID) {
        withAnimation(reduceMotion ? .easeInOut(duration: 0.25) : .spring(response: 0.45, dampingFraction: 0.82)) {
            selected = id
        }
    }

    private func close() {
        withAnimation(reduceMotion ? .easeInOut(duration: 0.25) : .spring(response: 0.4, dampingFraction: 0.85)) {
            selected = nil
        }
    }
}

#Preview {
    struct Tile: Identifiable {
        let id: Int
        let color: Color
    }
    let tiles = [Color.pink, .orange, .teal, .indigo, .mint, .purple].enumerated().map { Tile(id: $0.offset, color: $0.element) }
    return HeroGrid(tiles) { tile in
        tile.color.frame(height: 70)
    } detail: { tile in
        tile.color.overlay(Text("Tile \(tile.id + 1)").font(.title.bold()).foregroundStyle(.white))
    }
    .frame(width: 300, height: 160)
    .padding()
}
```

---

## Spotlight Card (`spotlight-card`)

A soft glow follows the pointer across the card, like a torch.

- API: `.spotlight()`
- Component: Card & Group
- Trigger: Hover / Drag
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/Spotlight.swift
- Files: Sources/MotionLibrary/Card/Spotlight.swift

### Usage

```swift
FeatureCard()
    .background(.black, in: RoundedRectangle(cornerRadius: 20))
    .spotlight()

PricingCard()
    .spotlight(color: .purple.opacity(0.4), radius: 160)
```

### How it works

Create a SwiftUI .spotlight(color:radius:) view modifier (iOS 17+). Track the pointer with .onContinuousHover(coordinateSpace: .local) and a finger with a simultaneous DragGesture(minimumDistance: 0); store the location in @State (nil when the pointer leaves or the drag ends) and remember the last location so the glow can fade out where it was. In an overlay GeometryReader, draw a RadialGradient from color (white 35 %) to clear, centred at the location converted to a UnitPoint, endRadius = radius (120 pt), and mask it with the content so it only lights the card. Move with .interactiveSpring(response: 0.2, dampingFraction: 0.9) and fade out with easeOut(0.35 s). Disable hit testing on the glow. With Reduce Motion on, move the glow without animation.

### Source

```swift
import SwiftUI

/// A soft glow that follows the pointer (or a dragging finger) across the
/// view, like a torch shone on the card. Fades out when the pointer leaves.
struct SpotlightModifier: ViewModifier {
    var color: Color
    var radius: CGFloat

    @State private var location: CGPoint?
    @State private var lastLocation: CGPoint = .zero
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        content
            .overlay {
                GeometryReader { geometry in
                    let size = geometry.size
                    let point = location ?? lastLocation
                    RadialGradient(
                        colors: [color, color.opacity(0)],
                        center: UnitPoint(x: size.width > 0 ? point.x / size.width : 0.5,
                                          y: size.height > 0 ? point.y / size.height : 0.5),
                        startRadius: 0,
                        endRadius: radius
                    )
                    .opacity(location == nil ? 0 : 1)
                }
                .mask { content }
                .allowsHitTesting(false)
                .accessibilityHidden(true)
            }
            .onContinuousHover(coordinateSpace: .local) { phase in
                switch phase {
                case let .active(point): move(to: point)
                case .ended: hide()
                }
            }
            .simultaneousGesture(
                DragGesture(minimumDistance: 0, coordinateSpace: .local)
                    .onChanged { move(to: $0.location) }
                    .onEnded { _ in hide() }
            )
    }

    private func move(to point: CGPoint) {
        // Reduce Motion: no glide, the glow jumps with the pointer.
        withAnimation(reduceMotion ? nil : .interactiveSpring(response: 0.2, dampingFraction: 0.9)) {
            location = point
            lastLocation = point
        }
    }

    private func hide() {
        withAnimation(.easeOut(duration: 0.35)) { location = nil }
    }
}

public extension View {
    /// Shines a soft glow on the view where the pointer or finger is.
    ///
    /// ```swift
    /// FeatureCard()
    ///     .background(.black, in: RoundedRectangle(cornerRadius: 20))
    ///     .spotlight()
    /// ```
    ///
    /// - Parameters:
    ///   - color: Colour at the centre of the glow.
    ///   - radius: Radius of the glow, in points.
    func spotlight(color: Color = .white.opacity(0.35), radius: CGFloat = 120) -> some View {
        modifier(SpotlightModifier(color: color, radius: radius))
    }
}

#Preview {
    RoundedRectangle(cornerRadius: 20, style: .continuous)
        .fill(Color(white: 0.1))
        .frame(width: 260, height: 160)
        .overlay(Text("Hover me").font(.headline).foregroundStyle(.white))
        .spotlight()
        .padding()
}
```

---

## Magnifying Dock (`dock`)

Icons swell as the pointer passes over them, like the macOS Dock.

- API: `MagnifyingDock(items) { item in }`
- Component: Card & Group
- Trigger: Hover / Drag
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/MagnifyingDock.swift
- Files: Sources/MotionLibrary/Card/MagnifyingDock.swift

### Usage

```swift
MagnifyingDock(apps) { app in
    Image(app.icon).resizable()
}
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
```

### How it works

Create a generic SwiftUI MagnifyingDock (iOS 17+) over Identifiable items with size (40 pt), magnification (1.8), range (110 pt), spacing (8) and a content builder. Lay the items out in an HStack(alignment: .bottom) and frame each icon to size × scale(i). Put the HStack in a frame exactly as wide as the resting row (count × size + gaps) and size × magnification tall, aligned to the bottom, so the magnified row overflows it evenly; track the pointer x on that frame with .onContinuousHover and a simultaneous DragGesture(minimumDistance: 0). scale(i) = 1 + (magnification − 1) × cos(d / range × π/2) where d is the distance from the pointer to the icon's resting centre (i × (size + spacing) + size/2), or 1 beyond range. Animate pointer changes with .interactiveSpring(response: 0.2) and the release with .spring(response: 0.35, dampingFraction: 0.75). Add horizontal padding for the overflow. With Reduce Motion on, don't magnify.

### Source

```swift
import SwiftUI

/// A row of icons that swell as the pointer (or a dragging finger) passes
/// over them, like the macOS Dock. Neighbours grow a little too.
public struct MagnifyingDock<Item: Identifiable, Content: View>: View {
    private let items: [Item]
    private let size: CGFloat
    private let magnification: CGFloat
    private let range: CGFloat
    private let spacing: CGFloat
    private let content: (Item) -> Content

    @State private var pointerX: CGFloat?
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - items: The items to show.
    ///   - size: Resting icon size, in points.
    ///   - magnification: Scale of the icon right under the pointer.
    ///   - range: Distance in points over which neighbours still grow.
    ///   - spacing: Gap between icons.
    ///   - content: The icon for an item; it is sized to fill a square.
    public init(
        _ items: [Item],
        size: CGFloat = 40,
        magnification: CGFloat = 1.8,
        range: CGFloat = 110,
        spacing: CGFloat = 8,
        @ViewBuilder content: @escaping (Item) -> Content
    ) {
        self.items = items
        self.size = size
        self.magnification = magnification
        self.range = range
        self.spacing = spacing
        self.content = content
    }

    public var body: some View {
        HStack(alignment: .bottom, spacing: spacing) {
            ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
                let side = size * scale(for: index)
                content(item)
                    .frame(width: side, height: side)
            }
        }
        // A frame the size of the resting row: the magnified row overflows it
        // evenly on both sides, and the pointer is measured in resting
        // coordinates, so the icons don't run away from it.
        .frame(width: restingWidth, height: size * magnification, alignment: .bottom)
        .contentShape(Rectangle())
        .onContinuousHover(coordinateSpace: .local) { phase in
            switch phase {
            case let .active(point): track(point.x)
            case .ended: track(nil)
            }
        }
        .simultaneousGesture(
            DragGesture(minimumDistance: 0, coordinateSpace: .local)
                .onChanged { track($0.location.x) }
                .onEnded { _ in track(nil) }
        )
        .padding(.horizontal, size * (magnification - 1) + spacing)
        .padding(.vertical, 6)
    }

    private var restingWidth: CGFloat {
        CGFloat(items.count) * size + CGFloat(max(0, items.count - 1)) * spacing
    }

    /// Scale of the icon at `index`: a cosine falloff of the distance between
    /// its resting centre and the pointer.
    private func scale(for index: Int) -> CGFloat {
        guard let pointerX, !reduceMotion else { return 1 }
        let restingCentre = CGFloat(index) * (size + spacing) + size / 2
        let distance = abs(pointerX - restingCentre)
        guard distance < range else { return 1 }
        let falloff = cos(distance / range * .pi / 2)
        return 1 + (magnification - 1) * falloff
    }

    private func track(_ x: CGFloat?) {
        withAnimation(x == nil ? .spring(response: 0.35, dampingFraction: 0.75) : .interactiveSpring(response: 0.2, dampingFraction: 0.85)) {
            pointerX = x
        }
    }
}

#Preview {
    struct App: Identifiable {
        let id: String
        let color: Color
    }
    let apps = [("message.fill", Color.green), ("safari.fill", .blue), ("music.note", .pink), ("photo.fill", .orange), ("gearshape.fill", .gray)]
        .map { App(id: $0.0, color: $0.1) }
    return MagnifyingDock(apps) { app in
        RoundedRectangle(cornerRadius: 10, style: .continuous)
            .fill(app.color.gradient)
            .overlay(Image(systemName: app.id).foregroundStyle(.white))
    }
    .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
    .padding()
}
```

---

## Jiggle (`jiggle`)

The home-screen edit-mode wiggle, each item slightly out of step.

- API: `.jiggle(isActive:)`
- Component: Card & Group
- Trigger: Loop / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/Jiggle.swift
- Files: Sources/MotionLibrary/Card/Jiggle.swift

### Usage

```swift
ForEach(apps) { app in
    AppIcon(app)
        .jiggle(isActive: isEditing)
}
```

### How it works

Create a SwiftUI .jiggle(isActive:angle:) view modifier (iOS 17+) that wiggles a view like an app icon in edit mode. Give each instance a random @State phase (0–1) and rate (0.9–1.1) so neighbours never move in lockstep. In TimelineView(.animation(paused: !isActive)), let t = (time × rate + phase) × 2π × 3.6, rotate by sin(t) × angle (2.5°) and offset y by cos(t/2) × 0.8 pt. When inactive, return to 0 with a 0.15 s ease. With Reduce Motion on, don't move.

### Source

```swift
import SwiftUI

/// The home-screen "edit mode" wiggle: a quick, slightly irregular rotation
/// and bob. Each view gets its own phase so a grid never moves in lockstep.
struct JiggleModifier: ViewModifier {
    var isActive: Bool
    var angle: Double

    @State private var phase = Double.random(in: 0..<1)
    @State private var rate = Double.random(in: 0.9..<1.1)
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        // Reduce Motion: stay still.
        let running = isActive && !reduceMotion
        TimelineView(.animation(paused: !running)) { timeline in
            let t = (timeline.date.timeIntervalSinceReferenceDate * rate + phase) * 2 * .pi * 3.6
            content
                .rotationEffect(.degrees(running ? sin(t) * angle : 0))
                .offset(y: running ? CGFloat(cos(t * 0.5)) * 0.8 : 0)
                .animation(.easeOut(duration: 0.15), value: running)
        }
    }
}

public extension View {
    /// Wiggles the view like an app icon in edit mode while `isActive` is true.
    ///
    /// ```swift
    /// ForEach(apps) { app in
    ///     AppIcon(app)
    ///         .jiggle(isActive: isEditing)
    /// }
    /// ```
    ///
    /// - Parameter angle: Peak rotation, in degrees.
    func jiggle(isActive: Bool = true, angle: Double = 2.5) -> some View {
        modifier(JiggleModifier(isActive: isActive, angle: angle))
    }
}

#Preview {
    HStack(spacing: 16) {
        ForEach([Color.blue, .green, .orange, .pink], id: \.self) { color in
            RoundedRectangle(cornerRadius: 12, style: .continuous)
                .fill(color.gradient)
                .frame(width: 54, height: 54)
                .jiggle()
        }
    }
    .padding()
}
```

---

## Fan Stack (`fan-stack`)

A pile of cards fans out like a hand of playing cards, and gathers back.

- API: `FanStack(items, isSpread:) { item in }`
- Component: Card & Group
- Trigger: Tap / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/FanStack.swift
- Files: Sources/MotionLibrary/Card/FanStack.swift

### Usage

```swift
FanStack(cards, isSpread: isSpread) { card in
    CardView(card).frame(width: 80, height: 120)
}
.onTapGesture { isSpread.toggle() }
```

### How it works

Create a generic SwiftUI FanStack (iOS 17+) over Identifiable items with isSpread, angle (10°) and spacing (34 pt). Draw the cards in a ZStack, bottom first. For card i, let c = i − (count − 1)/2. Fanned: rotate by c × angle around the bottom anchor, offset x by c × spacing and y by c² × 4 pt so the outer cards dip along an arc. Stacked: rotate by c × 1.5° around the centre and offset y by −2 pt × i for a neat pile. Animate each card with .spring(response: 0.45, dampingFraction: 0.7).delay(i × 0.03) on isSpread so they follow each other. With Reduce Motion on, use a 0.25 s ease without the stagger.

### Source

```swift
import SwiftUI

/// A stack of cards that fans out like a hand of playing cards, and gathers
/// back into a neat pile. Cards follow each other with a short stagger.
public struct FanStack<Item: Identifiable, Content: View>: View {
    private let items: [Item]
    private let isSpread: Bool
    private let angle: Double
    private let spacing: CGFloat
    private let content: (Item) -> Content

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - items: The cards, bottom first.
    ///   - isSpread: Fanned out (true) or stacked (false).
    ///   - angle: Rotation between neighbouring cards when fanned, in degrees.
    ///   - spacing: Horizontal distance between neighbouring cards when fanned.
    ///   - content: The card for an item.
    public init(
        _ items: [Item],
        isSpread: Bool,
        angle: Double = 10,
        spacing: CGFloat = 34,
        @ViewBuilder content: @escaping (Item) -> Content
    ) {
        self.items = items
        self.isSpread = isSpread
        self.angle = angle
        self.spacing = spacing
        self.content = content
    }

    public var body: some View {
        ZStack {
            ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
                let centred = Double(index) - Double(items.count - 1) / 2
                content(item)
                    .rotationEffect(
                        .degrees(isSpread ? centred * angle : centred * 1.5),
                        anchor: isSpread ? .bottom : .center
                    )
                    .offset(
                        x: isSpread ? CGFloat(centred) * spacing : 0,
                        // The outer cards dip, following the arc of the fan.
                        y: isSpread ? CGFloat(abs(centred) * abs(centred)) * 4 : CGFloat(-index) * 2
                    )
                    .animation(
                        reduceMotion
                            ? .easeInOut(duration: 0.25)
                            : .spring(response: 0.45, dampingFraction: 0.7).delay(Double(index) * 0.03),
                        value: isSpread
                    )
            }
        }
    }
}

#Preview {
    struct Card: Identifiable {
        let id: Int
        let color: Color
    }
    struct Demo: View {
        @State private var spread = false
        let cards = [Color.pink, .orange, .yellow, .mint, .blue].enumerated().map { Card(id: $0.offset, color: $0.element) }
        var body: some View {
            FanStack(cards, isSpread: spread) { card in
                RoundedRectangle(cornerRadius: 10).fill(card.color.gradient).frame(width: 60, height: 86)
            }
            .onTapGesture { spread.toggle() }
        }
    }
    return Demo().padding(60)
}
```

---

## Scratch Card (`scratch-card`)

Rub away a scratch-off cover to reveal what's underneath.

- API: `.scratchToReveal(_:onReveal:)`
- Component: Card & Group
- Trigger: Drag
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/ScratchCard.swift
- Files: Sources/MotionLibrary/Card/ScratchCard.swift

### Usage

```swift
PrizeView()
    .scratchToReveal(Color.gray) {
        didReveal = true
    }
```

### How it works

Create a SwiftUI .scratchToReveal(_ cover:lineWidth:threshold:onReveal:) view modifier (iOS 17+). Overlay a Rectangle filled with the cover style (with a faint "hand.draw.fill" hint until the first stroke) and mask it with a compositingGroup of a full Rectangle plus a stroked Path of every finger stroke (lineWidth 34, round caps and joins) using .blendMode(.destinationOut), so strokes punch holes in the cover. A DragGesture(minimumDistance: 0) starts a new stroke when translation is zero and appends locations otherwise. Track coverage on a 12 × 12 grid: mark every cell within lineWidth / 2 of each point; once 55 % of cells are marked, fade the cover out over 0.4 s, disable its hit testing and call onReveal once. Give the view a new .id to reset it. Accessibility: a button whose action reveals.

### Source

```swift
import SwiftUI

/// Covers the view with a scratch-off layer. Dragging a finger erases the
/// cover along its path; once enough of it is scratched away, the rest
/// fades out and `onReveal` runs.
struct ScratchModifier<Cover: ShapeStyle>: ViewModifier {
    var cover: Cover
    var lineWidth: CGFloat
    var threshold: Double
    var onReveal: () -> Void

    @State private var strokes: [[CGPoint]] = []
    @State private var scratched: Set<Int> = []
    @State private var isRevealed = false

    /// The cover is split into a grid to estimate how much has been scratched.
    private let grid = 12

    func body(content: Content) -> some View {
        content.overlay {
            GeometryReader { geometry in
                Rectangle()
                    .fill(cover)
                    .overlay {
                        Image(systemName: "hand.draw.fill")
                            .font(.title)
                            .foregroundStyle(.white.opacity(0.7))
                            .opacity(strokes.isEmpty ? 1 : 0)
                            .animation(.easeOut(duration: 0.2), value: strokes.isEmpty)
                    }
                    .mask {
                        ZStack {
                            Rectangle()
                            ScratchPath(strokes: strokes)
                                .stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round))
                                .blendMode(.destinationOut)
                        }
                        .compositingGroup()
                    }
                    .opacity(isRevealed ? 0 : 1)
                    .contentShape(Rectangle())
                    .gesture(
                        DragGesture(minimumDistance: 0)
                            .onChanged { value in
                                guard !isRevealed else { return }
                                if value.translation == .zero || strokes.isEmpty {
                                    strokes.append([value.location])
                                } else {
                                    strokes[strokes.count - 1].append(value.location)
                                }
                                mark(value.location, in: geometry.size)
                            }
                    )
                    .allowsHitTesting(!isRevealed)
            }
            .accessibilityElement()
            .accessibilityLabel("Scratch card")
            .accessibilityAddTraits(.isButton)
            .accessibilityAction { reveal() }
            .accessibilityHidden(isRevealed)
        }
    }

    /// Marks every grid cell the brush touches at `point`.
    private func mark(_ point: CGPoint, in size: CGSize) {
        guard size.width > 0, size.height > 0 else { return }
        let cellW = size.width / CGFloat(grid), cellH = size.height / CGFloat(grid)
        let r = lineWidth / 2
        let minC = max(0, Int((point.x - r) / cellW)), maxC = min(grid - 1, Int((point.x + r) / cellW))
        let minR = max(0, Int((point.y - r) / cellH)), maxR = min(grid - 1, Int((point.y + r) / cellH))
        guard minC <= maxC, minR <= maxR else { return }
        for row in minR...maxR {
            for column in minC...maxC {
                scratched.insert(row * grid + column)
            }
        }
        if Double(scratched.count) / Double(grid * grid) >= threshold {
            reveal()
        }
    }

    private func reveal() {
        guard !isRevealed else { return }
        withAnimation(.easeOut(duration: 0.4)) { isRevealed = true }
        onReveal()
    }
}

struct ScratchPath: Shape {
    var strokes: [[CGPoint]]

    nonisolated func path(in rect: CGRect) -> Path {
        var path = Path()
        for stroke in strokes {
            guard let first = stroke.first else { continue }
            path.move(to: first)
            // A single tap still leaves a round dot.
            path.addLine(to: stroke.count > 1 ? stroke[1] : CGPoint(x: first.x + 0.1, y: first.y))
            if stroke.count > 2 { path.addLines(Array(stroke.dropFirst())) }
        }
        return path
    }
}

public extension View {
    /// Hides the view under a scratch-off cover that the user rubs away.
    ///
    /// ```swift
    /// PrizeView()
    ///     .scratchToReveal(LinearGradient(colors: [.gray, .secondary], startPoint: .top, endPoint: .bottom)) {
    ///         didReveal = true
    ///     }
    /// ```
    ///
    /// Give the view a new `.id` to reset the cover.
    ///
    /// - Parameters:
    ///   - cover: The scratch-off layer's fill.
    ///   - lineWidth: Width of the "coin", in points.
    ///   - threshold: Fraction of the cover to scratch before it clears itself.
    ///   - onReveal: Called once when the cover clears.
    func scratchToReveal<Cover: ShapeStyle>(
        _ cover: Cover = Color.gray,
        lineWidth: CGFloat = 34,
        threshold: Double = 0.55,
        onReveal: @escaping () -> Void = {}
    ) -> some View {
        modifier(ScratchModifier(cover: cover, lineWidth: lineWidth, threshold: threshold, onReveal: onReveal))
    }
}

#Preview {
    VStack(spacing: 6) {
        Text("🎉").font(.system(size: 44))
        Text("You won!").font(.headline)
    }
    .frame(width: 220, height: 140)
    .background(Color.yellow.opacity(0.3))
    .scratchToReveal(LinearGradient(colors: [Color(white: 0.62), Color(white: 0.78)], startPoint: .topLeading, endPoint: .bottomTrailing))
    .clipShape(RoundedRectangle(cornerRadius: 16))
    .padding()
}
```

---

## Stacking Cards (`stacking-cards`)

Cards stick at the top while scrolling and shrink as the next one covers them.

- API: `.stackingCard()`
- Component: Card & Group
- Trigger: Scroll
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/StackingCards.swift
- Files: Sources/MotionLibrary/Card/StackingCards.swift

### Usage

```swift
ScrollView {
    VStack(spacing: 16) {
        ForEach(cards) { card in
            CardView(card).stackingCard()
        }
    }
}
```

### How it works

Create a SwiftUI .stackingCard(top:scale:) view modifier (iOS 17+) for cards in a VStack inside a vertical ScrollView, built with visualEffect so it needs no layout passes. From proxy.frame(in: .scrollView): overshoot = max(0, top − minY) (top 16 pt), covered = min(1, overshoot / height), gone = clamp(overshoot / height − 1, 0, 1). Apply offset(y: overshoot) so the card sticks at the top, scaleEffect(1 − (1 − scale) × covered, anchor: .top) with scale 0.88, brightness(−0.25 × covered) and opacity(1 − gone). Later cards naturally draw on top in the VStack, so they slide over the pinned ones like a wallet. With Reduce Motion on, skip the scaling.

### Source

```swift
import SwiftUI

/// While scrolling, each card sticks at the top of the scroll view, then
/// shrinks and dims as the next card slides over it, so the cards pile up
/// like a deck (Apple Wallet style). Uses `visualEffect`, so it costs no
/// layout passes.
struct StackingCardModifier: ViewModifier {
    var top: CGFloat
    var scale: CGFloat

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        let top = top, scale = scale, reduceMotion = reduceMotion
        return content.visualEffect { effect, proxy in
            let frame = proxy.frame(in: .scrollView)
            let height = max(1, frame.height)
            // How far the card has scrolled past its pin line.
            let overshoot = max(0, top - frame.minY)
            let covered = min(1, overshoot / height)
            // Once fully covered, fade it so the pile stays tidy.
            let gone = min(1, max(0, overshoot / height - 1))
            return effect
                .offset(y: overshoot)
                .scaleEffect(reduceMotion ? 1 : 1 - (1 - scale) * covered, anchor: .top)
                .brightness(-0.25 * covered)
                .opacity(1 - gone)
        }
    }
}

public extension View {
    /// Pins the card at the top of its scroll view and shrinks it as the
    /// next card slides over it. Apply to every card in a `VStack` inside a
    /// vertical `ScrollView`.
    ///
    /// ```swift
    /// ScrollView {
    ///     VStack(spacing: 16) {
    ///         ForEach(cards) { card in
    ///             CardView(card).stackingCard()
    ///         }
    ///     }
    /// }
    /// ```
    ///
    /// - Parameters:
    ///   - top: Distance from the top of the scroll view where cards stick.
    ///   - scale: How small a fully covered card gets.
    func stackingCard(top: CGFloat = 16, scale: CGFloat = 0.88) -> some View {
        modifier(StackingCardModifier(top: top, scale: scale))
    }
}

#Preview {
    ScrollView {
        VStack(spacing: 16) {
            ForEach([Color.pink, .orange, .teal, .indigo, .mint, .purple], id: \.self) { color in
                RoundedRectangle(cornerRadius: 20)
                    .fill(color.gradient)
                    .frame(height: 180)
                    .stackingCard()
            }
        }
        .padding()
        .padding(.bottom, 300)
    }
}
```

---

## Deck Shuffle (`deck-shuffle`)

A deck splits in two, mixes, and springs back into a messy stack.

- API: `DeckShuffle(items, trigger:) { item in }`
- Component: Card & Group
- Trigger: Value change / Tap
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/DeckShuffle.swift
- Files: Sources/MotionLibrary/Card/DeckShuffle.swift

### Usage

```swift
DeckShuffle(cards, trigger: shuffles) { card in
    CardFace(card)
}
.onTapGesture { shuffles += 1 }
```

### How it works

Create a generic SwiftUI DeckShuffle(_ items:trigger:spread:content:) view (iOS 17+) for Identifiable items. Keep the order as @State [ID]. Draw the cards in a ZStack with zIndex = stack position, offset y = −2 pt × position and a small hand-made tilt per position (0, −2.5, 2, −1, 3, −2, 1.5, −3°). When trigger changes: animate isSplit = true with .easeOut(0.28 s), which moves even positions to x −spread (70 pt) rotated −8° and odd ones to +spread rotated +8°; after 0.3 s, in one .spring(duration: 0.55, bounce: 0.3) animation, shuffle the order and set isSplit = false, so cards change sides and visibly cross over as they come together. With Reduce Motion on, just re-order with a 0.3 s ease.

### Source

```swift
import SwiftUI

/// A small deck of cards that riffle-shuffles every time `trigger` changes:
/// the deck splits into two halves that slide apart, the order is mixed, and
/// the cards spring back together into a slightly messy stack.
public struct DeckShuffle<Data: RandomAccessCollection, Trigger: Equatable, Content: View>: View
where Data.Element: Identifiable {
    private let items: Data
    private let trigger: Trigger
    private let spread: CGFloat
    private let content: (Data.Element) -> Content

    @State private var order: [Data.Element.ID] = []
    @State private var isSplit = false
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - items: The cards, bottom to top.
    ///   - trigger: Shuffles whenever it changes.
    ///   - spread: How far each half slides out, in points.
    ///   - content: Draws one card.
    public init(_ items: Data, trigger: Trigger, spread: CGFloat = 70, @ViewBuilder content: @escaping (Data.Element) -> Content) {
        self.items = items
        self.trigger = trigger
        self.spread = spread
        self.content = content
    }

    public var body: some View {
        let ids = order.isEmpty ? items.map(\.id) : order
        ZStack {
            ForEach(Array(items)) { item in
                let position = ids.firstIndex(of: item.id) ?? 0
                let tilt = Self.tilt(for: position)
                content(item)
                    .rotationEffect(.degrees(isSplit ? (position.isMultiple(of: 2) ? -8 : 8) : tilt))
                    .offset(
                        x: isSplit ? (position.isMultiple(of: 2) ? -spread : spread) : 0,
                        y: isSplit ? 0 : -CGFloat(position) * 2
                    )
                    .zIndex(Double(position))
            }
        }
        .onAppear { if order.isEmpty { order = items.map(\.id) } }
        .onChange(of: trigger) { shuffle() }
    }

    /// A small, stable tilt per stack position so the deck looks hand-made.
    private static func tilt(for position: Int) -> Double {
        let values: [Double] = [0, -2.5, 2, -1, 3, -2, 1.5, -3]
        return values[position % values.count]
    }

    private func shuffle() {
        let shuffled = (order.isEmpty ? items.map(\.id) : order).shuffled()
        if reduceMotion {
            withAnimation(.easeInOut(duration: 0.3)) { order = shuffled }
            return
        }
        withAnimation(.easeOut(duration: 0.28)) { isSplit = true }
        Task { @MainActor in
            try? await Task.sleep(for: .seconds(0.3))
            // Re-ordering while apart changes which half each card is in, so
            // cards visibly cross over as they come back together.
            withAnimation(.spring(duration: 0.55, bounce: 0.3)) {
                order = shuffled
                isSplit = false
            }
        }
    }
}

#Preview {
    struct Card: Identifiable { let id: Int; let color: Color }
    struct Demo: View {
        @State private var shuffles = 0
        let cards = [Card(id: 0, color: .pink), Card(id: 1, color: .orange), Card(id: 2, color: .teal), Card(id: 3, color: .indigo), Card(id: 4, color: .mint)]
        var body: some View {
            DeckShuffle(cards, trigger: shuffles) { card in
                RoundedRectangle(cornerRadius: 12).fill(card.color.gradient).frame(width: 80, height: 112)
            }
            .onTapGesture { shuffles += 1 }
            .padding(80)
        }
    }
    return Demo()
}
```

---

## Looping Carousel (`looping-carousel`)

A paging carousel that wraps around forever, with peeking neighbours.

- API: `LoopingCarousel(items) { item in }`
- Component: Card & Group
- Trigger: Drag / Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/LoopingCarousel.swift
- Files: Sources/MotionLibrary/Card/LoopingCarousel.swift

### Usage

```swift
LoopingCarousel(banners, autoplay: 3) { banner in
    BannerCard(banner)
}
.frame(height: 200)
```

### How it works

Create a SwiftUI LoopingCarousel(_ items:spacing:peek:autoplay:content:) view (iOS 17+). Inside a horizontal ScrollView, a LazyHStack(spacing: 12) lays the items out three times (index 0..<3n, item = items[index % n]), each .containerRelativeFrame(.horizontal) with .scrollTransition(.interactive) scaling to 1 − |phase| × 0.12 and fading to 1 − |phase| × 0.4, and .id(index). Use .scrollTargetLayout(), .scrollTargetBehavior(.viewAligned), .contentMargins(.horizontal, peek + spacing) (peek 36 pt) and .scrollPosition(id:), starting at n (the middle copy). In .task(id: position), wait 0.45 s for scrolling to settle; if the position is outside the middle copy, jump to n + position % n inside a transaction with animations disabled, which looks identical. For autoplay, loop: sleep autoplay seconds, then position += 1 with .spring(duration: 0.6, bounce: 0.15). With Reduce Motion on, skip the scaling.

### Source

```swift
import SwiftUI

/// A horizontally paging carousel that never ends: it wraps from the last
/// card back to the first. Neighbours peek in from the sides, shrunk and
/// faded, and it can advance on its own.
///
/// The items are laid out three times; after scrolling settles, the
/// position silently jumps back to the middle copy.
public struct LoopingCarousel<Data: RandomAccessCollection, Content: View>: View where Data.Index == Int {
    private let items: Data
    private let spacing: CGFloat
    private let peek: CGFloat
    private let autoplay: Double?
    private let content: (Data.Element) -> Content

    @State private var position: Int?
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - items: The cards, in order.
    ///   - spacing: Gap between cards, in points.
    ///   - peek: How much of each neighbour shows at the sides, in points.
    ///   - autoplay: Seconds between automatic advances; `nil` to disable.
    ///   - content: Draws one card.
    public init(_ items: Data, spacing: CGFloat = 12, peek: CGFloat = 36, autoplay: Double? = nil, @ViewBuilder content: @escaping (Data.Element) -> Content) {
        self.items = items
        self.spacing = spacing
        self.peek = peek
        self.autoplay = autoplay
        self.content = content
    }

    public var body: some View {
        let count = items.count
        let reduceMotion = reduceMotion
        ScrollView(.horizontal) {
            LazyHStack(spacing: spacing) {
                ForEach(0..<(count * 3), id: \.self) { index in
                    content(items[items.startIndex + index % max(1, count)])
                        .containerRelativeFrame(.horizontal)
                        .scrollTransition(.interactive, axis: .horizontal) { view, phase in
                            view
                                .scaleEffect(reduceMotion ? 1 : 1 - abs(phase.value) * 0.12)
                                .opacity(1 - abs(phase.value) * 0.4)
                        }
                        .id(index)
                }
            }
            .scrollTargetLayout()
        }
        .contentMargins(.horizontal, peek + spacing, for: .scrollContent)
        .scrollTargetBehavior(.viewAligned)
        .scrollIndicators(.hidden)
        .scrollPosition(id: $position)
        .onAppear { if position == nil { position = count } }
        .task(id: position) {
            // Once scrolling has settled, recentre on the middle copy.
            try? await Task.sleep(for: .seconds(0.45))
            guard count > 0, let current = position, current < count || current >= count * 2 else { return }
            var jump = Transaction()
            jump.disablesAnimations = true
            withTransaction(jump) { position = count + current % count }
        }
        .task(id: autoplay) {
            guard let autoplay, count > 1 else { return }
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(autoplay))
                guard !Task.isCancelled else { return }
                withAnimation(.spring(duration: 0.6, bounce: 0.15)) {
                    position = (position ?? count) + 1
                }
            }
        }
    }
}

#Preview {
    LoopingCarousel([Color.pink, .orange, .teal, .indigo], autoplay: 2) { color in
        RoundedRectangle(cornerRadius: 20).fill(color.gradient).frame(height: 180)
    }
    .frame(height: 200)
}
```

---

## Drag to Dismiss (`drag-to-dismiss`)

Fling a card away; it flies off with your throw or springs back.

- API: `.dragToDismiss(isPresented:)`
- Component: Card & Group
- Trigger: Drag
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/DragToDismiss.swift
- Files: Sources/MotionLibrary/Card/DragToDismiss.swift

### Usage

```swift
if showPhoto {
    PhotoView()
        .dragToDismiss(isPresented: $showPhoto)
}
```

### How it works

Create a SwiftUI .dragToDismiss(isPresented:threshold:) view modifier (iOS 17+). While dragging, offset the view by the translation, scale it to 1 − min(0.2, distance / 1200) and rotate it by translation.width / 25 degrees. On release, if the drag distance exceeds threshold (120 pt) or the predicted end translation exceeds 2.5 × threshold, fly it off along the predicted direction normalised to 700 pt with .easeOut(0.3 s) while fading out, then set isPresented = false and reset the offset without animation. Otherwise spring back with .spring(duration: 0.45, bounce: 0.4). Add an accessibility action "Dismiss". With Reduce Motion on, skip the scale and rotation.

### Source

```swift
import SwiftUI

/// Lets the user fling a view away. While dragging it follows the finger,
/// shrinking and tilting a little; released past a distance (or flicked fast
/// enough) it flies off in the throw direction and `isPresented` turns
/// false; otherwise it springs back.
struct DragToDismissModifier: ViewModifier {
    @Binding var isPresented: Bool
    var threshold: CGFloat

    @State private var drag: CGSize = .zero
    @State private var isLeaving = false
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        let distance = hypot(drag.width, drag.height)
        content
            .scaleEffect(reduceMotion ? 1 : 1 - min(0.2, distance / 1200))
            .rotationEffect(.degrees(reduceMotion ? 0 : Double(drag.width) / 25))
            .offset(drag)
            .opacity(isLeaving ? 0 : 1)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        guard !isLeaving else { return }
                        drag = value.translation
                    }
                    .onEnded { value in
                        guard !isLeaving else { return }
                        let predicted = value.predictedEndTranslation
                        let thrown = hypot(predicted.width, predicted.height) > threshold * 2.5
                        if hypot(value.translation.width, value.translation.height) > threshold || thrown {
                            dismiss(towards: predicted)
                        } else {
                            withAnimation(.spring(duration: 0.45, bounce: 0.4)) { drag = .zero }
                        }
                    }
            )
            .accessibilityAction(named: "Dismiss") { isPresented = false }
    }

    private func dismiss(towards predicted: CGSize) {
        let length = max(1, hypot(predicted.width, predicted.height))
        // Keep the throw direction but make sure it leaves the screen.
        let fly = CGSize(width: predicted.width / length * 700, height: predicted.height / length * 700)
        withAnimation(.easeOut(duration: 0.3)) {
            drag = fly
            isLeaving = true
        }
        Task { @MainActor in
            try? await Task.sleep(for: .seconds(0.3))
            isPresented = false
            var reset = Transaction()
            reset.disablesAnimations = true
            withTransaction(reset) {
                drag = .zero
                isLeaving = false
            }
        }
    }
}

public extension View {
    /// Lets the user drag the view away to dismiss it, with a spring back if
    /// they don't drag far enough.
    ///
    /// ```swift
    /// if showPhoto {
    ///     PhotoView()
    ///         .dragToDismiss(isPresented: $showPhoto)
    /// }
    /// ```
    ///
    /// - Parameters:
    ///   - isPresented: Set to `false` once the view has flown off.
    ///   - threshold: Drag distance, in points, that dismisses on release.
    func dragToDismiss(isPresented: Binding<Bool>, threshold: CGFloat = 120) -> some View {
        modifier(DragToDismissModifier(isPresented: isPresented, threshold: threshold))
    }
}

#Preview {
    struct Demo: View {
        @State private var shown = true
        var body: some View {
            ZStack {
                if shown {
                    RoundedRectangle(cornerRadius: 24)
                        .fill(Color.indigo.gradient)
                        .frame(width: 220, height: 300)
                        .dragToDismiss(isPresented: $shown)
                } else {
                    Button("Show again") { shown = true }
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
    }
    return Demo()
}
```

---

## Shrinking Header (`shrinking-header`)

The header sticks to the top and shrinks as content scrolls under it.

- API: `.shrinkingHeader()`
- Component: Card & Group
- Trigger: Scroll
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/ScrollHeaders.swift
- Files: Sources/MotionLibrary/Card/ScrollHeaders.swift

### Usage

```swift
ScrollView {
    VStack(spacing: 0) {
        ProfileHeader().shrinkingHeader()
        ForEach(posts) { PostRow($0) }
    }
}
```

### How it works

Create a SwiftUI .shrinkingHeader(minScale:) view modifier (iOS 17+) for the first view in a vertical ScrollView, built with visualEffect. From proxy.frame(in: .scrollView): scrolled = max(0, −minY), progress = min(1, scrolled / (height × (1 − minScale))). Apply scaleEffect(1 − (1 − minScale) × progress, anchor: .top) with minScale 0.6 first, then offset(y: scrolled) so it stays pinned at the top (offset first and the scale would shrink the offset too, letting the header drift away), and give the view .zIndex(1) so the content scrolls underneath it. With Reduce Motion on, pin it without scaling.

### Source

```swift
import SwiftUI

// Headers that react to the scroll position, driven by `visualEffect` so
// they cost no extra layout passes. Apply them to the first view inside a
// vertical `ScrollView`.

// MARK: - Shrinking header

struct ShrinkingHeaderModifier: ViewModifier {
    var minScale: CGFloat

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        let minScale = minScale, reduceMotion = reduceMotion
        return content
            .visualEffect { effect, proxy in
                let frame = proxy.frame(in: .scrollView)
                let scrolled = max(0, -frame.minY)
                // Shrinks over the distance it loses in height, then stays put.
                let range = max(1, frame.height * (1 - minScale))
                let progress = min(1, scrolled / range)
                // Scale first, then offset: the other way round the scale
                // would shrink the pinning offset too and the header would
                // drift off the top.
                return effect
                    .scaleEffect(reduceMotion ? 1 : 1 - (1 - minScale) * progress, anchor: .top)
                    .offset(y: scrolled)
            }
            // Stay above the content that scrolls underneath.
            .zIndex(1)
    }
}

// MARK: - Stretchy header

struct StretchyHeaderModifier: ViewModifier {
    var parallax: CGFloat

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        let parallax = reduceMotion ? 0 : parallax
        return content.visualEffect { effect, proxy in
            let frame = proxy.frame(in: .scrollView)
            let pull = max(0, frame.minY)
            let height = max(1, frame.height)
            return effect
                // Pulled down: grow from the bottom so it fills the gap.
                .scaleEffect(1 + pull / height, anchor: .bottom)
                // Scrolled up: lag behind the content for a parallax feel.
                .offset(y: min(0, frame.minY) * -parallax)
        }
    }
}

public extension View {
    /// Pins the header to the top of its scroll view and shrinks it as the
    /// content scrolls up underneath.
    ///
    /// ```swift
    /// ScrollView {
    ///     VStack(spacing: 0) {
    ///         ProfileHeader().shrinkingHeader()
    ///         ForEach(posts) { PostRow($0) }
    ///     }
    /// }
    /// ```
    ///
    /// - Parameter minScale: The header's scale once fully collapsed.
    func shrinkingHeader(minScale: CGFloat = 0.6) -> some View {
        modifier(ShrinkingHeaderModifier(minScale: minScale))
    }

    /// Makes a header image stretch when the scroll view is pulled down past
    /// the top, and drift with a parallax when scrolled up.
    ///
    /// ```swift
    /// ScrollView {
    ///     Image("cover")
    ///         .resizable()
    ///         .scaledToFill()
    ///         .frame(height: 260)
    ///         .clipped()
    ///         .stretchyHeader()
    ///     ArticleBody()
    /// }
    /// ```
    ///
    /// - Parameter parallax: How much slower than the content it moves when
    ///   scrolled up (0 = none, 0.5 = half speed).
    func stretchyHeader(parallax: CGFloat = 0.5) -> some View {
        modifier(StretchyHeaderModifier(parallax: parallax))
    }
}

#Preview {
    ScrollView {
        VStack(spacing: 0) {
            LinearGradient(colors: [.orange, .pink], startPoint: .top, endPoint: .bottom)
                .frame(height: 200)
                .overlay(Text("Stretch me").font(.title.bold()).foregroundStyle(.white))
                .stretchyHeader()
            ForEach(0..<20, id: \.self) { index in
                Text("Row \(index)")
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding()
                    .background(.background)
            }
        }
    }
}
```

---

## Stretchy Header (`stretchy-header`)

A header image stretches when pulled down and drifts with parallax when scrolled.

- API: `.stretchyHeader()`
- Component: Card & Group
- Trigger: Scroll
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/ScrollHeaders.swift
- Files: Sources/MotionLibrary/Card/ScrollHeaders.swift

### Usage

```swift
ScrollView {
    Image("cover")
        .resizable()
        .scaledToFill()
        .frame(height: 260)
        .clipped()
        .stretchyHeader()
    ArticleBody()
}
```

### How it works

Create a SwiftUI .stretchyHeader(parallax:) view modifier (iOS 17+) for a header image at the top of a vertical ScrollView, built with visualEffect. From proxy.frame(in: .scrollView): when pulled past the top (minY > 0), scaleEffect(1 + minY / height, anchor: .bottom) so it grows upward to fill the gap; when scrolled up (minY < 0), offset(y: minY × −parallax) with parallax 0.5 so it moves at half speed. With Reduce Motion on, skip the parallax.

### Source

```swift
import SwiftUI

// Headers that react to the scroll position, driven by `visualEffect` so
// they cost no extra layout passes. Apply them to the first view inside a
// vertical `ScrollView`.

// MARK: - Shrinking header

struct ShrinkingHeaderModifier: ViewModifier {
    var minScale: CGFloat

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        let minScale = minScale, reduceMotion = reduceMotion
        return content
            .visualEffect { effect, proxy in
                let frame = proxy.frame(in: .scrollView)
                let scrolled = max(0, -frame.minY)
                // Shrinks over the distance it loses in height, then stays put.
                let range = max(1, frame.height * (1 - minScale))
                let progress = min(1, scrolled / range)
                // Scale first, then offset: the other way round the scale
                // would shrink the pinning offset too and the header would
                // drift off the top.
                return effect
                    .scaleEffect(reduceMotion ? 1 : 1 - (1 - minScale) * progress, anchor: .top)
                    .offset(y: scrolled)
            }
            // Stay above the content that scrolls underneath.
            .zIndex(1)
    }
}

// MARK: - Stretchy header

struct StretchyHeaderModifier: ViewModifier {
    var parallax: CGFloat

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        let parallax = reduceMotion ? 0 : parallax
        return content.visualEffect { effect, proxy in
            let frame = proxy.frame(in: .scrollView)
            let pull = max(0, frame.minY)
            let height = max(1, frame.height)
            return effect
                // Pulled down: grow from the bottom so it fills the gap.
                .scaleEffect(1 + pull / height, anchor: .bottom)
                // Scrolled up: lag behind the content for a parallax feel.
                .offset(y: min(0, frame.minY) * -parallax)
        }
    }
}

public extension View {
    /// Pins the header to the top of its scroll view and shrinks it as the
    /// content scrolls up underneath.
    ///
    /// ```swift
    /// ScrollView {
    ///     VStack(spacing: 0) {
    ///         ProfileHeader().shrinkingHeader()
    ///         ForEach(posts) { PostRow($0) }
    ///     }
    /// }
    /// ```
    ///
    /// - Parameter minScale: The header's scale once fully collapsed.
    func shrinkingHeader(minScale: CGFloat = 0.6) -> some View {
        modifier(ShrinkingHeaderModifier(minScale: minScale))
    }

    /// Makes a header image stretch when the scroll view is pulled down past
    /// the top, and drift with a parallax when scrolled up.
    ///
    /// ```swift
    /// ScrollView {
    ///     Image("cover")
    ///         .resizable()
    ///         .scaledToFill()
    ///         .frame(height: 260)
    ///         .clipped()
    ///         .stretchyHeader()
    ///     ArticleBody()
    /// }
    /// ```
    ///
    /// - Parameter parallax: How much slower than the content it moves when
    ///   scrolled up (0 = none, 0.5 = half speed).
    func stretchyHeader(parallax: CGFloat = 0.5) -> some View {
        modifier(StretchyHeaderModifier(parallax: parallax))
    }
}

#Preview {
    ScrollView {
        VStack(spacing: 0) {
            LinearGradient(colors: [.orange, .pink], startPoint: .top, endPoint: .bottom)
                .frame(height: 200)
                .overlay(Text("Stretch me").font(.title.bold()).foregroundStyle(.white))
                .stretchyHeader()
            ForEach(0..<20, id: \.self) { index in
                Text("Row \(index)")
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding()
                    .background(.background)
            }
        }
    }
}
```

---

## Island Pill (`island-pill`)

A black pill springs open into a card and back, its contents blurring across.

- API: `IslandPill(isExpanded:) { } expanded: { }`
- Component: Card & Group
- Trigger: Tap / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/IslandPill.swift
- Files: Sources/MotionLibrary/Card/IslandPill.swift

### Usage

```swift
IslandPill(isExpanded: showsPlayer) {
    Image(systemName: "waveform")
} expanded: {
    NowPlayingView()
}
.onTapGesture { showsPlayer.toggle() }
```

### How it works

Create a SwiftUI IslandPill(isExpanded:compactSize:expandedSize:color:compact:expanded:) view (iOS 17+): a black shape that morphs between a pill (default 126 × 37, corner radius half its height) and a card (default 340 × 160, corner radius min(44, height / 2)), using a continuous RoundedRectangle as background and clip shape. Inside, a ZStack holds both contents: the compact one fades out while blurring to 8 pt and scaling to 1.6; the expanded one fades in from blur 10 pt and scale 0.6 (anchored top). Force the dark colour scheme inside. Animate every change of isExpanded with .spring(duration: 0.5, bounce: 0.32). With Reduce Motion on, skip the blur and scale and use .easeInOut(duration: 0.25).

### Source

```swift
import SwiftUI

/// A black pill that springs open into a larger rounded card and back, like a
/// live activity growing out of the top of the screen. The compact and
/// expanded contents cross-fade with a blur while the shape morphs.
///
/// ```swift
/// IslandPill(isExpanded: showsPlayer) {
///     Image(systemName: "waveform")
/// } expanded: {
///     NowPlayingView()
/// }
/// ```
public struct IslandPill<Compact: View, Expanded: View>: View {
    private let isExpanded: Bool
    private let compactSize: CGSize
    private let expandedSize: CGSize
    private let color: Color
    private let compact: Compact
    private let expanded: Expanded

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - isExpanded: Shows the expanded content when true. Changes animate.
    ///   - compactSize: Size of the closed pill.
    ///   - expandedSize: Size of the open card.
    ///   - color: Fill of the pill.
    ///   - compact: Content of the closed pill.
    ///   - expanded: Content of the open card.
    public init(
        isExpanded: Bool,
        compactSize: CGSize = CGSize(width: 126, height: 37),
        expandedSize: CGSize = CGSize(width: 340, height: 160),
        color: Color = .black,
        @ViewBuilder compact: () -> Compact,
        @ViewBuilder expanded: () -> Expanded
    ) {
        self.isExpanded = isExpanded
        self.compactSize = compactSize
        self.expandedSize = expandedSize
        self.color = color
        self.compact = compact()
        self.expanded = expanded()
    }

    public var body: some View {
        let size = isExpanded ? expandedSize : compactSize
        let radius = isExpanded ? min(44, expandedSize.height / 2) : compactSize.height / 2
        ZStack {
            compact
                .opacity(isExpanded ? 0 : 1)
                .blur(radius: isExpanded && !reduceMotion ? 8 : 0)
                .scaleEffect(isExpanded && !reduceMotion ? 1.6 : 1)
            expanded
                .opacity(isExpanded ? 1 : 0)
                .blur(radius: isExpanded || reduceMotion ? 0 : 10)
                .scaleEffect(isExpanded || reduceMotion ? 1 : 0.6, anchor: .top)
        }
        .frame(width: size.width, height: size.height)
        .background(color, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
        .clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
        .environment(\.colorScheme, .dark)
        .animation(reduceMotion ? .easeInOut(duration: 0.25) : .spring(duration: 0.5, bounce: 0.32), value: isExpanded)
    }
}

#Preview {
    struct Demo: View {
        @State private var open = false
        var body: some View {
            IslandPill(isExpanded: open) {
                HStack {
                    Image(systemName: "music.note")
                    Spacer()
                    Image(systemName: "waveform").foregroundStyle(.green)
                }
                .padding(.horizontal, 14)
                .foregroundStyle(.white)
            } expanded: {
                HStack(spacing: 14) {
                    RoundedRectangle(cornerRadius: 12).fill(.orange.gradient).frame(width: 64, height: 64)
                    VStack(alignment: .leading) {
                        Text("Now Playing").font(.headline)
                        Text("Motion Library").foregroundStyle(.secondary)
                    }
                    Spacer()
                }
                .padding(20)
                .foregroundStyle(.white)
            }
            .onTapGesture { open.toggle() }
            .frame(height: 220, alignment: .top)
        }
    }
    return Demo()
}
```

---

## Hover Lift (`hover-lift`)

Cards rise, grow and lean towards the pointer on iPad and Mac.

- API: `.hoverLift(isActive:)`
- Component: Card & Group
- Trigger: Hover
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/HoverLift.swift
- Files: Sources/MotionLibrary/Card/HoverLift.swift

### Usage

```swift
LazyVGrid(columns: columns) {
    ForEach(projects) { project in
        ProjectCard(project)
            .hoverLift()
    }
}
```

### How it works

Create a SwiftUI .hoverLift(scale:tilt:cornerRadius:) view modifier (iOS 17+) for pointer hover on iPad and Mac. Track the pointer with .onContinuousHover(coordinateSpace: .local) and the view size with a background GeometryReader. While hovered, scale to 1.05, offset y −4 and deepen the shadow (black 22 %, radius 18, y 12; at rest 8 %, radius 6, y 3), all with .spring(duration: 0.35, bounce: 0.3). Lean towards the pointer: with lean = location / size × 2 − 1, apply rotation3DEffect(tilt × lean.x around y) and (−tilt × lean.y around x), tilt 6°, perspective 0.6, animated with .interactiveSpring(duration: 0.25). With Reduce Motion on, don't move or scale; brighten by 0.05 instead. Add isActive (default false) to lift the view without a pointer too, for the focused or selected card. On visionOS, where apps can't see the gaze, use .contentShape(.hoverEffect, RoundedRectangle(cornerRadius:)) and the system .hoverEffect().

### Source

```swift
import SwiftUI

/// Lifts the view towards the pointer on hover (iPad trackpad or mouse, Mac):
/// it grows a little, rises, casts a deeper shadow and leans slightly
/// towards the pointer. On visionOS, where apps can't see the gaze, it uses
/// the system hover effect instead.
struct HoverLiftModifier: ViewModifier {
    var scale: CGFloat
    var tilt: Double
    var cornerRadius: CGFloat
    var isActive: Bool

    @State private var location: CGPoint?
    @State private var size: CGSize = .zero
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        #if os(visionOS)
        content
            .contentShape(.hoverEffect, RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
            .hoverEffect()
            .scaleEffect(isActive && !reduceMotion ? scale : 1)
            .animation(.spring(duration: 0.35, bounce: 0.3), value: isActive)
        #else
        let hovering = location != nil || isActive
        // -1…1 from the centre, for the lean.
        let lean = location.map { CGPoint(x: size.width > 0 ? $0.x / size.width * 2 - 1 : 0, y: size.height > 0 ? $0.y / size.height * 2 - 1 : 0) } ?? .zero
        let moves = !reduceMotion
        content
            .scaleEffect(hovering && moves ? scale : 1)
            .rotation3DEffect(.degrees(moves ? tilt * Double(lean.x) : 0), axis: (x: 0, y: 1, z: 0), perspective: 0.6)
            .rotation3DEffect(.degrees(moves ? -tilt * Double(lean.y) : 0), axis: (x: 1, y: 0, z: 0), perspective: 0.6)
            .offset(y: hovering && moves ? -4 : 0)
            .shadow(color: .black.opacity(hovering ? 0.22 : 0.08), radius: hovering ? 18 : 6, y: hovering ? 12 : 3)
            .brightness(hovering && !moves ? 0.05 : 0)
            .animation(.spring(duration: 0.35, bounce: 0.3), value: hovering)
            .animation(.interactiveSpring(duration: 0.25), value: lean)
            .background {
                GeometryReader { proxy in
                    Color.clear
                        .onAppear { size = proxy.size }
                        .onChange(of: proxy.size) { _, new in size = new }
                }
            }
            .onContinuousHover(coordinateSpace: .local) { phase in
                switch phase {
                case let .active(point): location = point
                case .ended: location = nil
                }
            }
        #endif
    }
}

public extension View {
    /// Lifts the view under the pointer: it scales up, rises, deepens its
    /// shadow and leans towards the pointer. For iPad pointers and the Mac;
    /// visionOS gets the system hover highlight.
    ///
    /// ```swift
    /// ProjectCard(project)
    ///     .hoverLift()
    /// ```
    ///
    /// - Parameters:
    ///   - scale: Scale while hovered.
    ///   - tilt: Maximum lean towards the pointer, in degrees.
    ///   - cornerRadius: Corner radius of the hover shape on visionOS.
    ///   - isActive: Also lifts the view while true, for example for the
    ///     focused or selected card (without the lean, which follows the pointer).
    func hoverLift(scale: CGFloat = 1.05, tilt: Double = 6, cornerRadius: CGFloat = 16, isActive: Bool = false) -> some View {
        modifier(HoverLiftModifier(scale: scale, tilt: tilt, cornerRadius: cornerRadius, isActive: isActive))
    }
}

#Preview {
    HStack(spacing: 20) {
        ForEach(0..<3) { index in
            RoundedRectangle(cornerRadius: 16, style: .continuous)
                .fill([Color.orange, .pink, .indigo][index].gradient)
                .frame(width: 90, height: 120)
                .hoverLift()
        }
    }
    .padding(40)
}
```

---

## Marquee Wall (`marquee-wall`)

A tilted wall of cards, columns scrolling endlessly up and down.

- API: `MarqueeWall(count:) { index in }`
- Component: Card & Group
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/MarqueeWall.swift
- Files: Sources/MotionLibrary/Card/MarqueeWall.swift

### Usage

```swift
MarqueeWall(count: shots.count, columns: 4) { index in
    Image(shots[index]).resizable().scaledToFill()
}
.frame(height: 320)
.clipped()
```

### How it works

Create a SwiftUI MarqueeWall(count:columns:itemSize:spacing:speed:angle:content:) view (iOS 17+), the scrolling showcase wall from landing pages. Each of the columns (default 4) shows perColumn = max(3, ceil(count / columns)) items twice in a VStack, cards of itemSize (110 × 140) with 14 pt continuous corners and 12 pt spacing, so the loop height is perColumn × (height + spacing). In TimelineView(.animation), distance = (time × speed × (1 + 0.15 × (column % 3))) mod loopHeight (24 pt/s × speed, a multiplier defaulting to 1); even columns offset by −distance (moving up), odd ones by distance − loopHeight (moving down). Lay the columns in an HStack, rotate the whole thing by −14° and let the caller clip it. Disable hit testing. With Reduce Motion on, pause the timeline.

### Source

```swift
import SwiftUI

/// A tilted wall of cards whose columns scroll endlessly in alternating
/// directions, the showcase backdrop of portfolio and landing pages.
///
/// ```swift
/// MarqueeWall(count: 12, columns: 4) { index in
///     Image("shot-\(index)").resizable().scaledToFill()
/// }
/// .frame(height: 300)
/// .clipped()
/// ```
public struct MarqueeWall<Content: View>: View {
    private let count: Int
    private let columns: Int
    private let itemSize: CGSize
    private let spacing: CGFloat
    private let speed: Double
    private let angle: Angle
    private let content: (Int) -> Content

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - count: Number of distinct items; they're shared out between the columns.
    ///   - columns: Number of columns.
    ///   - itemSize: Size of each card.
    ///   - spacing: Gap between cards.
    ///   - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast.
    ///   - angle: Rotation of the whole wall.
    ///   - content: Builds the card for an index in `0..<count`.
    public init(
        count: Int,
        columns: Int = 4,
        itemSize: CGSize = CGSize(width: 110, height: 140),
        spacing: CGFloat = 12,
        speed: Double = 1,
        angle: Angle = .degrees(-14),
        @ViewBuilder content: @escaping (Int) -> Content
    ) {
        self.count = max(1, count)
        self.columns = max(1, columns)
        self.itemSize = itemSize
        self.spacing = spacing
        self.speed = speed * 24
        self.angle = angle
        self.content = content
    }

    public var body: some View {
        // Each column shows its items twice over, so the loop is seamless.
        let perColumn = max(3, Int(ceil(Double(count) / Double(columns))))
        let loopHeight = CGFloat(perColumn) * (itemSize.height + spacing)
        TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in
            let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate
            HStack(alignment: .top, spacing: spacing) {
                ForEach(0..<columns, id: \.self) { column in
                    let distance = CGFloat((time * speed * (1 + Double(column % 3) * 0.15)).truncatingRemainder(dividingBy: Double(loopHeight)))
                    let offset = column.isMultiple(of: 2) ? -distance : distance - loopHeight
                    VStack(spacing: spacing) {
                        ForEach(0..<(perColumn * 2), id: \.self) { row in
                            content((column * perColumn + row % perColumn) % count)
                                .frame(width: itemSize.width, height: itemSize.height)
                                .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
                        }
                    }
                    .offset(y: offset)
                    .frame(height: loopHeight, alignment: .top)
                }
            }
            .rotationEffect(angle)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
        .allowsHitTesting(false)
    }
}

#Preview {
    let colors: [Color] = [.orange, .pink, .purple, .blue, .teal, .green, .yellow, .red]
    return MarqueeWall(count: 8, columns: 4, itemSize: CGSize(width: 80, height: 100)) { index in
        Rectangle().fill(colors[index].gradient)
    }
    .frame(width: 340, height: 240)
    .clipped()
    .background(.black)
}
```

---

## Photo Develop (`photo-develop`)

The view develops like an instant photo, from murky dark to full colour.

- API: `.photoDevelop()`
- Component: Card & Group
- Trigger: Appear / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/PhotoDevelop.swift
- Files: Sources/MotionLibrary/Card/PhotoDevelop.swift

### Usage

```swift
Image("snapshot")
    .resizable()
    .scaledToFill()
    .frame(width: 200, height: 200)
    .photoDevelop(trigger: shotCount)
```

### How it works

Create a SwiftUI .photoDevelop(trigger:duration:) view modifier (iOS 17+) that develops the view like an instant photo on appear and whenever trigger changes. Reset progress to 0 without animation, then animate it to 1 with .easeOut(duration: 3.5). Apply the look in an Animatable modifier so each quality keeps its own pace: light = min(1, 1.6p), colour = max(0, (p − 0.25) / 0.75), focus = min(1, 1.3p); .saturation(colour), .contrast(0.55 + 0.45 × light), .brightness(−0.45 × (1 − light)), .blur(radius: 5 × (1 − focus)), and an overlay LinearGradient from blue-grey (0.2, 0.25, 0.35) to brown (0.35, 0.25, 0.18) with opacity 0.75 × (1 − colour) and .blendMode(.multiply). With Reduce Motion on, develop over 0.4 s.

### Source

```swift
import SwiftUI

/// The view develops like an instant photo: it starts as a dark, murky,
/// blue-brown smudge and over a few seconds the light, contrast, colour and
/// focus come in.
struct PhotoDevelopModifier<Trigger: Equatable>: ViewModifier {
    var trigger: Trigger
    var duration: Double

    @State private var progress: Double = 0
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        content
            .modifier(DevelopEffect(progress: progress))
            .onAppear(perform: develop)
            .onChange(of: trigger) { develop() }
    }

    private func develop() {
        var reset = Transaction()
        reset.disablesAnimations = true
        withTransaction(reset) { progress = 0 }
        withAnimation(reduceMotion ? .easeInOut(duration: 0.4) : .easeOut(duration: duration)) { progress = 1 }
    }
}

/// The look at a point of the development, 0 (blank) to 1 (developed).
/// Animatable, so each quality keeps its own pace during the animation.
struct DevelopEffect: ViewModifier, Animatable {
    var progress: Double

    nonisolated var animatableData: Double {
        get { progress }
        set { progress = newValue }
    }

    func body(content: Content) -> some View {
        // Each quality arrives at its own pace: light first, colour last.
        let light = min(1, progress * 1.6)
        let colour = max(0, (progress - 0.25) / 0.75)
        let focus = min(1, progress * 1.3)
        content
            .saturation(colour)
            .contrast(0.55 + 0.45 * light)
            .brightness(-0.45 * (1 - light))
            .blur(radius: 5 * (1 - focus))
            .overlay {
                // The murky blue-brown cast of an undeveloped print.
                LinearGradient(colors: [Color(red: 0.2, green: 0.25, blue: 0.35), Color(red: 0.35, green: 0.25, blue: 0.18)], startPoint: .topLeading, endPoint: .bottomTrailing)
                    .opacity(0.75 * (1 - colour))
                    .blendMode(.multiply)
                    .allowsHitTesting(false)
            }
    }
}

public extension View {
    /// Develops the view like an instant photo on appear, and again whenever
    /// `trigger` changes.
    ///
    /// ```swift
    /// Image("snapshot")
    ///     .resizable()
    ///     .scaledToFill()
    ///     .photoDevelop()
    /// ```
    ///
    /// - Parameters:
    ///   - trigger: Develops the photo again whenever it changes.
    ///   - duration: Seconds from blank to fully developed.
    func photoDevelop(trigger: some Equatable = 0, duration: Double = 3.5) -> some View {
        modifier(PhotoDevelopModifier(trigger: trigger, duration: duration))
    }
}

#Preview {
    struct Demo: View {
        @State private var shots = 0
        var body: some View {
            VStack(spacing: 0) {
                LinearGradient(colors: [.orange, .pink, .purple], startPoint: .top, endPoint: .bottom)
                    .overlay(Image(systemName: "sun.max.fill").font(.system(size: 50)).foregroundStyle(.yellow))
                    .frame(width: 160, height: 160)
                    .photoDevelop(trigger: shots)
                Color.white.frame(height: 40)
            }
            .padding([.top, .horizontal], 10)
            .background(.white)
            .shadow(radius: 6)
            .onTapGesture { shots += 1 }
        }
    }
    return Demo()
}
```

---

## Pulse Rings (`pulse-rings`)

Concentric rings expand and fade continuously, like a radar or live dot.

- API: `PulseRings()`
- Component: Shape
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/PulseRings.swift
- Files: Sources/MotionLibrary/Shape/PulseRings.swift

### Usage

```swift
ZStack {
    PulseRings(color: .green, count: 3)
        .frame(width: 120, height: 120)
    Circle().fill(.green).frame(width: 24, height: 24)
}
```

### How it works

Create a SwiftUI PulseRings view (iOS 17+). Draw N stroked circles (default 3, 2 pt line) that each grow from 20% to 100% of the frame while fading from opacity 1 to 0 over a 2 s cycle, evenly phase-offset so a new ring is always emerging. Drive it with TimelineView(.animation) and compute each ring's progress from the timeline date (no @State or repeatForever), so it never drifts and stops when off screen. Hide it from accessibility.

### Source

```swift
import SwiftUI

/// Concentric rings that continuously expand and fade, like a radar or a
/// "live" indicator. Place it behind an icon or avatar.
///
/// ```swift
/// ZStack {
///     PulseRings(color: .green)
///         .frame(width: 120, height: 120)
///     Circle().fill(.green).frame(width: 24, height: 24)
/// }
/// ```
public struct PulseRings: View {
    private let color: Color
    private let count: Int
    private let duration: Double
    private let lineWidth: CGFloat

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    public init(color: Color = .accentColor, count: Int = 3, duration: Double = 2, lineWidth: CGFloat = 2) {
        self.color = color
        self.count = max(1, count)
        self.duration = duration
        self.lineWidth = lineWidth
    }

    public var body: some View {
        // Reduce Motion: frozen rings at evenly spaced sizes.
        TimelineView(.animation(paused: reduceMotion)) { timeline in
            let time = reduceMotion ? duration * 0.5 : timeline.date.timeIntervalSinceReferenceDate
            ZStack {
                ForEach(0..<count, id: \.self) { index in
                    ring(progress: progress(time: time, index: index))
                }
            }
        }
        .accessibilityHidden(true)
    }

    private func progress(time: TimeInterval, index: Int) -> Double {
        let offset = Double(index) / Double(count)
        return (time / duration + offset).truncatingRemainder(dividingBy: 1)
    }

    private func ring(progress: Double) -> some View {
        Circle()
            .stroke(color, lineWidth: lineWidth)
            .scaleEffect(0.2 + progress * 0.8)
            .opacity(1 - progress)
    }
}

#Preview {
    ZStack {
        PulseRings(color: .green)
            .frame(width: 160, height: 160)
        Circle()
            .fill(.green)
            .frame(width: 28, height: 28)
    }
}
```

---

## Like Burst (`burst`)

Particles fly outward from the centre when a value changes.

- API: `.burst(trigger:)`
- Component: Shape
- Trigger: Tap / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/Burst.swift
- Files: Sources/MotionLibrary/Shape/Burst.swift

### Usage

```swift
Image(systemName: isLiked ? "heart.fill" : "heart")
    .contentTransition(.symbolEffect(.replace))
    .burst(trigger: isLiked, color: .pink, isActive: isLiked)
```

### How it works

Create a SwiftUI .burst(trigger:) view modifier (iOS 17+) for "like" feedback. Every time the Equatable trigger changes (and isActive is true), spawn a burst in an overlay: 12 small circles (alternating 7 pt and 4 pt) placed evenly around a circle, which move outward from the centre to a ~36 pt radius while shrinking to 30% and fading out with easeOut over 0.6 s. Remove each burst after it finishes, allow overlapping bursts, and never intercept touches. Color, count and radius are parameters.

### Source

```swift
import SwiftUI

/// Small dots that fly outward from the centre of the view, e.g. on a "like"
/// tap. Plays every time `trigger` changes.
struct BurstModifier<Trigger: Equatable>: ViewModifier {
    var trigger: Trigger
    var color: Color
    var count: Int
    var radius: CGFloat
    var isEnabled: Bool

    @State private var bursts: [UUID] = []
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        content
            .overlay {
                ZStack {
                    ForEach(bursts, id: \.self) { _ in
                        BurstParticles(color: color, count: count, radius: radius)
                    }
                }
                .allowsHitTesting(false)
            }
            .onChange(of: trigger) {
                // Reduce Motion: skip the particles.
                guard isEnabled, !reduceMotion else { return }
                let id = UUID()
                bursts.append(id)
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
                    bursts.removeAll { $0 == id }
                }
            }
    }
}

private struct BurstParticles: View {
    let color: Color
    let count: Int
    let radius: CGFloat

    @State private var exploded = false

    var body: some View {
        ZStack {
            ForEach(0..<max(1, count), id: \.self) { index in
                particle(angle: Double(index) / Double(max(1, count)) * 2 * .pi, index: index)
            }
        }
        .onAppear {
            withAnimation(.easeOut(duration: 0.6)) {
                exploded = true
            }
        }
    }

    private func particle(angle: Double, index: Int) -> some View {
        // Alternate particle size and distance so the burst feels organic.
        let size: CGFloat = index.isMultiple(of: 2) ? 7 : 4
        let distance = radius * (index.isMultiple(of: 3) ? 0.75 : 1)
        return Circle()
            .fill(color)
            .frame(width: size, height: size)
            // Scale before offsetting, otherwise the scale also shrinks the
            // travel distance and the particles never leave the icon.
            .scaleEffect(exploded ? 0.3 : 1)
            .offset(
                x: exploded ? CGFloat(cos(angle)) * distance : 0,
                y: exploded ? CGFloat(sin(angle)) * distance : 0
            )
            .opacity(exploded ? 0 : 1)
    }
}

public extension View {
    /// Emits a burst of particles every time `trigger` changes.
    ///
    /// ```swift
    /// Image(systemName: isLiked ? "heart.fill" : "heart")
    ///     .burst(trigger: isLiked, color: .pink, isActive: isLiked)
    /// ```
    func burst<Trigger: Equatable>(
        trigger: Trigger,
        color: Color = .pink,
        count: Int = 12,
        radius: CGFloat = 36,
        isActive: Bool = true
    ) -> some View {
        modifier(BurstModifier(trigger: trigger, color: color, count: count, radius: radius, isEnabled: isActive))
    }

    @available(*, deprecated, renamed: "burst(trigger:color:count:radius:isActive:)")
    func burst<Trigger: Equatable>(
        trigger: Trigger,
        color: Color = .pink,
        count: Int = 12,
        radius: CGFloat = 36,
        isEnabled: Bool
    ) -> some View {
        burst(trigger: trigger, color: color, count: count, radius: radius, isActive: isEnabled)
    }
}

#Preview {
    struct Demo: View {
        @State private var liked = false
        var body: some View {
            Button {
                liked.toggle()
            } label: {
                Image(systemName: liked ? "heart.fill" : "heart")
                    .font(.system(size: 44))
                    .foregroundStyle(liked ? Color.pink : Color.secondary)
                    .contentTransition(.symbolEffect(.replace))
            }
            .buttonStyle(.plain)
            .burst(trigger: liked, isActive: liked)
        }
    }
    return Demo()
}
```

---

## Checkmark Draw (`checkmark`)

A success mark draws itself: circle stroke, fill, then a popping check.

- API: `AnimatedCheckmark(isChecked:)`
- Component: Shape
- Trigger: Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/AnimatedCheckmark.swift
- Files: Sources/MotionLibrary/Shape/AnimatedCheckmark.swift, Sources/MotionLibrary/Support/CheckmarkShape.swift

### Usage

```swift
AnimatedCheckmark(isChecked: isDone)
    .frame(width: 80, height: 80)

AnimatedCheckmark(isChecked: saved, color: .blue, lineWidth: 3)
```

### How it works

Create a SwiftUI AnimatedCheckmark view (iOS 17+) with isChecked, color (green) and lineWidth (4 at 60 pt, scaled with size). It is square. Layer three parts in a ZStack: a circle stroke trimmed from 0 to 1 and rotated −90° so it draws clockwise from the top (easeInOut 0.45 s); a filled circle that scales from 0.6 and fades in with a spring after 0.35 s; and a white checkmark path (short stroke down-right, long stroke up-right, round caps and joins) trimmed from 0 to 1 with easeOut 0.3 s after 0.55 s. Finish with a small bouncy scale pop at 0.75 s. Unchecking reverses everything without delays. With Reduce Motion on, replace the sequence with a 0.2 s fade.

### Source

```swift
import SwiftUI

/// A success mark that draws itself: the circle strokes around, fills, then
/// the checkmark is drawn on top with a small pop. Unchecking erases it.
public struct AnimatedCheckmark: View {
    private let isChecked: Bool
    private let color: Color
    private let lineWidth: CGFloat

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - isChecked: Draws the mark when true, erases it when false.
    ///   - color: Circle colour; the checkmark is white on top of it.
    ///   - lineWidth: Stroke width, scaled with the view's size (at 60 pt).
    public init(isChecked: Bool, color: Color = .green, lineWidth: CGFloat = 4) {
        self.isChecked = isChecked
        self.color = color
        self.lineWidth = lineWidth
    }

    public var body: some View {
        GeometryReader { geometry in
            let side = min(geometry.size.width, geometry.size.height)
            let scale = side / 60
            ZStack {
                Circle()
                    .trim(from: 0, to: isChecked ? 1 : 0)
                    .stroke(color, style: StrokeStyle(lineWidth: lineWidth * scale, lineCap: .round))
                    .rotationEffect(.degrees(-90))
                    .animation(timing(.easeInOut(duration: 0.45)), value: isChecked)
                Circle()
                    .fill(color)
                    .scaleEffect(isChecked ? 1 : 0.6)
                    .opacity(isChecked ? 1 : 0)
                    .animation(timing(.spring(response: 0.35, dampingFraction: 0.6).delay(isChecked ? 0.35 : 0)), value: isChecked)
                CheckmarkShape()
                    .trim(from: 0, to: isChecked ? 1 : 0)
                    .stroke(.white, style: StrokeStyle(lineWidth: lineWidth * 1.2 * scale, lineCap: .round, lineJoin: .round))
                    .frame(width: side * 0.42, height: side * 0.32)
                    .offset(y: side * 0.02)
                    .animation(timing(.easeOut(duration: 0.3).delay(isChecked ? 0.55 : 0)), value: isChecked)
            }
            .frame(width: side, height: side)
            .scaleEffect(isChecked && !reduceMotion ? 1 : 0.95)
            .animation(timing(.spring(response: 0.3, dampingFraction: 0.4).delay(isChecked ? 0.75 : 0)), value: isChecked)
            .frame(width: geometry.size.width, height: geometry.size.height)
        }
        .aspectRatio(1, contentMode: .fit)
        .accessibilityElement()
        .accessibilityLabel(isChecked ? Text("Done") : Text("Not done"))
    }

    /// Reduce Motion: skip the drawing and just fade.
    private func timing(_ animation: Animation) -> Animation {
        reduceMotion ? .easeInOut(duration: 0.2) : animation
    }
}

#Preview {
    struct Demo: View {
        @State private var checked = false

        var body: some View {
            AnimatedCheckmark(isChecked: checked)
                .frame(width: 80, height: 80)
                .onTapGesture { checked.toggle() }
        }
    }
    return Demo()
}

// MARK: - CheckmarkShape.swift
// Needed by this effect: save it as its own file next to the one above.

// Shared by StatusButton and AnimatedCheckmark.

import SwiftUI

/// A checkmark drawn left to right, sized to its frame.
struct CheckmarkShape: Shape {
    nonisolated func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: rect.minX, y: rect.midY))
        path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.38, y: rect.maxY))
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
        return path
    }
}
```

---

## Confetti (`confetti`)

Confetti bursts out of the view, tumbles under gravity and fades.

- API: `.confetti(trigger:)`
- Component: Shape
- Trigger: Tap / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/Confetti.swift
- Files: Sources/MotionLibrary/Shape/Confetti.swift

### Usage

```swift
@State private var orders = 0

Button("Complete order") { orders += 1 }
    .confetti(trigger: orders)

Text("🎉 Level up!")
    .confetti(trigger: level, colors: [.yellow, .orange], count: 120)
```

### How it works

Create a SwiftUI .confetti(trigger:colors:count:spread:) view modifier (iOS 17+). Each change of the equatable trigger appends a burst with a start date and 80 random pieces, removed after 2.4 s. Each piece has a velocity fanned ±60° around straight up (speed up to 3.2 × spread), a spin rate, a wobble phase, a 5–9 × 8–14 pt size, a colour index and every fourth one is a circle. Draw all bursts in one Canvas inside TimelineView(.animation), in an overlay enlarged by .padding(−spread) (160 pt) so confetti flies beyond the view, with hit testing off. Position = origin + velocity × (1 − e^(−1.6t)) / 1.6 + ½ × gravity × t² (air drag plus gravity), rotate by spin × t, scale x by cos(wobble + 8t) to fake turning over, and fade out over the last 0.6 s. With Reduce Motion on, skip the burst.

### Source

```swift
import SwiftUI

/// Confetti that bursts up and out of the view whenever `trigger` changes,
/// then tumbles down under gravity and fades. Drawn in a single `Canvas`, so
/// a few hundred pieces stay cheap.
struct ConfettiModifier<Trigger: Equatable>: ViewModifier {
    var trigger: Trigger
    var colors: [Color]
    var count: Int
    var spread: CGFloat

    @State private var bursts: [ConfettiBurst] = []
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        content
            .overlay {
                if !bursts.isEmpty {
                    TimelineView(.animation) { timeline in
                        Canvas { context, size in
                            let now = timeline.date
                            for burst in bursts {
                                burst.draw(in: &context, size: size, time: now.timeIntervalSince(burst.start), colors: colors)
                            }
                        }
                    }
                    // Draw well beyond the view's own bounds.
                    .padding(-spread)
                    .allowsHitTesting(false)
                    .accessibilityHidden(true)
                }
            }
            .onChange(of: trigger) {
                guard !reduceMotion else { return }
                let burst = ConfettiBurst(count: count, spread: spread)
                bursts.append(burst)
                DispatchQueue.main.asyncAfter(deadline: .now() + ConfettiBurst.lifetime) {
                    bursts.removeAll { $0.id == burst.id }
                }
            }
    }
}

struct ConfettiBurst: Identifiable {
    static let lifetime: Double = 2.4

    struct Piece {
        var velocity: CGVector
        var spin: Double
        var wobble: Double
        var size: CGSize
        var colorIndex: Int
        var isCircle: Bool
    }

    let id = UUID()
    let start = Date()
    let pieces: [Piece]

    init(count: Int, spread: CGFloat) {
        pieces = (0..<max(1, count)).map { index in
            // Mostly upward, fanned out ±60° from vertical.
            let angle = -Double.pi / 2 + Double.random(in: -1.05...1.05)
            let speed = Double.random(in: 0.55...1.0) * Double(spread) * 3.2
            return Piece(
                velocity: CGVector(dx: cos(angle) * speed, dy: sin(angle) * speed),
                spin: Double.random(in: -9...9),
                wobble: Double.random(in: 0...(2 * .pi)),
                size: CGSize(width: .random(in: 5...9), height: .random(in: 8...14)),
                colorIndex: index,
                isCircle: index.isMultiple(of: 4)
            )
        }
    }

    func draw(in context: inout GraphicsContext, size: CGSize, time: Double, colors: [Color]) {
        guard time >= 0, time < Self.lifetime, !colors.isEmpty else { return }
        let origin = CGPoint(x: size.width / 2, y: size.height / 2)
        let gravity = 900.0
        let drag = 1.6
        // Velocity decays with air drag; position integrates it.
        let decay = (1 - exp(-drag * time)) / drag
        let fade = time > Self.lifetime - 0.6 ? (Self.lifetime - time) / 0.6 : 1
        for piece in pieces {
            let x = origin.x + piece.velocity.dx * decay
            let y = origin.y + piece.velocity.dy * decay + 0.5 * gravity * time * time * 0.55
            var copy = context
            copy.opacity = fade
            copy.translateBy(x: x, y: y)
            copy.rotate(by: .radians(piece.spin * time))
            // Squash horizontally to fake the piece turning over in 3D.
            copy.scaleBy(x: CGFloat(cos(piece.wobble + time * 8)), y: 1)
            let rect = CGRect(
                x: -piece.size.width / 2,
                y: -piece.size.height / 2,
                width: piece.size.width,
                height: piece.isCircle ? piece.size.width : piece.size.height
            )
            let shape = piece.isCircle ? Path(ellipseIn: rect) : Path(rect)
            copy.fill(shape, with: .color(colors[piece.colorIndex % colors.count]))
        }
    }
}

public extension View {
    /// Fires a confetti burst from the view each time `trigger` changes.
    ///
    /// ```swift
    /// Button("Complete order") { orders += 1 }
    ///     .confetti(trigger: orders)
    /// ```
    ///
    /// - Parameters:
    ///   - trigger: Any equatable value; each change fires one burst.
    ///   - colors: Colours the pieces cycle through.
    ///   - count: Pieces per burst.
    ///   - spread: How far beyond the view's bounds confetti may travel.
    func confetti<Trigger: Equatable>(
        trigger: Trigger,
        colors: [Color] = [.pink, .orange, .yellow, .green, .blue, .purple],
        count: Int = 80,
        spread: CGFloat = 160
    ) -> some View {
        modifier(ConfettiModifier(trigger: trigger, colors: colors, count: count, spread: spread))
    }
}

#Preview {
    struct Demo: View {
        @State private var count = 0

        var body: some View {
            Button("Celebrate 🎉") { count += 1 }
                .buttonStyle(.borderedProminent)
                .confetti(trigger: count)
                .frame(width: 400, height: 400)
        }
    }
    return Demo()
}
```

---

## Loaders (`loaders`)

Five looping activity indicators: spinner, dots, bars, orbit and pulse.

- API: `MotionLoader(.spinner)`
- Component: Shape
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/MotionLoader.swift
- Files: Sources/MotionLibrary/Shape/MotionLoader.swift

### Usage

```swift
MotionLoader(.spinner)
    .frame(width: 32, height: 32)

MotionLoader(.dots, color: .secondary)
    .frame(width: 44, height: 44)
```

### How it works

Create a SwiftUI MotionLoader view (iOS 17+) with a style enum (spinner, dots, bars, orbit, pulse), a color and a speed multiplier. It is square and drives everything from TimelineView(.animation) time, sizing parts relative to its side. Spinner: a round-capped arc whose trim length breathes between 15% and 75% while it rotates. Dots: three dots hopping up in sequence (max(0, sin(6t − i·0.9))) and brightening at the top. Bars: five capsules whose heights follow a sine wave offset per bar. Orbit: two dots circling each other, scaled and layered by a fake depth. Pulse: two rings expanding and fading in turn around a centre dot. Label it "Loading" for accessibility, and with Reduce Motion on, show a dot that slowly breathes in opacity.

### Source

```swift
import SwiftUI

/// A set of looping activity indicators. Pick a style; size it with `.frame`.
public struct MotionLoader: View {
    /// The look of the loader.
    public enum Style: String, CaseIterable, Sendable {
        /// An arc that spins while its length breathes.
        case spinner
        /// Three dots hopping in sequence.
        case dots
        /// Equaliser bars rising and falling in a wave.
        case bars
        /// Two dots orbiting each other.
        case orbit
        /// A ring that pulses outward.
        case pulse
    }

    private let style: Style
    private let color: Color
    private let speed: Double

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - style: Which loader to draw.
    ///   - color: Fill or stroke colour.
    ///   - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast.
    public init(_ style: Style = .spinner, color: Color = .accentColor, speed: Double = 1) {
        self.style = style
        self.color = color
        self.speed = speed
    }

    public var body: some View {
        TimelineView(.animation(paused: speed == 0)) { timeline in
            // Reduce Motion: a slow opacity breathe instead of movement.
            let time = timeline.date.timeIntervalSinceReferenceDate * speed
            GeometryReader { geometry in
                let side = min(geometry.size.width, geometry.size.height)
                Group {
                    if reduceMotion {
                        Circle()
                            .fill(color)
                            .frame(width: side * 0.4, height: side * 0.4)
                            .opacity(0.4 + 0.4 * (0.5 + 0.5 * sin(time * 2)))
                    } else {
                        loader(time: time, side: side)
                    }
                }
                .frame(width: geometry.size.width, height: geometry.size.height)
            }
        }
        .aspectRatio(1, contentMode: .fit)
        .accessibilityElement()
        .accessibilityLabel("Loading")
        .accessibilityAddTraits(.updatesFrequently)
    }

    @ViewBuilder
    private func loader(time: Double, side: CGFloat) -> some View {
        switch style {
        case .spinner:
            let breathe = 0.5 + 0.5 * sin(time * 3)
            Circle()
                .trim(from: 0, to: 0.15 + 0.6 * breathe)
                .stroke(color, style: StrokeStyle(lineWidth: side * 0.1, lineCap: .round))
                .rotationEffect(.radians(time * 5))
                .padding(side * 0.05)

        case .dots:
            HStack(spacing: side * 0.1) {
                ForEach(0..<3, id: \.self) { index in
                    let hop = max(0, sin(time * 6 - Double(index) * 0.9))
                    Circle()
                        .fill(color)
                        .frame(width: side * 0.22, height: side * 0.22)
                        .offset(y: -side * 0.25 * hop)
                        .opacity(0.5 + 0.5 * hop)
                }
            }
            .offset(y: side * 0.1)

        case .bars:
            HStack(alignment: .center, spacing: side * 0.07) {
                ForEach(0..<5, id: \.self) { index in
                    let level = 0.5 + 0.5 * sin(time * 5 - Double(index) * 0.7)
                    Capsule()
                        .fill(color)
                        .frame(width: side * 0.12, height: side * (0.25 + 0.65 * level))
                }
            }

        case .orbit:
            ZStack {
                ForEach(0..<2, id: \.self) { index in
                    let angle = time * 4 + Double(index) * .pi
                    let depth = 0.5 + 0.5 * sin(angle)
                    Circle()
                        .fill(color)
                        .frame(width: side * 0.3, height: side * 0.3)
                        .scaleEffect(0.7 + 0.3 * depth)
                        .opacity(0.6 + 0.4 * depth)
                        .offset(x: cos(angle) * side * 0.3)
                        .zIndex(depth)
                }
            }

        case .pulse:
            ZStack {
                ForEach(0..<2, id: \.self) { index in
                    let phase = (time * 0.8 + Double(index) * 0.5).truncatingRemainder(dividingBy: 1)
                    Circle()
                        .stroke(color, lineWidth: side * 0.06)
                        .scaleEffect(0.2 + 0.8 * phase)
                        .opacity(1 - phase)
                }
                Circle()
                    .fill(color)
                    .frame(width: side * 0.2, height: side * 0.2)
            }
        }
    }
}

#Preview {
    HStack(spacing: 24) {
        ForEach(MotionLoader.Style.allCases, id: \.self) { style in
            MotionLoader(style)
                .frame(width: 44, height: 44)
        }
    }
    .padding()
}
```

---

## Morphing Blob (`morphing-blob`)

An organic, lava-lamp blob whose outline never stops changing.

- API: `MorphingBlob()`
- Component: Shape
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/MorphingBlob.swift
- Files: Sources/MotionLibrary/Shape/MorphingBlob.swift

### Usage

```swift
MorphingBlob(colors: [.purple, .pink, .orange])
    .frame(width: 200, height: 200)

MorphingBlob(fill: Color.blue.opacity(0.3), points: 8, wobble: 0.1)
    .overlay(Image(systemName: "mic.fill"))
```

### How it works

Create a SwiftUI MorphingBlob view (iOS 17+) generic over a ShapeStyle fill, with a convenience initializer taking colours for a diagonal LinearGradient. Draw a custom Shape in TimelineView(.animation): place N control points (6) evenly around the centre, each at radius base × (1 + wobble × (0.6·sin(1.1t + 1.7i) + 0.4·sin(0.7t + 2.9i))), where base = half the side × (1 − wobble) and wobble is 0.18. Connect them into a smooth closed curve with a Catmull-Rom spline converted to cubic Béziers (control points p1 + (p2 − p0)/6 and p2 − (p3 − p1)/6). Keep a 1:1 aspect ratio and hide it from accessibility. With Reduce Motion on, freeze the shape.

### Source

```swift
import SwiftUI

/// An organic blob whose outline keeps morphing, like a lava lamp. Use it as
/// an avatar backdrop, a "listening" indicator or a hero decoration.
public struct MorphingBlob<Fill: ShapeStyle>: View {
    private let fill: Fill
    private let points: Int
    private let wobble: CGFloat
    private let speed: Double

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - fill: Any shape style: a colour, gradient or material.
    ///   - points: Number of lobes around the outline (4…10 looks organic).
    ///   - wobble: How far the outline moves in and out, as a fraction of the radius.
    ///   - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast.
    public init(fill: Fill, points: Int = 6, wobble: CGFloat = 0.18, speed: Double = 1) {
        self.fill = fill
        self.points = points
        self.wobble = wobble
        self.speed = speed
    }

    public var body: some View {
        TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in
            BlobShape(
                time: reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * speed,
                points: points,
                wobble: wobble
            )
            .fill(fill)
        }
        .aspectRatio(1, contentMode: .fit)
        .accessibilityHidden(true)
    }
}

public extension MorphingBlob where Fill == LinearGradient {
    /// A blob filled with a diagonal gradient.
    init(colors: [Color] = [.purple, .pink, .orange], points: Int = 6, wobble: CGFloat = 0.18, speed: Double = 1) {
        self.init(
            fill: LinearGradient(colors: colors, startPoint: .topLeading, endPoint: .bottomTrailing),
            points: points,
            wobble: wobble,
            speed: speed
        )
    }
}

/// A closed, smooth outline through `points` control points whose distance
/// from the centre is a sum of slow sine waves.
struct BlobShape: Shape {
    var time: Double
    var points: Int
    var wobble: CGFloat

    nonisolated func path(in rect: CGRect) -> Path {
        let count = max(3, points)
        let centre = CGPoint(x: rect.midX, y: rect.midY)
        let baseRadius = min(rect.width, rect.height) / 2 * (1 - wobble)
        let vertices: [CGPoint] = (0..<count).map { index in
            let angle = Double(index) / Double(count) * 2 * .pi
            let i = Double(index)
            // Two waves with unrelated frequencies so the motion never visibly repeats.
            let offset = sin(time * 1.1 + i * 1.7) * 0.6 + sin(time * 0.7 + i * 2.9) * 0.4
            let radius = baseRadius * (1 + wobble * CGFloat(offset))
            return CGPoint(x: centre.x + CGFloat(cos(angle)) * radius, y: centre.y + CGFloat(sin(angle)) * radius)
        }
        return smoothClosedPath(through: vertices)
    }

    /// Catmull-Rom spline through the points, as cubic Béziers.
    private func smoothClosedPath(through p: [CGPoint]) -> Path {
        var path = Path()
        let n = p.count
        path.move(to: p[0])
        for i in 0..<n {
            let p0 = p[(i - 1 + n) % n], p1 = p[i], p2 = p[(i + 1) % n], p3 = p[(i + 2) % n]
            let c1 = CGPoint(x: p1.x + (p2.x - p0.x) / 6, y: p1.y + (p2.y - p0.y) / 6)
            let c2 = CGPoint(x: p2.x - (p3.x - p1.x) / 6, y: p2.y - (p3.y - p1.y) / 6)
            path.addCurve(to: p2, control1: c1, control2: c2)
        }
        path.closeSubpath()
        return path
    }
}

#Preview {
    MorphingBlob()
        .frame(width: 200, height: 200)
        .padding()
}
```

---

## Draw On (`draw-on`)

Any Shape strokes itself on like a pen, and erases back.

- API: `Shape.drawOn()`
- Component: Shape
- Trigger: Appear / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/DrawOn.swift
- Files: Sources/MotionLibrary/Shape/DrawOn.swift

### Usage

```swift
Circle()
    .drawOn()
    .frame(width: 80, height: 80)

SignaturePath()
    .drawOn(isDrawn: signed, style: .blue, lineWidth: 3, duration: 1.6)
```

### How it works

Create a SwiftUI Shape extension drawOn(isDrawn:style:lineWidth:duration:) (iOS 17+) returning a DrawOnShape view. It strokes shape.trim(from: 0, to: progress) with the given ShapeStyle (accent colour), a StrokeStyle with lineWidth (3 pt), round caps and round joins, and pads by lineWidth / 2 so the stroke isn't clipped. Keep progress in @State; on appear and whenever isDrawn changes, animate it to 1 (draw) or 0 (erase) with easeInOut over duration (1.2 s). Works with any Shape, including custom paths, which are drawn in path order. With Reduce Motion on, use a 0.2 s ease.

### Source

```swift
import SwiftUI

/// Strokes any `Shape` as if a pen were drawing it, from the start of its
/// path to the end. Draws on appear and whenever `isDrawn` turns on; erases
/// back when it turns off.
public struct DrawOnShape<S: Shape>: View {
    private let shape: S
    private let isDrawn: Bool
    private let style: AnyShapeStyle
    private let lineWidth: CGFloat
    private let duration: Double

    @State private var progress: CGFloat = 0
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    init(shape: S, isDrawn: Bool, style: AnyShapeStyle, lineWidth: CGFloat, duration: Double) {
        self.shape = shape
        self.isDrawn = isDrawn
        self.style = style
        self.lineWidth = lineWidth
        self.duration = duration
    }

    public var body: some View {
        shape
            .trim(from: 0, to: progress)
            .stroke(style, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round))
            .padding(lineWidth / 2)
            .onAppear { update(isDrawn) }
            .onChange(of: isDrawn) { _, newValue in update(newValue) }
    }

    private func update(_ drawn: Bool) {
        // Reduce Motion: the stroke appears all at once.
        withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .easeInOut(duration: duration)) {
            progress = drawn ? 1 : 0
        }
    }
}

public extension Shape {
    /// Draws the shape's outline like a pen stroke.
    ///
    /// ```swift
    /// Circle()
    ///     .drawOn()
    ///     .frame(width: 80, height: 80)
    ///
    /// SignaturePath()
    ///     .drawOn(isDrawn: signed, style: .blue, lineWidth: 3, duration: 1.6)
    /// ```
    ///
    /// - Parameters:
    ///   - isDrawn: Draws when true, erases when false.
    ///   - style: Stroke colour or gradient.
    ///   - lineWidth: Stroke width, in points.
    ///   - duration: Time to draw the whole path, in seconds.
    @MainActor
    func drawOn<Style: ShapeStyle>(
        isDrawn: Bool = true,
        style: Style = Color.accentColor,
        lineWidth: CGFloat = 3,
        duration: Double = 1.2
    ) -> DrawOnShape<Self> {
        DrawOnShape(shape: self, isDrawn: isDrawn, style: AnyShapeStyle(style), lineWidth: lineWidth, duration: duration)
    }
}

#Preview {
    HStack(spacing: 24) {
        Circle().drawOn().frame(width: 70, height: 70)
        RoundedRectangle(cornerRadius: 14).drawOn(style: .pink, duration: 2).frame(width: 90, height: 70)
    }
    .padding()
}
```

---

## Progress Ring (`progress-ring`)

A gradient ring springs to each value as the percentage rolls.

- API: `ProgressRing(progress:)`
- Component: Shape
- Trigger: Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/ProgressRing.swift
- Files: Sources/MotionLibrary/Shape/ProgressRing.swift

### Usage

```swift
ProgressRing(progress: uploaded)
    .frame(width: 120, height: 120)

ProgressRing(progress: steps / goal, colors: [.green, .mint]) {
    Image(systemName: "figure.walk").font(.title)
}
```

### How it works

Create a SwiftUI ProgressRing (iOS 17+) with progress (0…1), lineWidth (12 pt), colors (cyan, blue, purple) and an optional label view builder (default: the percentage). In a ZStack, stroke a full Circle in secondary at 18 % opacity, then a Circle().trim(from: 0, to: progress) stroked with AngularGradient(colors:) over 0–360° and a round line cap, rotated −90° so it starts at twelve o'clock. Pad by lineWidth / 2. Animate progress changes with .spring(response: 0.6, dampingFraction: 0.75). The default label is the percentage as .title2.bold().monospacedDigit() with .contentTransition(.numericText(value:)), so the digits roll. Expose the percentage as the accessibility value. With Reduce Motion on, use a short ease.

### Source

```swift
import SwiftUI

/// A circular progress ring with a gradient stroke and rounded cap that
/// springs to each new value, with the percentage counting in the middle.
public struct ProgressRing<Label: View>: View {
    private let progress: Double
    private let lineWidth: CGFloat
    private let colors: [Color]
    private let label: Label

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - progress: 0…1.
    ///   - lineWidth: Ring thickness, in points.
    ///   - colors: Gradient along the ring, from start to end.
    ///   - label: What sits inside the ring.
    public init(
        progress: Double,
        lineWidth: CGFloat = 12,
        colors: [Color] = [.cyan, .blue, .purple],
        @ViewBuilder label: () -> Label
    ) {
        self.progress = progress
        self.lineWidth = lineWidth
        self.colors = colors
        self.label = label()
    }

    public var body: some View {
        let clamped = min(1, max(0, progress))
        ZStack {
            Circle()
                .stroke(Color.secondary.opacity(0.18), lineWidth: lineWidth)
            Circle()
                .trim(from: 0, to: clamped)
                .stroke(
                    AngularGradient(colors: colors, center: .center, startAngle: .degrees(0), endAngle: .degrees(360)),
                    style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
                )
                .rotationEffect(.degrees(-90))
            label
        }
        .padding(lineWidth / 2)
        .animation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(response: 0.6, dampingFraction: 0.75), value: clamped)
        .accessibilityElement(children: .ignore)
        .accessibilityValue(clamped.formatted(.percent.precision(.fractionLength(0))))
    }
}

public extension ProgressRing where Label == ProgressRingPercent {
    /// A ring with the percentage in the middle.
    ///
    /// ```swift
    /// ProgressRing(progress: uploaded)
    ///     .frame(width: 120, height: 120)
    /// ```
    init(progress: Double, lineWidth: CGFloat = 12, colors: [Color] = [.cyan, .blue, .purple]) {
        self.init(progress: progress, lineWidth: lineWidth, colors: colors) {
            ProgressRingPercent(progress: progress)
        }
    }
}

/// The default label: a percentage that rolls to each new value.
public struct ProgressRingPercent: View {
    let progress: Double

    public var body: some View {
        let value = min(1, max(0, progress))
        Text(value.formatted(.percent.precision(.fractionLength(0))))
            .font(.title2.bold().monospacedDigit())
            .contentTransition(.numericText(value: value))
    }
}

#Preview {
    struct Demo: View {
        @State private var progress = 0.35
        var body: some View {
            ProgressRing(progress: progress)
                .frame(width: 120, height: 120)
                .onTapGesture { progress = progress >= 1 ? 0.1 : progress + 0.3 }
        }
    }
    return Demo().padding()
}
```

---

## Skeleton (`skeleton`)

A shimmering placeholder of the view's own layout while it loads.

- API: `.skeleton(isLoading:)`
- Component: Shape
- Trigger: Loop / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/Skeleton.swift
- Files: Sources/MotionLibrary/Shape/Skeleton.swift, Sources/MotionLibrary/Text/Shimmer.swift

### Usage

```swift
ArticleRow(article)
    .skeleton(isLoading: isLoading)
```

### How it works

Create a SwiftUI .skeleton(isLoading:) view modifier (iOS 17+). While loading, apply .redacted(reason: .placeholder) so text and images become grey blocks in the view's real layout, then the shimmer modifier (a white 80 % highlight band, 35 % of the width, sweeping left to right every 1.6 s, masked by the content), disable hit testing and hide the view from accessibility. When loading ends, remove both and animate with easeInOut(0.3 s) so the real content fades in. The shimmer pauses for Reduce Motion.

### Source

```swift
import SwiftUI

/// Swaps a view for a shimmering grey placeholder of the same layout while
/// it loads, then fades the real content in.
struct SkeletonModifier: ViewModifier {
    var isLoading: Bool

    func body(content: Content) -> some View {
        content
            .redacted(reason: isLoading ? .placeholder : [])
            // `.shimmer()` already pauses itself for Reduce Motion.
            .shimmer(isActive: isLoading)
            .allowsHitTesting(!isLoading)
            .animation(.easeInOut(duration: 0.3), value: isLoading)
            // Placeholder text is meaningless to VoiceOver.
            .accessibilityHidden(isLoading)
    }
}

public extension View {
    /// Shows a shimmering placeholder of the view's own layout while
    /// `isLoading` is true.
    ///
    /// ```swift
    /// ArticleRow(article)
    ///     .skeleton(isLoading: article.isPlaceholder)
    /// ```
    func skeleton(isLoading: Bool) -> some View {
        modifier(SkeletonModifier(isLoading: isLoading))
    }
}

#Preview {
    struct Demo: View {
        @State private var loading = true
        var body: some View {
            HStack(spacing: 12) {
                Image(systemName: "person.crop.circle.fill").font(.largeTitle)
                VStack(alignment: .leading) {
                    Text("Taylor Appleseed").font(.headline)
                    Text("Shipped a new build").font(.subheadline)
                }
            }
            .skeleton(isLoading: loading)
            .onTapGesture { loading.toggle() }
        }
    }
    return Demo().padding()
}

// MARK: - Shimmer.swift
// Needed by this effect: save it as its own file next to the one above.

import SwiftUI

/// A band of light that sweeps across the view, clipped to the view's own
/// shape. Works on text, images, and skeleton placeholders.
struct ShimmerModifier: ViewModifier {
    var isActive: Bool
    var duration: Double
    var bandWidth: CGFloat
    var highlight: Color

    @State private var animating = false
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        content
            .overlay {
                if isActive {
                    GeometryReader { geometry in
                        let width = geometry.size.width
                        let band = max(1, width * bandWidth)
                        LinearGradient(
                            colors: [.clear, highlight, .clear],
                            startPoint: .leading,
                            endPoint: .trailing
                        )
                        .frame(width: band, height: geometry.size.height)
                        .offset(x: animating ? width : -band)
                    }
                    .mask { content }
                    .allowsHitTesting(false)
                    .onAppear {
                        animating = false
                        // Reduce Motion: no sweep; the band stays parked off-screen.
                        guard !reduceMotion else { return }
                        withAnimation(.linear(duration: duration).repeatForever(autoreverses: false)) {
                            animating = true
                        }
                    }
                }
            }
    }
}

public extension View {
    /// Sweeps a highlight across the view.
    ///
    /// ```swift
    /// Text("Loading…").font(.title).shimmer()
    /// RoundedRectangle(cornerRadius: 8).fill(.gray.opacity(0.3)).shimmer()
    /// ```
    func shimmer(
        isActive: Bool = true,
        duration: Double = 1.6,
        bandWidth: CGFloat = 0.35,
        highlight: Color = .white.opacity(0.8)
    ) -> some View {
        modifier(ShimmerModifier(
            isActive: isActive,
            duration: duration,
            bandWidth: bandWidth,
            highlight: highlight
        ))
    }
}

#Preview {
    VStack(spacing: 16) {
        Text("Shimmering title")
            .font(.largeTitle.bold())
            .foregroundStyle(.gray)
            .shimmer()
        RoundedRectangle(cornerRadius: 12)
            .fill(.gray.opacity(0.25))
            .frame(height: 60)
            .shimmer()
    }
    .padding()
}
```

---

## Sliding Tabs (`sliding-tabs`)

A segmented control whose pill or underline glides to the selection.

- API: `SlidingTabs([…], selection:)`
- Component: Shape
- Trigger: Tap
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/SlidingTabs.swift
- Files: Sources/MotionLibrary/Shape/SlidingTabs.swift

### Usage

```swift
SlidingTabs(["Day", "Week", "Month"], selection: $range)

SlidingTabs(Category.allCases, selection: $category, indicator: .underline) { $0.title }
```

### How it works

Create a generic SwiftUI SlidingTabs control (iOS 17+) over Hashable options with a selection binding, an indicator style (.pill or .underline), a color and a title closure (plus a convenience init for [String]). Show the options as plain Buttons in an HStack inside a 4 pt padded capsule tinted secondary at 12 % (pill style only). Behind the selected title only, draw the indicator — a capsule filled with the color (title turns white), or a 3 pt capsule at the bottom inset 10 pt (title turns tint) — with .matchedGeometryEffect(id: "indicator", in: namespace), so it glides between options. Change the selection inside withAnimation(.spring(response: 0.35, dampingFraction: 0.8)), and add the .isSelected trait. With Reduce Motion on, use a 0.15 s ease.

### Source

```swift
import SwiftUI

/// A segmented control whose selection indicator (a pill or an underline)
/// glides to the tapped option with `matchedGeometryEffect`.
public struct SlidingTabs<Value: Hashable>: View {
    /// How the selected option is marked.
    public enum Indicator: Sendable {
        /// A filled capsule behind the selected title.
        case pill
        /// A short bar under the selected title.
        case underline
    }

    @Binding private var selection: Value
    private let options: [Value]
    private let title: (Value) -> String
    private let indicator: Indicator
    private let color: Color

    @Namespace private var namespace
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - options: The options, in order.
    ///   - selection: The selected option.
    ///   - indicator: `.pill` or `.underline`.
    ///   - color: Colour of the indicator.
    ///   - title: The title of an option.
    public init(
        _ options: [Value],
        selection: Binding<Value>,
        indicator: Indicator = .pill,
        color: Color = .accentColor,
        title: @escaping (Value) -> String
    ) {
        self.options = options
        _selection = selection
        self.indicator = indicator
        self.color = color
        self.title = title
    }

    @available(*, deprecated, renamed: "init(_:selection:indicator:color:title:)")
    public init(
        _ options: [Value],
        selection: Binding<Value>,
        indicator: Indicator = .pill,
        tint: Color,
        title: @escaping (Value) -> String
    ) {
        self.init(options, selection: selection, indicator: indicator, color: tint, title: title)
    }

    public var body: some View {
        HStack(spacing: 4) {
            ForEach(options, id: \.self) { option in
                let selected = option == selection
                Button {
                    withAnimation(reduceMotion ? .easeInOut(duration: 0.15) : .spring(response: 0.35, dampingFraction: 0.8)) {
                        selection = option
                    }
                } label: {
                    Text(title(option))
                        .font(.subheadline.weight(.semibold))
                        .padding(.horizontal, 14)
                        .padding(.vertical, 8)
                        .foregroundStyle(indicator == .pill && selected ? Color.white : selected ? color : Color.secondary)
                        .background { marker(selected) }
                        .contentShape(Rectangle())
                }
                .buttonStyle(.plain)
                .accessibilityAddTraits(selected ? .isSelected : [])
            }
        }
        .padding(4)
        .background {
            if indicator == .pill {
                Capsule().fill(Color.secondary.opacity(0.12))
            }
        }
    }

    @ViewBuilder
    private func marker(_ selected: Bool) -> some View {
        if selected {
            switch indicator {
            case .pill:
                Capsule()
                    .fill(color)
                    .matchedGeometryEffect(id: "indicator", in: namespace)
            case .underline:
                VStack {
                    Spacer()
                    Capsule()
                        .fill(color)
                        .frame(height: 3)
                        .padding(.horizontal, 10)
                }
                .matchedGeometryEffect(id: "indicator", in: namespace)
            }
        }
    }
}

public extension SlidingTabs where Value == String {
    /// Tabs whose options are their own titles.
    ///
    /// ```swift
    /// SlidingTabs(["Day", "Week", "Month"], selection: $range)
    /// ```
    init(_ options: [String], selection: Binding<String>, indicator: Indicator = .pill, color: Color = .accentColor) {
        self.init(options, selection: selection, indicator: indicator, color: color) { $0 }
    }

    @available(*, deprecated, renamed: "init(_:selection:indicator:color:)")
    init(_ options: [String], selection: Binding<String>, indicator: Indicator = .pill, tint: Color) {
        self.init(options, selection: selection, indicator: indicator, color: tint) { $0 }
    }
}

#Preview {
    struct Demo: View {
        @State private var range = "Week"
        var body: some View {
            VStack(spacing: 24) {
                SlidingTabs(["Day", "Week", "Month"], selection: $range)
                SlidingTabs(["Day", "Week", "Month"], selection: $range, indicator: .underline)
            }
        }
    }
    return Demo().padding()
}
```

---

## Reading Progress (`reading-progress`)

A bar along the top fills as you scroll through the content.

- API: `ReadingProgressScrollView { … }`
- Component: Shape
- Trigger: Scroll
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/ReadingProgress.swift
- Files: Sources/MotionLibrary/Shape/ReadingProgress.swift

### Usage

```swift
ReadingProgressScrollView(color: .pink) {
    ArticleBody()
}
```

### How it works

Create a SwiftUI ReadingProgressScrollView(color:height:content:) (iOS 17+): a vertical ScrollView whose content gets a top overlay holding a Color.clear layer with a Capsule (4 pt tall, the colour, a soft glow shadow) at its top-leading edge. Drive the layer with visualEffect, not state or a PreferenceKey (preferences don't reliably leave an iOS scroll view): frame = proxy.frame(in: .scrollView), viewport = proxy.bounds(of: .scrollView)?.size, progress = clamp(−frame.minY / (frame.height − viewport.height), 0, 1); scaleEffect(x: viewport.width × progress / frame.width, anchor: .topLeading), then offset(x: −frame.minX, y: −frame.minY) to pin it to the top. Disable hit testing.

### Source

```swift
import SwiftUI

/// A vertical scroll view with a reading-progress bar along its top edge
/// that fills as you scroll from the top to the bottom of the content.
public struct ReadingProgressScrollView<Content: View>: View {
    private let color: Color
    private let height: CGFloat
    private let content: Content

    /// - Parameters:
    ///   - color: Bar colour.
    ///   - height: Bar thickness, in points.
    ///   - content: The scrolling content.
    public init(color: Color = .accentColor, height: CGFloat = 4, @ViewBuilder content: () -> Content) {
        self.color = color
        self.height = height
        self.content = content()
    }

    public var body: some View {
        ScrollView {
            content.overlay(alignment: .top) { bar }
        }
    }

    /// The bar lives inside the scrolled content and is pinned and sized by
    /// `visualEffect`, so it needs no state, no preferences (they don't reach
    /// out of an iOS scroll view reliably) and no extra layout passes.
    private var bar: some View {
        Color.clear
            .overlay(alignment: .topLeading) {
                Capsule()
                    .fill(color)
                    .frame(height: height)
                    .shadow(color: color.opacity(0.5), radius: 4)
            }
            .visualEffect { effect, proxy in
                let frame = proxy.frame(in: .scrollView)
                let viewport = proxy.bounds(of: .scrollView)?.size ?? frame.size
                let scrollable = max(1, frame.height - viewport.height)
                let progress = min(1, max(0, -frame.minY / scrollable))
                return effect
                    // Span the scroll view's width, filled to `progress`.
                    .scaleEffect(x: viewport.width * progress / max(1, frame.width), y: 1, anchor: .topLeading)
                    // Stay pinned to the top of the visible area.
                    .offset(x: -frame.minX, y: -frame.minY)
            }
            .allowsHitTesting(false)
            .accessibilityHidden(true)
    }
}

#Preview {
    ReadingProgressScrollView(color: .pink) {
        VStack(alignment: .leading, spacing: 16) {
            ForEach(0..<30, id: \.self) { index in
                Text("Paragraph \(index + 1). Lorem ipsum dolor sit amet, consectetur adipiscing elit.")
            }
        }
        .padding()
    }
}
```

---

## Aurora (`aurora`)

Soft, blurred colour blobs drift slowly behind your content.

- API: `AuroraBackground()`
- Component: Background
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/AuroraBackground.swift
- Files: Sources/MotionLibrary/Background/AuroraBackground.swift

### Usage

```swift
ZStack {
    AuroraBackground(colors: [.purple, .blue, .teal, .pink])
        .ignoresSafeArea()
    Text("Welcome").font(.largeTitle.bold())
}
```

### How it works

Create a SwiftUI AuroraBackground view (iOS 17+). On a dark base color, draw one large circle per palette color (default purple, blue, teal, pink; 80% opacity, diameter 70% of the larger side). Each circle drifts on its own Lissajous path, x = cos(t·0.6 + phase)·30% width and y = sin(t·0.8 + phase·1.3)·30% height, where phase differs per index. Drive time from TimelineView(.animation) scaled by 0.4 × a speed multiplier (default 1), blur the whole group with a ~60 pt radius, clip to bounds and hide it from accessibility.

### Source

```swift
import SwiftUI

/// Soft, slowly drifting colour blobs, heavily blurred. A calm animated
/// backdrop for onboarding screens, paywalls, or hero sections.
///
/// ```swift
/// ZStack {
///     AuroraBackground()
///         .ignoresSafeArea()
///     Text("Welcome").font(.largeTitle.bold())
/// }
/// ```
public struct AuroraBackground: View {
    private let colors: [Color]
    private let speed: Double
    private let blurRadius: CGFloat
    private let baseColor: Color

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    public init(
        colors: [Color] = [.purple, .blue, .teal, .pink],
        speed: Double = 1,
        blurRadius: CGFloat = 60,
        baseColor: Color = .black
    ) {
        self.colors = colors
        self.speed = speed * 0.4
        self.blurRadius = blurRadius
        self.baseColor = baseColor
    }

    public var body: some View {
        // Reduce Motion: a still frame of the same blobs.
        TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in
            let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * speed
            GeometryReader { geometry in
                blobs(time: time, size: geometry.size)
            }
        }
        .background(baseColor)
        .clipped()
        .accessibilityHidden(true)
    }

    private func blobs(time: Double, size: CGSize) -> some View {
        ZStack {
            ForEach(colors.indices, id: \.self) { index in
                blob(color: colors[index], index: index, time: time, size: size)
            }
        }
        .frame(width: size.width, height: size.height)
        .blur(radius: blurRadius)
    }

    private func blob(color: Color, index: Int, time: Double, size: CGSize) -> some View {
        let phase = Double(index) * 1.7
        let diameter = max(size.width, size.height) * 0.7
        let x = CGFloat(cos(time * 0.6 + phase)) * size.width * 0.3
        let y = CGFloat(sin(time * 0.8 + phase * 1.3)) * size.height * 0.3
        return Circle()
            .fill(color.opacity(0.8))
            .frame(width: diameter, height: diameter)
            .offset(x: x, y: y)
    }
}

#Preview {
    ZStack {
        AuroraBackground()
            .ignoresSafeArea()
        Text("Welcome")
            .font(.largeTitle.bold())
            .foregroundStyle(.white)
    }
}
```

---

## Mesh Gradient (`mesh-gradient`)

A 3×3 MeshGradient whose points drift so the colours flow like liquid.

- API: `AnimatedMeshGradient()`
- Component: Background
- Trigger: Loop
- Requires: iOS 18+ · macOS 15+ · visionOS 2+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/AnimatedMeshGradient.swift
- Files: Sources/MotionLibrary/Background/AnimatedMeshGradient.swift

### Usage

```swift
if #available(iOS 18, *) {
    AnimatedMeshGradient()
        .ignoresSafeArea()
}
```

### How it works

Create a SwiftUI AnimatedMeshGradient view for iOS 18+ (mark it @available(iOS 18, macOS 15, visionOS 2, *)) with nine colours (indigo, purple, pink / blue, purple, orange / teal, mint, yellow by default; fewer are repeated) and a speed. Inside TimelineView(.animation), draw MeshGradient(width: 3, height: 3, points:colors:). Keep the four corners fixed; slide the four edge midpoints along their own edge by sin(t × 2π / period + offset) × 0.2, and move the centre point in both axes by 0.25, giving each a different period between 6 and 10 s so the motion never repeats visibly. Hide it from accessibility, and with Reduce Motion on, pause it.

### Source

```swift
import SwiftUI

/// A 3×3 `MeshGradient` whose inner points drift in slow loops, so the
/// colours flow into each other like liquid.
@available(iOS 18, macOS 15, visionOS 2, *)
public struct AnimatedMeshGradient: View {
    private let colors: [Color]
    private let speed: Double

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - colors: Nine colours, row by row from the top left. Fewer are
    ///     repeated; more are ignored.
    ///   - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast.
    public init(
        colors: [Color] = [
            .indigo, .purple, .pink,
            .blue, .purple, .orange,
            .teal, .mint, .yellow,
        ],
        speed: Double = 1
    ) {
        let palette = colors.isEmpty ? [Color.blue] : colors
        self.colors = (0..<9).map { palette[$0 % palette.count] }
        self.speed = speed
    }

    public var body: some View {
        TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in
            let t = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * speed
            MeshGradient(width: 3, height: 3, points: Self.points(at: t), colors: colors)
        }
        .accessibilityHidden(true)
    }

    /// Corners stay put; edge midpoints slide along their edge and the
    /// centre wanders, each on its own period.
    static func points(at t: Double) -> [SIMD2<Float>] {
        func wave(_ period: Double, _ amount: Double, _ offset: Double = 0) -> Float {
            Float(sin(t * 2 * .pi / period + offset) * amount)
        }
        return [
            [0, 0], [0.5 + wave(7, 0.2), 0], [1, 0],
            [0, 0.5 + wave(9, 0.2, 1)], [0.5 + wave(6, 0.25, 2), 0.5 + wave(8, 0.25)], [1, 0.5 + wave(10, 0.2, 3)],
            [0, 1], [0.5 + wave(8, 0.2, 4), 1], [1, 1],
        ]
    }
}

#Preview {
    if #available(iOS 18, macOS 15, visionOS 2, *) {
        AnimatedMeshGradient()
            .frame(width: 300, height: 200)
            .clipShape(RoundedRectangle(cornerRadius: 24))
    }
}
```

---

## Retro Grid (`retro-grid`)

A synthwave floor: a perspective grid scrolling toward you.

- API: `RetroGrid()`
- Component: Background
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/RetroGrid.swift
- Files: Sources/MotionLibrary/Background/RetroGrid.swift

### Usage

```swift
ZStack {
    Color.black
    RetroGrid(color: .pink)
    Text("NEON").font(.system(size: 60, weight: .black))
}
```

### How it works

Create a SwiftUI RetroGrid view (iOS 17+) with color (pink), spacing (40 pt), speed (a multiplier, default 1, on 0.8 rows per second) and horizon (0.35 of the height). In a Canvas inside TimelineView(.animation): with top = height × horizon and depth = height − top, draw horizontal lines at y = top + depth / z for z = n + 1 − fract(time × 0.8 × speed), n = 0…39, so the bottom edge is z = 1, rows bunch up toward the horizon and every row moves toward the viewer; draw lines from the vanishing point (centre, top) to (centre + k × spacing, bottom) for enough k to cover the width. Stroke the path twice: 3 pt at 35 % opacity for a glow, then 1 pt solid. Mask the view with a vertical gradient that is clear down to the horizon and opaque 45 % lower, so lines fade into the distance. Hide from accessibility; Reduce Motion pauses it.

### Source

```swift
import SwiftUI

/// A synthwave floor: a perspective grid that scrolls toward the viewer and
/// fades into the horizon.
public struct RetroGrid: View {
    private let color: Color
    private let spacing: CGFloat
    private let speed: Double
    private let horizon: CGFloat

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - color: Line colour.
    ///   - spacing: Distance between lines at the bottom edge, in points.
    ///   - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast.
    ///   - horizon: Height of the horizon, as a fraction of the view (0 = top).
    public init(color: Color = .pink, spacing: CGFloat = 40, speed: Double = 1, horizon: CGFloat = 0.35) {
        self.color = color
        self.spacing = spacing
        self.speed = speed * 0.8
        self.horizon = horizon
    }

    public var body: some View {
        TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in
            let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate
            Canvas { context, size in
                draw(in: &context, size: size, time: time)
            }
        }
        .mask {
            LinearGradient(
                stops: [.init(color: .clear, location: horizon), .init(color: .black, location: min(1, horizon + 0.45))],
                startPoint: .top,
                endPoint: .bottom
            )
        }
        .accessibilityHidden(true)
    }

    private func draw(in context: inout GraphicsContext, size: CGSize, time: Double) {
        let top = size.height * horizon
        let depth = size.height - top
        guard depth > 0, spacing > 0 else { return }
        let centre = size.width / 2
        var path = Path()

        // Horizontal lines at depths z = 1, 2, 3…; y = top + depth / z puts
        // z = 1 at the bottom edge and z → ∞ at the horizon. Shifting z by the
        // fractional time moves every row toward the viewer.
        let shift = time * speed
        let scroll = shift - shift.rounded(.down)
        for row in 0..<40 {
            let z = CGFloat(Double(row) + 1 - scroll)
            guard z > 0.05 else { continue }
            let y = top + depth / z
            guard y <= size.height + 1 else { continue }
            path.move(to: CGPoint(x: 0, y: y))
            path.addLine(to: CGPoint(x: size.width, y: y))
        }

        // Lines running into the distance all meet at the vanishing point.
        let columns = Int(size.width / spacing) + 4
        for column in -columns...columns {
            let x = centre + CGFloat(column) * spacing
            path.move(to: CGPoint(x: centre, y: top))
            path.addLine(to: CGPoint(x: x, y: size.height))
        }

        context.stroke(path, with: .color(color.opacity(0.35)), lineWidth: 3)
        context.stroke(path, with: .color(color), lineWidth: 1)
    }
}

#Preview {
    RetroGrid()
        .background(Color(red: 0.08, green: 0.02, blue: 0.15))
        .frame(width: 320, height: 200)
}
```

---

## Border Beam (`border-beam`)

A short comet of light runs around the card's border forever.

- API: `.borderBeam()`
- Component: Background
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/BorderBeam.swift
- Files: Sources/MotionLibrary/Background/BorderBeam.swift

### Usage

```swift
PlanCard()
    .background(.black, in: RoundedRectangle(cornerRadius: 20))
    .borderBeam(cornerRadius: 20)
```

### How it works

Create a SwiftUI .borderBeam(cornerRadius:colors:lineWidth:length:duration:) view modifier (iOS 17+). In an overlay TimelineView(.animation), compute head = fract(time / duration) (4 s per lap). Stroke RoundedRectangle(cornerRadius:, style: .continuous).trim(from: head − length, to: head) with a LinearGradient of the colours (orange, pink, purple), lineWidth 2 pt and round caps; when head − length is negative, also stroke trim(from: 1 + head − length, to: 1) so the beam wraps past the path's start seamlessly. Draw the beam twice, once blurred by 2 × lineWidth underneath for a glow. Pad by lineWidth / 2, disable hit testing and hide from accessibility. With Reduce Motion on, park the beam.

### Source

```swift
import SwiftUI

/// A short comet of light that runs around the view's rounded border forever.
struct BorderBeamModifier: ViewModifier {
    var cornerRadius: CGFloat
    var colors: [Color]
    var lineWidth: CGFloat
    var length: CGFloat
    var duration: Double

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        content.overlay {
            // Reduce Motion: the beam is parked at the top-left corner.
            TimelineView(.animation(paused: reduceMotion)) { timeline in
                let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate
                let head = CGFloat((time / max(0.1, duration)).truncatingRemainder(dividingBy: 1))
                ZStack {
                    beam(head: head).blur(radius: lineWidth * 2)
                    beam(head: head)
                }
            }
            .allowsHitTesting(false)
            .accessibilityHidden(true)
        }
    }

    /// The beam from `head - length` to `head`, split in two when it wraps
    /// past the start of the path.
    private func beam(head: CGFloat) -> some View {
        let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
        let tail = head - length
        let style = StrokeStyle(lineWidth: lineWidth, lineCap: .round)
        let gradient = LinearGradient(colors: colors, startPoint: .topLeading, endPoint: .bottomTrailing)
        return ZStack {
            shape.trim(from: max(0, tail), to: head).stroke(gradient, style: style)
            if tail < 0 {
                shape.trim(from: 1 + tail, to: 1).stroke(gradient, style: style)
            }
        }
        .padding(lineWidth / 2)
    }
}

public extension View {
    /// Sends a beam of light around the view's border, forever.
    ///
    /// ```swift
    /// PlanCard()
    ///     .background(.black, in: RoundedRectangle(cornerRadius: 20))
    ///     .borderBeam(cornerRadius: 20)
    /// ```
    ///
    /// - Parameters:
    ///   - cornerRadius: Match the view's own corner radius.
    ///   - colors: Gradient of the beam.
    ///   - lineWidth: Beam thickness, in points.
    ///   - length: Fraction of the border the beam covers (0…1).
    ///   - duration: Seconds per lap.
    func borderBeam(
        cornerRadius: CGFloat = 16,
        colors: [Color] = [.orange, .pink, .purple],
        lineWidth: CGFloat = 2,
        length: CGFloat = 0.2,
        duration: Double = 4
    ) -> some View {
        modifier(BorderBeamModifier(
            cornerRadius: cornerRadius,
            colors: colors,
            lineWidth: lineWidth,
            length: min(1, max(0.01, length)),
            duration: duration
        ))
    }
}

#Preview {
    RoundedRectangle(cornerRadius: 20, style: .continuous)
        .fill(Color(white: 0.08))
        .frame(width: 240, height: 140)
        .overlay(Text("Pro plan").font(.headline).foregroundStyle(.white))
        .borderBeam(cornerRadius: 20)
        .padding()
}
```

---

## Waves (`waves`)

Layered sine waves roll across the bottom, back layers slower and paler.

- API: `WaveBackground()`
- Component: Background
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/WaveBackground.swift
- Files: Sources/MotionLibrary/Background/WaveBackground.swift

### Usage

```swift
ZStack(alignment: .top) {
    WaveBackground()
        .ignoresSafeArea()
    Text("Ocean").font(.largeTitle.bold()).padding(.top, 80)
}
```

### How it works

Create a SwiftUI WaveBackground view (iOS 17+) with colors (one per layer, back to front: cyan 35 %, blue 45 %, indigo 70 %), amplitude (12 pt) and speed. In a Canvas inside TimelineView(.animation), for each layer with depth d = layer / (count − 1): baseline at height × (0.45 + 0.2 d), wavelength width × (0.9 − 0.3 d), phase = time × (0.6 + 0.5 d), alternating direction per layer. Build a path from the bottom-left corner along y = baseline + sin(x / wavelength × 2π + phase + layer) × amplitude × (0.7 + 0.3 d) sampled every 4 pt, down to the bottom-right corner, close it and fill it with the layer's colour. Hide from accessibility; Reduce Motion pauses it.

### Source

```swift
import SwiftUI

/// Layered sine waves rolling across the bottom of the view, each layer a
/// little slower and paler than the one in front, like an ocean horizon.
public struct WaveBackground: View {
    private let colors: [Color]
    private let amplitude: CGFloat
    private let speed: Double

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - colors: One colour per layer, back to front.
    ///   - amplitude: Wave height, in points.
    ///   - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast.
    public init(
        colors: [Color] = [.cyan.opacity(0.35), .blue.opacity(0.45), .indigo.opacity(0.7)],
        amplitude: CGFloat = 12,
        speed: Double = 1
    ) {
        self.colors = colors
        self.amplitude = amplitude
        self.speed = speed
    }

    public var body: some View {
        TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in
            let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * speed
            Canvas { context, size in
                for (layer, color) in colors.enumerated() {
                    let depth = CGFloat(layer) / CGFloat(max(1, colors.count - 1))
                    // Back layers sit higher, move slower and have longer waves.
                    let baseline = size.height * (0.45 + 0.2 * depth)
                    let wavelength = size.width * (0.9 - 0.3 * depth)
                    let phase = time * (0.6 + 0.5 * depth) * (layer.isMultiple(of: 2) ? 1 : -1)
                    var path = Path()
                    path.move(to: CGPoint(x: 0, y: size.height))
                    for x in stride(from: 0, through: size.width, by: 4) {
                        let angle = Double(x / wavelength) * 2 * .pi + phase + Double(layer)
                        let y = baseline + CGFloat(sin(angle)) * amplitude * (0.7 + 0.3 * depth)
                        path.addLine(to: CGPoint(x: x, y: y))
                    }
                    path.addLine(to: CGPoint(x: size.width, y: size.height))
                    path.closeSubpath()
                    context.fill(path, with: .color(color))
                }
            }
        }
        .accessibilityHidden(true)
    }
}

#Preview {
    WaveBackground()
        .frame(width: 320, height: 200)
        .background(Color.cyan.opacity(0.08))
}
```

---

## Film Grain (`grain`)

Flickering film grain over any view, from a tiled noise texture.

- API: `.grain()`
- Component: Background
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/Grain.swift
- Files: Sources/MotionLibrary/Background/Grain.swift, Sources/MotionLibrary/Support/SeededRandom.swift

### Usage

```swift
LinearGradient(colors: [.orange, .pink], startPoint: .top, endPoint: .bottom)
    .grain()

HeroImage()
    .grain(opacity: 0.2, isAnimated: false)
```

### How it works

Create a SwiftUI .grain(opacity:isAnimated:) view modifier (iOS 17+). Generate a 128 × 128 grey noise CGImage once (8-bit DeviceGray, random bytes from a seeded generator, no interpolation). In an overlay, show it with Image(decorative:scale: 2).resizable(resizingMode: .tile) in a frame 64 pt larger than the view in each direction, clipped, and shift it by a different offset (frame × 37 mod 64, frame × 53 mod 64) 12 times a second using TimelineView(.periodic(by: 1/12)), so the grain flickers like film. Use .blendMode(.overlay) at opacity (0.35), disable hit testing and hide it from accessibility. With Reduce Motion on (or isAnimated false), keep the grain still.

### Source

```swift
import CoreGraphics
import SwiftUI

/// Film grain: a tiled noise texture over the view, re-positioned a few times
/// a second so it flickers like real film.
struct GrainModifier: ViewModifier {
    var opacity: Double
    var isAnimated: Bool

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        content.overlay {
            if let noise = NoiseTexture.image {
                // Reduce Motion: still grain.
                let animated = isAnimated && !reduceMotion
                TimelineView(.periodic(from: .now, by: 1.0 / 12)) { timeline in
                    let frame = animated ? Int(timeline.date.timeIntervalSinceReferenceDate * 12) : 0
                    GeometryReader { geometry in
                        Image(decorative: noise, scale: 2)
                            .resizable(resizingMode: .tile)
                            // One tile larger than the view in each direction so
                            // the random shift never uncovers an edge.
                            .frame(width: geometry.size.width + 64, height: geometry.size.height + 64)
                            .offset(x: -CGFloat(frame * 37 % 64), y: -CGFloat(frame * 53 % 64))
                    }
                    .clipped()
                }
                .blendMode(.overlay)
                .opacity(opacity)
                .allowsHitTesting(false)
                .accessibilityHidden(true)
            }
        }
    }
}

/// A 128×128 grey noise image, generated once.
enum NoiseTexture {
    static let image: CGImage? = {
        let side = 128
        var generator = SplitMix64(seed: 7)
        var pixels = [UInt8](repeating: 0, count: side * side)
        for index in pixels.indices {
            pixels[index] = UInt8(truncatingIfNeeded: generator.next() >> 56)
        }
        guard let provider = CGDataProvider(data: Data(pixels) as CFData) else { return nil }
        return CGImage(
            width: side,
            height: side,
            bitsPerComponent: 8,
            bitsPerPixel: 8,
            bytesPerRow: side,
            space: CGColorSpaceCreateDeviceGray(),
            bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue),
            provider: provider,
            decode: nil,
            shouldInterpolate: false,
            intent: .defaultIntent
        )
    }()
}

public extension View {
    /// Lays flickering film grain over the view.
    ///
    /// ```swift
    /// LinearGradient(colors: [.orange, .pink], startPoint: .top, endPoint: .bottom)
    ///     .grain()
    ///
    /// HeroImage()
    ///     .grain(opacity: 0.25, isAnimated: false)
    /// ```
    ///
    /// - Parameters:
    ///   - opacity: Strength of the grain (0…1).
    ///   - isAnimated: Re-rolls the grain 12 times a second when true.
    func grain(opacity: Double = 0.35, isAnimated: Bool = true) -> some View {
        modifier(GrainModifier(opacity: opacity, isAnimated: isAnimated))
    }
}

#Preview {
    LinearGradient(colors: [.orange, .pink, .purple], startPoint: .topLeading, endPoint: .bottomTrailing)
        .grain()
        .frame(width: 300, height: 200)
}

// MARK: - SeededRandom.swift
// Needed by this effect: save it as its own file next to the one above.

// Shared by the particle and background effects that need stable random
// layouts. Copy this file along with any effect that uses `SplitMix64`.

import SwiftUI

/// Small deterministic random generator, so particle layouts are stable
/// between launches and previews.
struct SplitMix64: RandomNumberGenerator {
    private var state: UInt64

    init(seed: UInt64) { state = seed }

    mutating func next() -> UInt64 {
        state &+= 0x9E37_79B9_7F4A_7C15
        var z = state
        z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
        z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
        return z ^ (z >> 31)
    }

    /// A Double in 0..<1.
    mutating func unit() -> Double { Double.random(in: 0..<1, using: &self) }
}

extension CGFloat {
    /// Wraps the value into 0..<length.
    func wrapped(to length: CGFloat) -> CGFloat {
        let r = truncatingRemainder(dividingBy: length)
        return r < 0 ? r + length : r
    }
}
```

---

## Flashlight (`flashlight`)

The view goes dark except a soft circle of light around the pointer.

- API: `.flashlight()`
- Component: Background
- Trigger: Hover / Drag
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/Flashlight.swift
- Files: Sources/MotionLibrary/Background/Flashlight.swift

### Usage

```swift
TreasureMap()
    .flashlight(radius: 100)
```

### How it works

Create a SwiftUI .flashlight(radius:darkness:) view modifier (iOS 17+). Track the pointer with .onContinuousHover(coordinateSpace: .local) and a finger with a simultaneous DragGesture(minimumDistance: 0), storing the location in @State (nil on exit) plus the last location. In an overlay GeometryReader, draw a RadialGradient from clear to black at darkness (0.85) opacity, centred on the location as a UnitPoint, from startRadius 0.55 × radius to endRadius radius (90 pt), so everything outside the circle is dark with a soft edge. Fade the whole overlay out when the pointer leaves (lights back on) with easeOut(0.4 s), and follow the pointer with .interactiveSpring(response: 0.18). Disable hit testing. With Reduce Motion on, move without animation.

### Source

```swift
import SwiftUI

/// Darkens the view except for a soft circle of light around the pointer (or
/// a dragging finger), like exploring a dark room with a torch.
struct FlashlightModifier: ViewModifier {
    var radius: CGFloat
    var darkness: Double

    @State private var location: CGPoint?
    @State private var lastLocation: CGPoint?
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        content
            .overlay {
                GeometryReader { geometry in
                    let size = geometry.size
                    let point = location ?? lastLocation ?? CGPoint(x: size.width / 2, y: size.height / 2)
                    RadialGradient(
                        colors: [.black.opacity(0), .black.opacity(darkness)],
                        center: UnitPoint(x: size.width > 0 ? point.x / size.width : 0.5,
                                          y: size.height > 0 ? point.y / size.height : 0.5),
                        startRadius: radius * 0.55,
                        endRadius: radius
                    )
                    // Lights come back on when the pointer leaves.
                    .opacity(location == nil ? 0 : 1)
                }
                .allowsHitTesting(false)
                .accessibilityHidden(true)
            }
            .onContinuousHover(coordinateSpace: .local) { phase in
                switch phase {
                case let .active(point): move(to: point)
                case .ended: release()
                }
            }
            .simultaneousGesture(
                DragGesture(minimumDistance: 0, coordinateSpace: .local)
                    .onChanged { move(to: $0.location) }
                    .onEnded { _ in release() }
            )
    }

    private func move(to point: CGPoint) {
        // Reduce Motion: the light jumps with the pointer instead of trailing it.
        withAnimation(reduceMotion ? nil : .interactiveSpring(response: 0.18, dampingFraction: 0.9)) {
            location = point
            lastLocation = point
        }
    }

    private func release() {
        withAnimation(.easeOut(duration: 0.4)) { location = nil }
    }
}

public extension View {
    /// Dims the view except around the pointer or finger.
    ///
    /// ```swift
    /// TreasureMap()
    ///     .flashlight(radius: 100)
    /// ```
    ///
    /// - Parameters:
    ///   - radius: Radius of the lit circle, in points.
    ///   - darkness: Opacity of the darkness outside the light (0…1).
    func flashlight(radius: CGFloat = 90, darkness: Double = 0.85) -> some View {
        modifier(FlashlightModifier(radius: radius, darkness: darkness))
    }
}

#Preview {
    LinearGradient(colors: [.yellow, .orange, .red], startPoint: .topLeading, endPoint: .bottomTrailing)
        .overlay(Text("Find me").font(.largeTitle.bold()))
        .frame(width: 300, height: 200)
        .flashlight()
}
```

---

## Lava Lamp (`lava-lamp`)

Soft blobs drift up and down, stretching and merging like hot wax.

- API: `LavaLamp()`
- Component: Background
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/LavaLamp.swift
- Files: Sources/MotionLibrary/Background/LavaLamp.swift

### Usage

```swift
LavaLamp(colors: [.yellow, .orange, .pink])
    .ignoresSafeArea()
```

### How it works

Create a SwiftUI LavaLamp(colors:background:blobs:speed:) view (iOS 17+). In TimelineView(.animation), fill a vertical LinearGradient of the colours, masked by a Canvas that adds .alphaThreshold(min: 0.5, color: .black) then .blur(radius: 7 % of the smaller side), and in drawLayer fills 7 black ellipses: blob i (seed = i × 1.618) at x = width × (0.2 + 0.6 × (0.5 + 0.5 sin(seed × 3.1 + t × 0.13))), y = height × (0.5 + 0.48 sin(t × (0.18 + 0.05 × (i % 3)) + seed × 2.3)), radius = min side × (0.11 + 0.07 × (0.5 + 0.5 sin(seed × 5.7))), stretched vertically by 1 + 0.25 |cos(t × 0.2 + seed × 2.3)|, plus wide pools of wax at the bottom and top. The threshold fuses the blurred blobs into liquid shapes (metaballs). Put the background colour (deep purple) behind. With Reduce Motion on, show one still frame.

### Source

```swift
import SwiftUI

/// A lava lamp: soft blobs drift up and down, stretching and merging when
/// they meet. The blobs are blurred and cut back with an alpha threshold
/// (metaballs), then used as a mask over a gradient.
public struct LavaLamp: View {
    private let colors: [Color]
    private let background: Color
    private let blobs: Int
    private let speed: Double

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - colors: Gradient of the wax, top to bottom.
    ///   - background: Colour behind the wax.
    ///   - blobs: Number of blobs.
    ///   - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast.
    public init(colors: [Color] = [.yellow, .orange, .pink], background: Color = Color(red: 0.16, green: 0.05, blue: 0.25), blobs: Int = 7, speed: Double = 1) {
        self.colors = colors
        self.background = background
        self.blobs = max(1, blobs)
        self.speed = speed
    }

    public var body: some View {
        TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in
            // Reduce Motion: a still frame.
            let time = reduceMotion ? 12 : timeline.date.timeIntervalSinceReferenceDate * speed
            LinearGradient(colors: colors, startPoint: .top, endPoint: .bottom)
                .mask {
                    Canvas { context, size in
                        context.addFilter(.alphaThreshold(min: 0.5, color: .black))
                        context.addFilter(.blur(radius: min(size.width, size.height) * 0.07))
                        context.drawLayer { layer in
                            let unit = min(size.width, size.height)
                            for index in 0..<blobs {
                                let seed = Double(index) * 1.618
                                let x = size.width * (0.2 + 0.6 * (0.5 + 0.5 * sin(seed * 3.1 + time * 0.13)))
                                let y = size.height * (0.5 + 0.48 * sin(time * (0.18 + 0.05 * Double(index % 3)) + seed * 2.3))
                                let r = unit * (0.11 + 0.07 * (0.5 + 0.5 * sin(seed * 5.7)))
                                // Blobs stretch vertically while they move fastest.
                                let stretch = 1 + 0.25 * abs(cos(time * 0.2 + seed * 2.3))
                                layer.fill(Path(ellipseIn: CGRect(x: x - r, y: y - r * stretch, width: r * 2, height: r * 2 * stretch)), with: .color(.black))
                            }
                            // Pools of wax at the bottom and top of the lamp.
                            layer.fill(Path(ellipseIn: CGRect(x: -size.width * 0.1, y: size.height * 0.9, width: size.width * 1.2, height: size.height * 0.3)), with: .color(.black))
                            layer.fill(Path(ellipseIn: CGRect(x: size.width * 0.1, y: -size.height * 0.22, width: size.width * 0.8, height: size.height * 0.28)), with: .color(.black))
                        }
                    }
                }
                .background(background)
        }
        .accessibilityHidden(true)
    }
}

#Preview {
    LavaLamp()
        .frame(width: 200, height: 320)
        .clipShape(RoundedRectangle(cornerRadius: 30))
}
```

---

## Matrix Rain (`matrix-rain`)

Columns of glyphs rain down with bright heads and fading trails.

- API: `MatrixRain()`
- Component: Background
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/MatrixRain.swift
- Files: Sources/MotionLibrary/Background/MatrixRain.swift

### Usage

```swift
MatrixRain(color: .green)
    .background(.black)
    .ignoresSafeArea()
```

### How it works

Create a SwiftUI MatrixRain(color:fontSize:speed:) view (iOS 17+) drawn in a Canvas inside TimelineView(.animation). Resolve every glyph (half-width katakana plus digits, monospaced, 14 pt) once per frame in the trail colour and in white. Columns are fontSize × 0.9 wide, rows fontSize × 1.1 tall. For each column, a stable random seed sets the trail length (8–22), the speed (6–16 rows/s) and a phase; the head row = (time × speed + seed × 97) mod (rows + length). Draw the head white at full opacity and each trail cell below it with opacity 0.85 × (1 − offset / length), picking the glyph from a hash of (column, row, floor(time × (2 + seed × 4))) so characters flicker. With Reduce Motion on, show one still frame.

### Source

```swift
import SwiftUI

/// Falling columns of glyphs, like the digital rain from the films: each
/// column has a bright head and a fading trail, and characters flicker as
/// they fall.
public struct MatrixRain: View {
    private let color: Color
    private let fontSize: CGFloat
    private let speed: Double

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    private static let glyphs = Array("アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワ0123456789").map(String.init)

    /// - Parameters:
    ///   - color: Colour of the trails. Heads are drawn white.
    ///   - fontSize: Glyph size; also sets the column width.
    ///   - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast.
    public init(color: Color = .green, fontSize: CGFloat = 14, speed: Double = 1) {
        self.color = color
        self.fontSize = fontSize
        self.speed = speed
    }

    public var body: some View {
        TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in
            let time = reduceMotion ? 7 : timeline.date.timeIntervalSinceReferenceDate * speed
            Canvas { context, size in
                let glyphs = Self.glyphs
                let font = Font.system(size: fontSize, weight: .medium, design: .monospaced)
                // Resolve every glyph once per frame, then stamp them.
                let trail = glyphs.map { context.resolve(Text($0).font(font).foregroundStyle(color)) }
                let head = glyphs.map { context.resolve(Text($0).font(font).foregroundStyle(.white)) }
                let columnWidth = fontSize * 0.9
                let rowHeight = fontSize * 1.1
                let columns = Int(size.width / columnWidth) + 1
                let rows = Int(size.height / rowHeight) + 1
                for column in 0..<columns {
                    let seed = Self.hash(column, 0)
                    let length = 8 + Int(seed * 14)
                    let velocity = 6 + seed * 10 // rows per second
                    let cycle = Double(rows + length)
                    let headRow = Int((time * velocity + seed * 97).truncatingRemainder(dividingBy: cycle))
                    for offset in 0..<length {
                        let row = headRow - offset
                        guard row >= 0 && row < rows else { continue }
                        // Characters change a few times a second.
                        let flicker = Int(time * (2 + seed * 4))
                        let pick = Int(Self.hash(column * 131 + row, flicker) * Double(glyphs.count)) % glyphs.count
                        var copy = context
                        copy.opacity = offset == 0 ? 1 : 0.85 * (1 - Double(offset) / Double(length))
                        copy.draw(offset == 0 ? head[pick] : trail[pick], at: CGPoint(x: CGFloat(column) * columnWidth + columnWidth / 2, y: CGFloat(row) * rowHeight + rowHeight / 2))
                    }
                }
            }
        }
        .accessibilityHidden(true)
    }

    /// A stable pseudo-random value in 0..<1.
    static func hash(_ a: Int, _ b: Int) -> Double {
        var x = UInt64(truncatingIfNeeded: a &* 374_761_393 &+ b &* 668_265_263)
        x = (x ^ (x >> 13)) &* 1_274_126_177
        x ^= x >> 16
        return Double(x % 10_000) / 10_000
    }
}

#Preview {
    MatrixRain()
        .frame(width: 300, height: 220)
        .background(.black)
}
```

---

## Interactive Dot Grid (`dot-grid`)

A grid of dots scatters away from your finger and eases back.

- API: `InteractiveDotGrid()`
- Component: Background
- Trigger: Drag / Hover / Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/InteractiveDotGrid.swift
- Files: Sources/MotionLibrary/Background/InteractiveDotGrid.swift

### Usage

```swift
InteractiveDotGrid(spacing: 18)
    .ignoresSafeArea()
```

### How it works

Create a SwiftUI InteractiveDotGrid(spacing:color:highlight:radius:) view (iOS 17+). Track the pointer from a DragGesture(minimumDistance: 0) and onContinuousHover (nil when released). In a Canvas inside TimelineView(.animation), smooth the pointer and a 0…1 strength each frame with exponential easing (k = 1 − e^(−14 dt) while active, strength decays with e^(−6 dt) after release) in a plain reference-type model. Draw a centred grid of dots every spacing points (18 pt): within radius (70 pt) of the pointer, near = (1 − d / radius)² × strength; push the dot away by near × spacing × 1.6, grow it by 2 × near and tint it with the highlight colour above 0.15. At rest, a faint wave sin((column + row) × 0.45 − time × 2) varies the radius by 0.5 pt. With Reduce Motion on, keep the highlight but don't move dots or run the wave.

### Source

```swift
import SwiftUI

/// A grid of dots that scatter away from your finger or pointer and ease
/// back when you let go. At rest a faint wave ripples through the dot sizes.
public struct InteractiveDotGrid: View {
    private let spacing: CGFloat
    private let color: Color
    private let highlight: Color
    private let radius: CGFloat

    @State private var pointer: CGPoint?
    @State private var model = DotGridModel()
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - spacing: Distance between dots, in points.
    ///   - color: Dot colour at rest.
    ///   - highlight: Dot colour near the pointer.
    ///   - radius: How far the pointer's push reaches, in points.
    public init(spacing: CGFloat = 18, color: Color = .secondary.opacity(0.45), highlight: Color = .accentColor, radius: CGFloat = 70) {
        self.spacing = max(4, spacing)
        self.color = color
        self.highlight = highlight
        self.radius = radius
    }

    public var body: some View {
        TimelineView(.animation) { timeline in
            let time = timeline.date.timeIntervalSinceReferenceDate
            Canvas { context, size in
                let state = model.step(towards: pointer)
                let columns = Int(size.width / spacing) + 1
                let rows = Int(size.height / spacing) + 1
                let originX = (size.width - CGFloat(columns - 1) * spacing) / 2
                let originY = (size.height - CGFloat(rows - 1) * spacing) / 2
                for row in 0..<rows {
                    for column in 0..<columns {
                        var p = CGPoint(x: originX + CGFloat(column) * spacing, y: originY + CGFloat(row) * spacing)
                        var near: CGFloat = 0
                        if state.strength > 0.001 {
                            let dx = p.x - state.point.x, dy = p.y - state.point.y
                            let distance = max(0.001, hypot(dx, dy))
                            if distance < radius {
                                near = pow(1 - distance / radius, 2) * state.strength
                                let push = reduceMotion ? 0 : near * spacing * 1.6
                                p.x += dx / distance * push
                                p.y += dy / distance * push
                            }
                        }
                        let wave = reduceMotion ? 0 : 0.5 + 0.5 * sin(Double(column + row) * 0.45 - time * 2)
                        let r = 1.2 + 0.5 * CGFloat(wave) + 2 * near
                        let shading: GraphicsContext.Shading = near > 0.15 ? .color(highlight.opacity(0.4 + 0.6 * Double(near))) : .color(color)
                        context.fill(Path(ellipseIn: CGRect(x: p.x - r, y: p.y - r, width: r * 2, height: r * 2)), with: shading)
                    }
                }
            }
        }
        .contentShape(Rectangle())
        .gesture(
            DragGesture(minimumDistance: 0)
                .onChanged { pointer = $0.location }
                .onEnded { _ in pointer = nil }
        )
        .onContinuousHover { phase in
            switch phase {
            case .active(let location): pointer = location
            case .ended: pointer = nil
            }
        }
        .accessibilityHidden(true)
    }
}

/// Smooths the pointer and the push strength from frame to frame, so dots
/// glide out and back instead of snapping. Not observed: it's only read and
/// written while drawing.
final class DotGridModel {
    private var point: CGPoint = .zero
    private var strength: CGFloat = 0
    private var last = Date()

    func step(towards target: CGPoint?) -> (point: CGPoint, strength: CGFloat) {
        let now = Date()
        let dt = min(0.05, now.timeIntervalSince(last))
        last = now
        let k = CGFloat(1 - exp(-dt * 14))
        if let target {
            if strength < 0.01 { point = target }
            point.x += (target.x - point.x) * k
            point.y += (target.y - point.y) * k
            strength += (1 - strength) * k
        } else {
            strength += (0 - strength) * CGFloat(1 - exp(-dt * 6))
        }
        return (point, strength)
    }
}

#Preview {
    InteractiveDotGrid()
        .frame(width: 300, height: 220)
}
```

---

## Meteors (`meteors`)

Meteors streak across a starry sky with glowing heads and fading tails.

- API: `Meteors()`
- Component: Background
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/Meteors.swift
- Files: Sources/MotionLibrary/Background/Meteors.swift

### Usage

```swift
Meteors(count: 10)
    .background(Color(red: 0.03, green: 0.04, blue: 0.12))
    .ignoresSafeArea()
```

### How it works

Create a SwiftUI Meteors(count:angle:color:showsStars:) view (iOS 17+) drawn in a Canvas inside TimelineView(.animation). Scatter 60 faint static stars (0.4–1.2 pt, 25–65 % opacity) from a stable hash. Each meteor i has a flight time of 1.6–3.8 s and a pause of 0.5–3 s; when in flight, its head moves from a start point spread along the top edge and the side it comes from along the direction (angle 135° in y-down coordinates, so they travel down and to the left) by (width + height) × 1.2 × progress. Stroke its 50–110 pt tail from the tail point to the head with a linear gradient clear → 90 % colour, 1.5 pt wide, round caps, and draw a 5 pt head with a 2 pt blur. With Reduce Motion on, draw the stars only.

### Source

```swift
import SwiftUI

/// Meteors streak diagonally across a starry sky, each with a glowing head
/// and a fading tail, at random intervals.
public struct Meteors: View {
    private let count: Int
    private let angle: Angle
    private let color: Color
    private let showsStars: Bool

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - count: Number of meteors in flight over a cycle.
    ///   - angle: Direction of travel (0° = right, 90° = down).
    ///   - color: Colour of heads and tails.
    ///   - showsStars: Scatter faint static stars behind the meteors.
    public init(count: Int = 10, angle: Angle = .degrees(135), color: Color = .white, showsStars: Bool = true) {
        self.count = max(1, count)
        self.angle = angle
        self.color = color
        self.showsStars = showsStars
    }

    public var body: some View {
        TimelineView(.animation(paused: reduceMotion)) { timeline in
            let time = timeline.date.timeIntervalSinceReferenceDate
            Canvas { context, size in
                if showsStars {
                    for index in 0..<60 {
                        let x = Self.hash(index, 1) * size.width, y = Self.hash(index, 2) * size.height
                        let r = 0.4 + Self.hash(index, 3) * 0.8
                        context.fill(Path(ellipseIn: CGRect(x: x - r, y: y - r, width: r * 2, height: r * 2)), with: .color(color.opacity(0.25 + 0.4 * Self.hash(index, 4))))
                    }
                }
                // Reduce Motion: stars only.
                guard !reduceMotion else { return }
                let direction = CGPoint(x: cos(angle.radians), y: sin(angle.radians))
                let travel = (size.width + size.height) * 1.2
                for index in 0..<count {
                    let duration = 1.6 + Self.hash(index, 5) * 2.2
                    let cycle = duration + 0.5 + Self.hash(index, 6) * 2.5 // flight + pause
                    let local = (time + Self.hash(index, 7) * 10).truncatingRemainder(dividingBy: cycle)
                    guard local < duration else { continue }
                    let progress = local / duration
                    // Start points spread along the top and the side the meteors come from.
                    let spread = Self.hash(index, 8) * (size.width + size.height)
                    let start = direction.x >= 0
                        ? (spread < size.width ? CGPoint(x: spread - size.width * 0.3, y: -20) : CGPoint(x: -20, y: spread - size.width))
                        : (spread < size.width ? CGPoint(x: spread + size.width * 0.3, y: -20) : CGPoint(x: size.width + 20, y: spread - size.width))
                    let head = CGPoint(x: start.x + direction.x * travel * progress, y: start.y + direction.y * travel * progress)
                    let tailLength = 50 + Self.hash(index, 9) * 60
                    let tail = CGPoint(x: head.x - direction.x * tailLength, y: head.y - direction.y * tailLength)
                    var line = Path()
                    line.move(to: tail)
                    line.addLine(to: head)
                    context.stroke(line, with: .linearGradient(Gradient(colors: [color.opacity(0), color.opacity(0.9)]), startPoint: tail, endPoint: head), style: StrokeStyle(lineWidth: 1.5, lineCap: .round))
                    var glow = context
                    glow.addFilter(.blur(radius: 2))
                    glow.fill(Path(ellipseIn: CGRect(x: head.x - 2.5, y: head.y - 2.5, width: 5, height: 5)), with: .color(color))
                }
            }
        }
        .accessibilityHidden(true)
    }

    static func hash(_ a: Int, _ b: Int) -> Double {
        var x = UInt64(truncatingIfNeeded: a &* 73_856_093 ^ b &* 19_349_663 &+ 0x9E37)
        x = (x ^ (x >> 33)) &* 0xff51_afd7_ed55_8ccd
        x ^= x >> 33
        return Double(x % 10_000) / 10_000
    }
}

#Preview {
    Meteors()
        .frame(width: 320, height: 220)
        .background(Color(red: 0.03, green: 0.04, blue: 0.12))
}
```

---

## Flow Field (`flow-field`)

Generative curves follow a shifting vector field with dashes flowing along them.

- API: `FlowField()`
- Component: Background
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/FlowField.swift
- Files: Sources/MotionLibrary/Background/FlowField.swift

### Usage

```swift
FlowField(colors: [.cyan, .blue, .purple, .pink])
    .background(Color(white: 0.06))
    .ignoresSafeArea()
```

### How it works

Create a SwiftUI FlowField(lines:colors:speed:) view (iOS 17+) drawn in a Canvas inside TimelineView(.animation). Define a flow angle field(x, y, t) = (sin(x × 0.012 + t) × cos(y × 0.014 − 0.7t) + sin((x + y) × 0.007 + 0.5t)) × π, with coordinates scaled so the smaller side maps to about 220 units and t = time × 0.25. For each of 140 lines, start at a stable random point and integrate 22 steps of 3.2 pt along the field into a Path. Stroke it in a colour picked from the list at 18 % opacity, then again at 85 % with a dash [5, 40] and dashPhase −((time × 30 + random × 45) mod 45), so short dashes travel along the curves (wrap the phase: a huge dash phase makes Core Graphics hang). With Reduce Motion on, show one still frame.

### Source

```swift
import SwiftUI

/// Generative art: short curves follow a slowly shifting vector field, like
/// wind or water, with dashes travelling along them to show the flow.
public struct FlowField: View {
    private let lines: Int
    private let colors: [Color]
    private let speed: Double

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - lines: Number of curves.
    ///   - colors: Curves are tinted along this list, left to right.
    ///   - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast.
    public init(lines: Int = 140, colors: [Color] = [.cyan, .blue, .purple, .pink], speed: Double = 1) {
        self.lines = max(1, lines)
        self.colors = colors.isEmpty ? [.primary] : colors
        self.speed = speed
    }

    public var body: some View {
        TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in
            let time = reduceMotion ? 3 : timeline.date.timeIntervalSinceReferenceDate * speed
            Canvas { context, size in
                let scale = 220 / max(1, min(size.width, size.height))
                for index in 0..<lines {
                    var p = CGPoint(x: Self.hash(index, 1) * size.width, y: Self.hash(index, 2) * size.height)
                    var path = Path()
                    path.move(to: p)
                    for _ in 0..<22 {
                        let angle = Self.field(Double(p.x) * scale, Double(p.y) * scale, time * 0.25)
                        p.x += CGFloat(cos(angle)) * 3.2
                        p.y += CGFloat(sin(angle)) * 3.2
                        path.addLine(to: p)
                    }
                    let tint = colors[min(colors.count - 1, Int(Self.hash(index, 1) * Double(colors.count)))]
                    context.stroke(path, with: .color(tint.opacity(0.18)), lineWidth: 1)
                    // Travelling dashes show the direction of flow. The phase is
                    // wrapped to one dash period: Core Graphics walks the pattern
                    // up to the phase, so a raw clock value would hang it.
                    let phase = (time * 30 + Self.hash(index, 3) * 45).truncatingRemainder(dividingBy: 45)
                    context.stroke(path, with: .color(tint.opacity(0.85)), style: StrokeStyle(lineWidth: 1.4, lineCap: .round, dash: [5, 40], dashPhase: -CGFloat(phase)))
                }
            }
        }
        .accessibilityHidden(true)
    }

    /// Flow direction, in radians, at a point: layered sines that drift over time.
    static func field(_ x: Double, _ y: Double, _ t: Double) -> Double {
        let a = sin(x * 0.012 + t) * cos(y * 0.014 - t * 0.7)
        let b = sin((x + y) * 0.007 + t * 0.5)
        return (a + b) * .pi
    }

    static func hash(_ a: Int, _ b: Int) -> Double {
        var x = UInt64(truncatingIfNeeded: a &* 2_246_822_519 &+ b &* 3_266_489_917)
        x = (x ^ (x >> 15)) &* 0x2c1b_3c6d
        x ^= x >> 12
        return Double(x % 10_000) / 10_000
    }
}

#Preview {
    FlowField()
        .frame(width: 320, height: 220)
        .background(Color(white: 0.06))
}
```

---

## Network (`network`)

Drifting points link up with lines when close, and gather at your finger.

- API: `NetworkBackground()`
- Component: Background
- Trigger: Loop / Hover / Drag
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/NetworkBackground.swift
- Files: Sources/MotionLibrary/Background/NetworkBackground.swift

### Usage

```swift
NetworkBackground(color: .cyan)
    .background(.black)
    .ignoresSafeArea()
```

### How it works

Create a SwiftUI NetworkBackground(count:linkDistance:color:isInteractive:) view (iOS 17+): a plexus of drifting points. In TimelineView(.animation) + Canvas, point i starts at a hashed position, moves at 6–20 pt/s in a hashed direction, and wraps around the edges with 20 pt of slack. Join every pair closer than linkDistance (70 pt) with a line of opacity 0.55 × (1 − d / linkDistance) and width 0.6 + 0.6 × that strength, then draw each point as a 3.6 pt dot. Track the pointer with onContinuousHover and a DragGesture(minimumDistance: 0); points within 110 pt are pulled towards it by (1 − d / 110) × 0.45 of the gap, so the web gathers under the finger. With Reduce Motion on, pause the timeline. Hide it from accessibility.

### Source

```swift
import SwiftUI

/// Drifting points that link up with thin lines whenever two come close, like
/// a constellation or a network map. Points near the pointer or finger are
/// pulled towards it, so the web gathers where you touch.
///
/// ```swift
/// NetworkBackground(color: .cyan)
///     .background(.black)
///     .ignoresSafeArea()
/// ```
public struct NetworkBackground: View {
    private let count: Int
    private let linkDistance: CGFloat
    private let color: Color
    private let isInteractive: Bool

    @State private var pointer: CGPoint?
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - count: Number of points.
    ///   - linkDistance: Points closer than this are joined, in points.
    ///   - color: Colour of points and lines.
    ///   - isInteractive: Pull points towards the pointer or a drag.
    public init(count: Int = 46, linkDistance: CGFloat = 70, color: Color = .white, isInteractive: Bool = true) {
        self.count = max(2, count)
        self.linkDistance = linkDistance
        self.color = color
        self.isInteractive = isInteractive
    }

    @available(*, deprecated, renamed: "init(count:linkDistance:color:isInteractive:)")
    public init(count: Int = 46, linkDistance: CGFloat = 70, color: Color = .white, interactive: Bool) {
        self.init(count: count, linkDistance: linkDistance, color: color, isInteractive: interactive)
    }

    public var body: some View {
        TimelineView(.animation(paused: reduceMotion)) { timeline in
            let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate
            Canvas { context, size in
                let points = (0..<count).map { position(of: $0, time: time, size: size) }
                for i in points.indices {
                    for j in (i + 1)..<points.count {
                        let distance = hypot(points[i].x - points[j].x, points[i].y - points[j].y)
                        guard distance < linkDistance else { continue }
                        var line = Path()
                        line.move(to: points[i])
                        line.addLine(to: points[j])
                        let strength = 1 - distance / linkDistance
                        context.stroke(line, with: .color(color.opacity(0.55 * strength)), lineWidth: 0.6 + strength * 0.6)
                    }
                }
                for point in points {
                    context.fill(Path(ellipseIn: CGRect(x: point.x - 1.8, y: point.y - 1.8, width: 3.6, height: 3.6)), with: .color(color.opacity(0.9)))
                }
            }
        }
        .contentShape(Rectangle())
        .onContinuousHover { phase in
            guard isInteractive else { return }
            if case let .active(location) = phase { pointer = location } else { pointer = nil }
        }
        .gesture(isInteractive ? DragGesture(minimumDistance: 0)
            .onChanged { pointer = $0.location }
            .onEnded { _ in pointer = nil } : nil)
        .accessibilityHidden(true)
    }

    private func position(of index: Int, time: Double, size: CGSize) -> CGPoint {
        let speed = 6 + Self.hash(index, 3) * 14 // points per second
        let heading = Self.hash(index, 4) * 2 * .pi
        let x = wrap(Self.hash(index, 1) * size.width + cos(heading) * speed * time, size.width)
        let y = wrap(Self.hash(index, 2) * size.height + sin(heading) * speed * time, size.height)
        var point = CGPoint(x: x, y: y)
        if let pointer {
            let dx = pointer.x - point.x, dy = pointer.y - point.y
            let distance = hypot(dx, dy)
            let reach: CGFloat = 110
            if distance < reach, distance > 0.1 {
                let pull = (1 - distance / reach) * 0.45
                point.x += dx * pull
                point.y += dy * pull
            }
        }
        return point
    }

    /// Wraps into -20…length+20, so points leave one edge and enter the other.
    private func wrap(_ value: Double, _ length: CGFloat) -> CGFloat {
        let span = Double(length) + 40
        let r = (value + 20).truncatingRemainder(dividingBy: span)
        return CGFloat((r < 0 ? r + span : r) - 20)
    }

    private static func hash(_ a: Int, _ b: Int) -> Double {
        var x = UInt64(truncatingIfNeeded: a &* 73_856_093 ^ b &* 19_349_663 &+ 0x51ED)
        x = (x ^ (x >> 33)) &* 0xff51_afd7_ed55_8ccd
        x ^= x >> 33
        return Double(x % 10_000) / 10_000
    }
}

#Preview {
    NetworkBackground(color: .cyan)
        .frame(width: 340, height: 240)
        .background(Color(red: 0.03, green: 0.05, blue: 0.12))
}
```

---

## Topographic Lines (`topographic`)

Contour lines of a shifting landscape flow like a living map.

- API: `TopographicLines()`
- Component: Background
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/TopographicLines.swift
- Files: Sources/MotionLibrary/Background/TopographicLines.swift

### Usage

```swift
TopographicLines(color: .green)
    .background(Color(white: 0.06))
```

### How it works

Create a SwiftUI TopographicLines(levels:scale:color:speed:) view (iOS 17+). In TimelineView(.animation(minimumInterval: 1/30)) + Canvas, sample a height field on an 8 pt grid: h = 0.65 × noise(x + t, y − 0.6t) + 0.35 × noise(2.1x − 0.8t, 2.1y + 7), with x, y in units of scale (90 pt), t = time × 0.12 × speed (a multiplier defaulting to 1), and noise a smoothstep-interpolated value noise from an integer hash. For each of levels (14) thresholds spread over 0.18…0.82, trace the contour with marching squares (16 cases, edge crossings linearly interpolated, saddles drawn as two segments) into one Path and stroke it: every fifth line at 1.4 pt and 85 % opacity, the others 0.7 pt at 45 %. With Reduce Motion on, pause the timeline.

### Source

```swift
import SwiftUI

/// Contour lines of a slowly shifting landscape, like a topographic map
/// coming alive: hills swell and sink and the lines flow around them, with
/// every fifth line drawn heavier.
///
/// ```swift
/// TopographicLines(color: .green)
///     .background(Color(white: 0.06))
/// ```
public struct TopographicLines: View {
    private let levels: Int
    private let scale: Double
    private let color: Color
    private let speed: Double

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - levels: Number of contour lines between the lowest and highest ground.
    ///   - scale: Size of the hills, in points.
    ///   - color: Colour of the lines.
    ///   - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast.
    public init(levels: Int = 14, scale: Double = 90, color: Color = .primary, speed: Double = 1) {
        self.levels = max(1, levels)
        self.scale = max(10, scale)
        self.color = color
        self.speed = speed * 0.12
    }

    public var body: some View {
        TimelineView(.animation(minimumInterval: 1.0 / 30, paused: reduceMotion || speed == 0)) { timeline in
            let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 10_000) * speed
            Canvas { context, size in
                let cell: CGFloat = 8
                let columns = Int(size.width / cell) + 2, rows = Int(size.height / cell) + 2
                // Height field on the grid corners.
                var field = [Double](repeating: 0, count: columns * rows)
                for row in 0..<rows {
                    for column in 0..<columns {
                        let x = Double(column) * cell / scale, y = Double(row) * cell / scale
                        field[row * columns + column] = Self.noise(x + time, y - time * 0.6) * 0.65 + Self.noise(x * 2.1 - time * 0.8, y * 2.1 + 7) * 0.35
                    }
                }
                for level in 1...levels {
                    let threshold = 0.18 + 0.64 * Double(level) / Double(levels + 1)
                    var path = Path()
                    for row in 0..<(rows - 1) {
                        for column in 0..<(columns - 1) {
                            Self.contour(in: &path, field: field, columns: columns, column: column, row: row, cell: cell, threshold: threshold)
                        }
                    }
                    let major = level % 5 == 0
                    context.stroke(path, with: .color(color.opacity(major ? 0.85 : 0.45)), lineWidth: major ? 1.4 : 0.7)
                }
            }
        }
        .accessibilityHidden(true)
    }

    /// Marching squares: the segment(s) where the field crosses `threshold`
    /// inside one grid cell.
    private static func contour(in path: inout Path, field: [Double], columns: Int, column: Int, row: Int, cell: CGFloat, threshold t: Double) {
        let a = field[row * columns + column], b = field[row * columns + column + 1]
        let c = field[(row + 1) * columns + column + 1], d = field[(row + 1) * columns + column]
        let index = (a > t ? 8 : 0) | (b > t ? 4 : 0) | (c > t ? 2 : 0) | (d > t ? 1 : 0)
        guard index != 0, index != 15 else { return }
        let x = CGFloat(column) * cell, y = CGFloat(row) * cell
        func lerp(_ v1: Double, _ v2: Double) -> CGFloat { CGFloat((t - v1) / (v2 - v1)) }
        let top = CGPoint(x: x + cell * lerp(a, b), y: y)
        let right = CGPoint(x: x + cell, y: y + cell * lerp(b, c))
        let bottom = CGPoint(x: x + cell * lerp(d, c), y: y + cell)
        let left = CGPoint(x: x, y: y + cell * lerp(a, d))
        func segment(_ p: CGPoint, _ q: CGPoint) { path.move(to: p); path.addLine(to: q) }
        switch index {
        case 1, 14: segment(left, bottom)
        case 2, 13: segment(bottom, right)
        case 3, 12: segment(left, right)
        case 4, 11: segment(top, right)
        case 5: segment(left, top); segment(bottom, right)
        case 6, 9: segment(top, bottom)
        case 7, 8: segment(left, top)
        case 10: segment(top, right); segment(left, bottom)
        default: break
        }
    }

    /// Smooth value noise in 0…1.
    private static func noise(_ x: Double, _ y: Double) -> Double {
        let ix = floor(x), iy = floor(y)
        let fx = x - ix, fy = y - iy
        let ux = fx * fx * (3 - 2 * fx), uy = fy * fy * (3 - 2 * fy)
        let a = hash(ix, iy), b = hash(ix + 1, iy), c = hash(ix, iy + 1), d = hash(ix + 1, iy + 1)
        return (a + (b - a) * ux) + ((c + (d - c) * ux) - (a + (b - a) * ux)) * uy
    }

    private static func hash(_ x: Double, _ y: Double) -> Double {
        var h = UInt64(bitPattern: Int64(x) &* 374_761_393 &+ Int64(y) &* 668_265_263)
        h = (h ^ (h >> 13)) &* 1_274_126_177
        h ^= h >> 16
        return Double(h % 10_000) / 10_000
    }
}

#Preview {
    TopographicLines(color: .mint)
        .frame(width: 340, height: 240)
        .background(Color(white: 0.06))
}
```

---

## Day & Night Sky (`day-night-sky`)

A sky runs from starry night through dawn, noon and sunset, sun and moon arcing over.

- API: `DayNightSky(time:)`
- Component: Background
- Trigger: Loop / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/DayNightSky.swift
- Files: Sources/MotionLibrary/Background/DayNightSky.swift

### Usage

```swift
DayNightSky()                // loops a day every 16 s
    .ignoresSafeArea()

DayNightSky(time: hour / 24) // follows the time of day
```

### How it works

Create a SwiftUI DayNightSky(time:cycle:) view (iOS 17+). time is 0…1 (0 midnight, 0.25 sunrise, 0.5 noon, 0.75 sunset); nil loops it with TimelineView every cycle (16 s). Draw an Animatable scene in a Canvas: a vertical gradient whose top and horizon colours are interpolated between stops (night navy, deep purple dawn, pink-orange sunrise horizon, bright blue noon, golden afternoon, orange sunset horizon under violet, dusk). Fade in 50 hashed stars near night, twinkling. Put the sun and moon on opposite sides of an arc: x = mid − cos(angle) × 0.45w, y = horizon − sin(angle) × 0.75h, sun angle = (t − 0.25) × 2π, each with a blurred halo; the sun turns orange near the horizon. Finish with a band of dark hills tinted from the sky colour. Animate a fixed time with .easeInOut(duration: 1.2). With Reduce Motion on, hold at early evening (0.7).

### Source

```swift
import SwiftUI

/// A sky that runs through a whole day: deep night with stars, a pink and
/// orange dawn, blue noon, a burning sunset, and back, with the sun and moon
/// arcing across. Loops on its own, or follows a time of day you set.
///
/// ```swift
/// DayNightSky()                 // loops a day every 16 s
/// DayNightSky(time: 0.75)       // fixed at sunset
/// ```
public struct DayNightSky: View {
    private let time: Double?
    private let cycle: Double

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - time: Time of day, 0…1 (0 midnight, 0.25 sunrise, 0.5 noon,
    ///     0.75 sunset). Changes animate. Leave nil to loop.
    ///   - cycle: Seconds per day when looping.
    public init(time: Double? = nil, cycle: Double = 16) {
        self.time = time
        self.cycle = max(1, cycle)
    }

    public var body: some View {
        if let time {
            SkyScene(time: time)
                .animation(.easeInOut(duration: 1.2), value: time)
                .accessibilityHidden(true)
        } else {
            TimelineView(.animation(minimumInterval: 1.0 / 30, paused: reduceMotion)) { timeline in
                // Reduce Motion: hold at a calm early evening.
                let t = reduceMotion ? 0.7 : (timeline.date.timeIntervalSinceReferenceDate / cycle).truncatingRemainder(dividingBy: 1)
                SkyScene(time: t)
            }
            .accessibilityHidden(true)
        }
    }
}

/// The sky at one time of day. Animatable, so a fixed `time` animates
/// through the colours in between.
struct SkyScene: View, Animatable {
    var time: Double

    nonisolated var animatableData: Double {
        get { time }
        set { time = newValue }
    }

    private typealias RGB = (Double, Double, Double)

    /// Sky colours (top, horizon) at times of day.
    private static let stops: [(time: Double, top: RGB, horizon: RGB)] = [
        (0.00, (0.02, 0.03, 0.10), (0.06, 0.08, 0.20)),
        (0.20, (0.05, 0.06, 0.20), (0.25, 0.15, 0.35)),
        (0.27, (0.30, 0.35, 0.65), (0.98, 0.60, 0.45)),
        (0.35, (0.30, 0.60, 0.95), (0.70, 0.85, 1.00)),
        (0.50, (0.20, 0.50, 0.95), (0.65, 0.85, 1.00)),
        (0.68, (0.30, 0.50, 0.90), (0.95, 0.80, 0.60)),
        (0.75, (0.35, 0.25, 0.55), (1.00, 0.45, 0.20)),
        (0.82, (0.08, 0.08, 0.25), (0.40, 0.20, 0.35)),
        (1.00, (0.02, 0.03, 0.10), (0.06, 0.08, 0.20)),
    ]

    var body: some View {
        let t = time - floor(time)
        let (top, horizon) = Self.colours(at: t)
        Canvas { context, size in
            let rect = CGRect(origin: .zero, size: size)
            context.fill(Path(rect), with: .linearGradient(
                Gradient(colors: [Self.color(top), Self.color(horizon)]),
                startPoint: .zero, endPoint: CGPoint(x: 0, y: size.height)))
            // Stars fade in at night and twinkle with the time.
            let night = max(0, 1 - min(abs(t - 0) , abs(t - 1)) / 0.22) // near midnight
            let starOpacity = max(night, max(0, 1 - abs(t - 0.1) / 0.15), max(0, 1 - abs(t - 0.9) / 0.15))
            if starOpacity > 0.01 {
                for index in 0..<50 {
                    let x = Self.hash(index, 1) * size.width, y = Self.hash(index, 2) * size.height * 0.75
                    let r = 0.5 + Self.hash(index, 3) * 1
                    let twinkle = 0.6 + 0.4 * sin(t * 300 + Double(index))
                    context.fill(Path(ellipseIn: CGRect(x: x - r, y: y - r, width: r * 2, height: r * 2)), with: .color(.white.opacity(starOpacity * twinkle * 0.9)))
                }
            }
            // Sun and moon on opposite sides of a wide arc.
            let horizonY = size.height * 0.92
            func orb(at angle: Double, radius: Double, color: Color, glow: Double) {
                let p = CGPoint(x: size.width / 2 - cos(angle) * size.width * 0.45, y: horizonY - sin(angle) * size.height * 0.75)
                guard p.y < horizonY + radius else { return }
                var halo = context
                halo.addFilter(.blur(radius: radius * 0.9))
                halo.fill(Path(ellipseIn: CGRect(x: p.x - radius * 1.6, y: p.y - radius * 1.6, width: radius * 3.2, height: radius * 3.2)), with: .color(color.opacity(glow)))
                context.fill(Path(ellipseIn: CGRect(x: p.x - radius, y: p.y - radius, width: radius * 2, height: radius * 2)), with: .color(color))
            }
            let sunAngle = (t - 0.25) * 2 * .pi
            orb(at: sunAngle, radius: 14, color: Color(red: 1, green: 0.85 - 0.3 * max(0, 1 - sin(sunAngle) * 3), blue: 0.4), glow: 0.6)
            orb(at: sunAngle + .pi, radius: 10, color: Color(red: 0.95, green: 0.95, blue: 0.85), glow: 0.3)
            // A dark band of hills in front.
            var hills = Path()
            hills.move(to: CGPoint(x: 0, y: size.height))
            for step in 0...40 {
                let x = size.width * Double(step) / 40
                hills.addLine(to: CGPoint(x: x, y: horizonY - 6 - 8 * sin(Double(step) * 0.45) - 5 * sin(Double(step) * 1.3)))
            }
            hills.addLine(to: CGPoint(x: size.width, y: size.height))
            hills.closeSubpath()
            context.fill(hills, with: .color(Self.color((top.0 * 0.35, top.1 * 0.35, top.2 * 0.4))))
        }
    }

    private static func colours(at t: Double) -> (RGB, RGB) {
        let upper = stops.firstIndex { $0.time >= t } ?? stops.count - 1
        let lower = max(0, upper - 1)
        let a = stops[lower], b = stops[upper]
        let span = b.time - a.time
        let f = span > 0 ? (t - a.time) / span : 0
        func mix(_ x: RGB, _ y: RGB) -> RGB { (x.0 + (y.0 - x.0) * f, x.1 + (y.1 - x.1) * f, x.2 + (y.2 - x.2) * f) }
        return (mix(a.top, b.top), mix(a.horizon, b.horizon))
    }

    private static func color(_ rgb: RGB) -> Color { Color(red: rgb.0, green: rgb.1, blue: rgb.2) }

    private static func hash(_ a: Int, _ b: Int) -> Double {
        var x = UInt64(truncatingIfNeeded: a &* 73_856_093 ^ b &* 19_349_663 &+ 0x5C1E)
        x = (x ^ (x >> 33)) &* 0xff51_afd7_ed55_8ccd
        x ^= x >> 33
        return Double(x % 10_000) / 10_000
    }
}

#Preview {
    DayNightSky(cycle: 10)
        .frame(width: 340, height: 220)
}
```

---

## Rolling Number (`rolling-number`)

Digits roll like an odometer when the value changes.

- API: `RollingNumber(value:)`
- Component: Number
- Trigger: Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Number/RollingNumber.swift
- Files: Sources/MotionLibrary/Number/RollingNumber.swift

### Usage

```swift
RollingNumber(value: score)
    .font(.system(size: 48, weight: .bold, design: .rounded))
```

### How it works

Create a SwiftUI RollingNumber view (iOS 17+) that displays a number with odometer-style digit rolling. Render Text(value, format:) with monospacedDigit(), apply .contentTransition(.numericText(value:)) so digits roll up when the value increases and down when it decreases, and animate changes with .snappy. Provide initialisers for Int and for Double with a FloatingPointFormatStyle.

### Source

```swift
import SwiftUI

/// A number whose digits roll up or down when the value changes, like an
/// odometer. Built on `contentTransition(.numericText)`.
///
/// ```swift
/// RollingNumber(value: score)
///     .font(.system(size: 48, weight: .bold, design: .rounded))
/// ```
public struct RollingNumber: View {
    private let value: Double
    private let format: FloatingPointFormatStyle<Double>
    private let animation: Animation

    public init(
        value: Double,
        format: FloatingPointFormatStyle<Double> = .number.precision(.fractionLength(0)),
        animation: Animation = .snappy
    ) {
        self.value = value
        self.format = format
        self.animation = animation
    }

    public init(value: Int, animation: Animation = .snappy) {
        self.init(value: Double(value), animation: animation)
    }

    public var body: some View {
        Text(value, format: format)
            .monospacedDigit()
            .contentTransition(.numericText(value: value))
            .animation(animation, value: value)
    }
}

#Preview {
    struct Demo: View {
        @State private var value = 1280
        var body: some View {
            VStack(spacing: 20) {
                RollingNumber(value: value)
                    .font(.system(size: 56, weight: .bold, design: .rounded))
                HStack {
                    Button("-") { value -= Int.random(in: 1...300) }
                    Button("+") { value += Int.random(in: 1...300) }
                }
                .buttonStyle(.bordered)
            }
        }
    }
    return Demo()
}
```

---

## Split-Flap Display (`split-flap`)

Characters flip through the alphabet like an airport departures board.

- API: `SplitFlapText(_:)`
- Component: Number
- Trigger: Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Number/SplitFlap.swift
- Files: Sources/MotionLibrary/Number/SplitFlap.swift

### Usage

```swift
SplitFlapText(departure.time)   // "12:45"
```

### How it works

Create a SwiftUI SplitFlapText(_ text:size:tile:ink:) view (iOS 17+), upper-casing the text, with one flap cell per character in an HStack. Each cell is size × 0.78 wide and size × 1.3 tall (size 36), drawn from four halves of a monospaced bold character on a rounded dark tile, each half clipped to the top or bottom half: static top = next character, static bottom = current, a top flap showing current rotated with rotation3DEffect about the x axis anchored at .bottom, and a bottom flap showing next anchored at .top; a thin dark line covers the seam. When the target changes, after a 0.05 s × index delay, step through the characters of the board alphabet " A…Z0…9:.-/" from current towards the target (at most the last 6 steps); each step folds the top flap 0 → −90° (.easeIn, 0.07 s), then the bottom flap 90° → 0 (.easeOut, 0.07 s), then commits next as current and resets the angles without animation. With Reduce Motion on, change instantly.

### Source

```swift
import SwiftUI

/// A split-flap display, like an airport departures board. When the text
/// changes, each character flips through a few intermediate characters
/// before landing, with the top flap folding down over the bottom half.
public struct SplitFlapText: View {
    private let text: String
    private let size: CGFloat
    private let tile: Color
    private let ink: Color

    /// - Parameters:
    ///   - text: The characters to show. Letters are shown in upper case.
    ///   - size: Font size of each character; tiles are sized from it.
    ///   - tile: Tile colour.
    ///   - ink: Character colour.
    public init(_ text: String, size: CGFloat = 36, tile: Color = Color(white: 0.13), ink: Color = .white) {
        self.text = text.uppercased()
        self.size = size
        self.tile = tile
        self.ink = ink
    }

    public var body: some View {
        let characters = Array(text)
        HStack(spacing: size * 0.08) {
            ForEach(characters.indices, id: \.self) { index in
                FlapCell(target: characters[index], size: size, tile: tile, ink: ink, delay: Double(index) * 0.05)
            }
        }
        .accessibilityElement()
        .accessibilityLabel(text)
    }
}

/// The order a real board cycles through.
private let flapAlphabet = Array(" ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:.-/")

private struct FlapCell: View {
    let target: Character
    let size: CGFloat
    let tile: Color
    let ink: Color
    let delay: Double

    @State private var current: Character = " "
    @State private var next: Character = " "
    @State private var topAngle: Double = 0
    @State private var bottomAngle: Double = 90
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    private let flipTime = 0.07

    var body: some View {
        let width = size * 0.78, height = size * 1.3
        ZStack {
            half(next, top: true)
            half(current, top: false)
            // The flap: the old top folds down…
            half(current, top: true)
                .rotation3DEffect(.degrees(topAngle), axis: (1, 0, 0), anchor: .bottom, perspective: 0.5)
                .opacity(topAngle > -89 ? 1 : 0)
            // …revealing the new bottom as it lands.
            half(next, top: false)
                .rotation3DEffect(.degrees(bottomAngle), axis: (1, 0, 0), anchor: .top, perspective: 0.5)
                .opacity(bottomAngle < 89 ? 1 : 0)
        }
        .frame(width: width, height: height)
        .overlay {
            Rectangle().fill(Color.black.opacity(0.5)).frame(height: max(1, size * 0.03))
        }
        .task(id: target) { await flip(to: target) }
    }

    private func half(_ character: Character, top: Bool) -> some View {
        let width = size * 0.78, height = size * 1.3
        return Text(String(character))
            .font(.system(size: size, weight: .bold, design: .monospaced))
            .foregroundStyle(ink)
            .frame(width: width, height: height)
            .background(tile, in: RoundedRectangle(cornerRadius: size * 0.12))
            .frame(height: height / 2, alignment: top ? .top : .bottom)
            .clipped()
            .frame(height: height, alignment: top ? .top : .bottom)
    }

    /// Steps through up to six characters of the alphabet on the way to `target`.
    private func flip(to target: Character) async {
        guard target != current else { return }
        if reduceMotion {
            current = target
            next = target
            return
        }
        try? await Task.sleep(for: .seconds(delay))
        var steps: [Character] = []
        if let from = flapAlphabet.firstIndex(of: current), let to = flapAlphabet.firstIndex(of: target) {
            var index = from
            while index != to && steps.count < 40 {
                index = (index + 1) % flapAlphabet.count
                steps.append(flapAlphabet[index])
            }
        }
        if steps.isEmpty { steps = [target] }
        for step in steps.suffix(6) {
            guard !Task.isCancelled else { return }
            await flipOnce(to: step)
        }
    }

    private func flipOnce(to character: Character) async {
        next = character
        withAnimation(.easeIn(duration: flipTime)) { topAngle = -90 }
        try? await Task.sleep(for: .seconds(flipTime))
        withAnimation(.easeOut(duration: flipTime)) { bottomAngle = 0 }
        try? await Task.sleep(for: .seconds(flipTime))
        var reset = Transaction()
        reset.disablesAnimations = true
        withTransaction(reset) {
            current = character
            topAngle = 0
            bottomAngle = 90
        }
    }
}

#Preview {
    struct Demo: View {
        @State private var index = 0
        let words = ["12:45", "BOARD", "08:10"]
        var body: some View {
            SplitFlapText(words[index])
                .onTapGesture { index = (index + 1) % words.count }
                .padding()
        }
    }
    return Demo()
}
```

---

## Slot Machine (`slot-machine`)

Number reels spin with motion blur and land one after another.

- API: `SlotMachineNumber(value:)`
- Component: Number
- Trigger: Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Number/SlotMachineNumber.swift
- Files: Sources/MotionLibrary/Number/SlotMachineNumber.swift

### Usage

```swift
SlotMachineNumber(value: score, digits: 3)
```

### How it works

Create a SwiftUI SlotMachineNumber(value:digits:size:) view (iOS 17+) that zero-pads the value and shows one reel per digit. A reel is a VStack of 40 rows (row % 10, heavy rounded monospaced digits, size × 1.25 tall) offset by −index × rowHeight and clipped to one row, on a rounded secondary-12 % tile with a top/bottom shading gradient like a drum. When its digit changes from old to new: jump (no animation) to index old, set a blur of size × 0.06, and animate index to 30 + new with .timingCurve(0.3, 0, 0.2, 1.08) over 0.9 s + 0.25 s × reel order, so reels land left to right with a small overshoot; ease the blur off over the last 30 %, and on completion jump back to index new without animation. With Reduce Motion on, animate straight to the new digit in 0.3 s.

### Source

```swift
import SwiftUI

/// A number shown on slot-machine reels. When the value changes, every reel
/// spins a few full turns, blurred with speed, and the reels land one after
/// another from left to right with a small bounce.
public struct SlotMachineNumber: View {
    private let value: Int
    private let digits: Int
    private let size: CGFloat

    /// - Parameters:
    ///   - value: The number to show. Negative values show as 0.
    ///   - digits: Minimum number of reels; the number is zero-padded.
    ///   - size: Font size of the digits.
    public init(value: Int, digits: Int = 3, size: CGFloat = 40) {
        self.value = max(0, value)
        self.digits = max(1, digits)
        self.size = size
    }

    public var body: some View {
        let text = String(value)
        let padded = String(repeating: "0", count: max(0, digits - text.count)) + text
        let numbers = padded.compactMap(\.wholeNumberValue)
        HStack(spacing: size * 0.12) {
            ForEach(numbers.indices, id: \.self) { index in
                SlotReel(digit: numbers[index], size: size, order: index)
            }
        }
        .accessibilityElement()
        .accessibilityLabel(String(value))
    }
}

private struct SlotReel: View {
    let digit: Int
    let size: CGFloat
    let order: Int

    /// Index into the strip of 40 digits (four turns of 0–9).
    @State private var index: Int = 0
    @State private var isSpinning = false
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    var body: some View {
        let height = size * 1.25
        VStack(spacing: 0) {
            ForEach(0..<40, id: \.self) { row in
                Text("\(row % 10)")
                    .font(.system(size: size, weight: .heavy, design: .rounded))
                    .monospacedDigit()
                    .frame(height: height)
            }
        }
        .offset(y: -CGFloat(index) * height)
        .blur(radius: isSpinning ? size * 0.06 : 0)
        .frame(height: height, alignment: .top)
        .padding(.horizontal, size * 0.12)
        .clipped()
        .background(Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: size * 0.18))
        .overlay {
            // Shading so the strip looks like it wraps around a drum.
            LinearGradient(stops: [
                .init(color: .black.opacity(0.25), location: 0),
                .init(color: .clear, location: 0.3),
                .init(color: .clear, location: 0.7),
                .init(color: .black.opacity(0.25), location: 1),
            ], startPoint: .top, endPoint: .bottom)
            .clipShape(RoundedRectangle(cornerRadius: size * 0.18))
            .allowsHitTesting(false)
        }
        .onAppear { index = digit }
        .onChange(of: digit) { old, new in spin(from: old, to: new) }
    }

    private func spin(from old: Int, to new: Int) {
        guard !reduceMotion else {
            withAnimation(.easeInOut(duration: 0.3)) { index = new }
            return
        }
        // Start from the old digit on the first turn, land on the fourth.
        var reset = Transaction()
        reset.disablesAnimations = true
        withTransaction(reset) { index = old }
        let duration = 0.9 + Double(order) * 0.25
        isSpinning = true
        withAnimation(.timingCurve(0.3, 0, 0.2, 1.08, duration: duration)) {
            index = 30 + new
        } completion: {
            isSpinning = false
            var settle = Transaction()
            settle.disablesAnimations = true
            withTransaction(settle) { index = new }
        }
        Task { @MainActor in
            // Sharpen up a little before the reel stops.
            try? await Task.sleep(for: .seconds(duration * 0.7))
            withAnimation(.easeOut(duration: duration * 0.3)) { isSpinning = false }
        }
    }
}

#Preview {
    struct Demo: View {
        @State private var value = 777
        var body: some View {
            SlotMachineNumber(value: value)
                .onTapGesture { value = Int.random(in: 0...999) }
                .padding()
        }
    }
    return Demo()
}
```

---

## Price Ticker (`price-ticker`)

Digits roll to the new price as the background flashes green or red.

- API: `PriceTicker(value:)`
- Component: Number
- Trigger: Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Number/PriceTicker.swift
- Files: Sources/MotionLibrary/Number/PriceTicker.swift

### Usage

```swift
PriceTicker(value: stock.price)
    .font(.title2.bold())
```

### How it works

Create a SwiftUI PriceTicker(value:fractionDigits:prefix:) view (iOS 17+): an HStack of a 10 pt "arrowtriangle.up.fill" / "arrowtriangle.down.fill" (hidden when flat) and the prefix + value formatted with fractionDigits decimals, monospacedDigit, with .contentTransition(.numericText(value:)) and .snappy(0.35 s) animation so digits roll. On change, record the direction (green up, red down) for the arrow and text colour, and bump a counter that drives keyframeAnimator on a rounded background in that colour: opacity 0 → 0.3 (0.05 s) → 0 (0.9 s), a quick flash. With Reduce Motion on, skip the roll animation and the flash.

### Source

```swift
import SwiftUI

/// A live price: digits roll to the new value, the background flashes green
/// or red, and an arrow shows which way it moved.
public struct PriceTicker: View {
    private let value: Double
    private let fractionDigits: Int
    private let prefix: String

    @State private var direction: Direction = .flat
    @State private var flashes = 0
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    private enum Direction { case up, down, flat }

    /// - Parameters:
    ///   - value: The current price.
    ///   - fractionDigits: Decimal places to show.
    ///   - prefix: Text before the number, such as a currency symbol.
    public init(value: Double, fractionDigits: Int = 2, prefix: String = "$") {
        self.value = value
        self.fractionDigits = fractionDigits
        self.prefix = prefix
    }

    public var body: some View {
        let color: Color = direction == .up ? .green : direction == .down ? .red : .secondary
        HStack(spacing: 6) {
            Image(systemName: direction == .down ? "arrowtriangle.down.fill" : "arrowtriangle.up.fill")
                .font(.system(size: 10))
                .foregroundStyle(color)
                .opacity(direction == .flat ? 0 : 1)
                .contentTransition(.symbolEffect(.replace))
            Text(prefix + value.formatted(.number.precision(.fractionLength(fractionDigits))))
                .monospacedDigit()
                .contentTransition(.numericText(value: value))
                .foregroundStyle(direction == .flat ? Color.primary : color)
        }
        .padding(.horizontal, 10)
        .padding(.vertical, 6)
        .background {
            RoundedRectangle(cornerRadius: 8)
                .fill(color)
                .keyframeAnimator(initialValue: 0.0, trigger: flashes) { view, opacity in
                    view.opacity(opacity)
                } keyframes: { _ in
                    LinearKeyframe(0.3, duration: 0.05)
                    LinearKeyframe(0, duration: 0.9)
                }
        }
        .animation(reduceMotion ? nil : .snappy(duration: 0.35), value: value)
        .onChange(of: value) { old, new in
            direction = new > old ? .up : new < old ? .down : .flat
            if !reduceMotion { flashes += 1 }
        }
    }
}

#Preview {
    struct Demo: View {
        @State private var price = 182.40
        var body: some View {
            PriceTicker(value: price)
                .font(.title2.bold())
                .task {
                    while !Task.isCancelled {
                        try? await Task.sleep(for: .seconds(1.2))
                        price += Double.random(in: -2...2)
                    }
                }
                .padding()
        }
    }
    return Demo()
}
```

---

## Animated Chart (`animated-chart`)

Bars grow up one after another; a line draws itself left to right.

- API: `GrowingBarChart(values:) / DrawingLineChart(values:)`
- Component: Number
- Trigger: Appear / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Number/AnimatedChart.swift
- Files: Sources/MotionLibrary/Number/AnimatedChart.swift

### Usage

```swift
GrowingBarChart(values: weekly)
    .frame(height: 120)

DrawingLineChart(values: trend, color: .pink)
    .frame(height: 120)
```

### How it works

Create two SwiftUI chart views without Swift Charts (iOS 17+). GrowingBarChart(values:color:spacing:): in a GeometryReader, an HStack(alignment: .bottom) of bars with top-rounded UnevenRoundedRectangles filled with the colour's gradient, bar width = width / (n + (n − 1) × spacing) with spacing 0.35; heights are 0 until onAppear sets grown, then height × value / max with .spring(duration: 0.6, bounce: 0.3) delayed 0.06 s × index, and springing to new heights when values change. DrawingLineChart(values:color:duration:): map values into the rect (5–6 pt inset), smooth with midpoint quadratic curves, stroke it 3 pt with round caps trimmed to progress, fill the closed area under it with a colour 35 % → clear gradient at opacity progress, and pop a 9 pt dot with a white ring onto the last point with a bouncy spring once progress reaches 1. In .task(id: values), reset progress to 0 without animation and animate to 1 with .easeInOut(1.2 s). With Reduce Motion on, use short eases.

### Source

```swift
import SwiftUI

/// A bar chart whose bars grow up from the baseline one after another on
/// appear, and spring to their new heights when the values change.
public struct GrowingBarChart: View {
    private let values: [Double]
    private let color: Color
    private let spacing: CGFloat

    @State private var grown = false
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - values: Bar values; the tallest fills the height.
    ///   - color: Bar colour.
    ///   - spacing: Gap between bars, as a fraction of a bar's width.
    public init(values: [Double], color: Color = .accentColor, spacing: CGFloat = 0.35) {
        self.values = values
        self.color = color
        self.spacing = spacing
    }

    public var body: some View {
        let top = max(values.max() ?? 1, 0.0001)
        GeometryReader { geometry in
            let count = CGFloat(max(1, values.count))
            let barWidth = geometry.size.width / (count + (count - 1) * spacing)
            HStack(alignment: .bottom, spacing: barWidth * spacing) {
                ForEach(values.indices, id: \.self) { index in
                    let fraction = grown ? max(0, values[index]) / top : 0
                    UnevenRoundedRectangle(topLeadingRadius: barWidth * 0.25, topTrailingRadius: barWidth * 0.25)
                        .fill(color.gradient)
                        .frame(width: barWidth, height: max(2, geometry.size.height * fraction))
                        .animation(
                            reduceMotion ? .easeOut(duration: 0.3)
                                : .spring(duration: 0.6, bounce: 0.3).delay(grown ? Double(index) * 0.06 : 0),
                            value: grown
                        )
                        .animation(reduceMotion ? nil : .spring(duration: 0.6, bounce: 0.3), value: values[index])
                }
            }
            .frame(maxHeight: .infinity, alignment: .bottom)
        }
        .onAppear { grown = true }
        .accessibilityElement()
        .accessibilityLabel("Bar chart")
        .accessibilityValue(values.map { $0.formatted() }.joined(separator: ", "))
    }
}

/// A line chart that draws itself from left to right, with a gradient area
/// that fades in underneath and a dot that lands on the last point. Redraws
/// when the values change.
public struct DrawingLineChart: View {
    private let values: [Double]
    private let color: Color
    private let duration: Double

    @State private var progress: Double = 0
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - values: The points, left to right.
    ///   - color: Line colour.
    ///   - duration: Seconds to draw the line.
    public init(values: [Double], color: Color = .accentColor, duration: Double = 1.2) {
        self.values = values
        self.color = color
        self.duration = duration
    }

    public var body: some View {
        ZStack {
            ChartLine(values: values, closed: true)
                .fill(LinearGradient(colors: [color.opacity(0.35), color.opacity(0)], startPoint: .top, endPoint: .bottom))
                .opacity(progress)
            ChartLine(values: values, closed: false)
                .trim(from: 0, to: progress)
                .stroke(color, style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round))
            GeometryReader { geometry in
                if let last = ChartLine.points(values, in: CGRect(origin: .zero, size: geometry.size)).last {
                    Circle()
                        .fill(color)
                        .frame(width: 9, height: 9)
                        .overlay(Circle().stroke(.white, lineWidth: 2))
                        .position(last)
                        .scaleEffect(progress >= 1 ? 1 : 0.01, anchor: UnitPoint(x: last.x / max(1, geometry.size.width), y: last.y / max(1, geometry.size.height)))
                        .animation(.spring(duration: 0.35, bounce: 0.5), value: progress >= 1)
                }
            }
        }
        .task(id: values) {
            var reset = Transaction()
            reset.disablesAnimations = true
            withTransaction(reset) { progress = 0 }
            try? await Task.sleep(for: .milliseconds(16))
            withAnimation(reduceMotion ? .easeOut(duration: 0.3) : .easeInOut(duration: duration)) { progress = 1 }
        }
        .accessibilityElement()
        .accessibilityLabel("Line chart")
        .accessibilityValue(values.map { $0.formatted() }.joined(separator: ", "))
    }
}

struct ChartLine: Shape {
    var values: [Double]
    var closed: Bool

    static func points(_ values: [Double], in rect: CGRect) -> [CGPoint] {
        guard values.count > 1, let low = values.min(), let high = values.max() else { return [] }
        let range = max(high - low, 0.0001)
        // A little headroom so the stroke and dot aren't clipped.
        let inset = rect.insetBy(dx: 5, dy: 6)
        return values.enumerated().map { index, value in
            CGPoint(
                x: inset.minX + inset.width * CGFloat(index) / CGFloat(values.count - 1),
                y: inset.maxY - inset.height * CGFloat((value - low) / range)
            )
        }
    }

    nonisolated func path(in rect: CGRect) -> Path {
        let points = Self.points(values, in: rect)
        var path = Path()
        guard let first = points.first, let last = points.last else { return path }
        path.move(to: first)
        // Smooth the line with midpoint quadratic curves.
        for index in 1..<points.count {
            let previous = points[index - 1], point = points[index]
            let mid = CGPoint(x: (previous.x + point.x) / 2, y: (previous.y + point.y) / 2)
            path.addQuadCurve(to: mid, control: previous)
            if index == points.count - 1 { path.addQuadCurve(to: point, control: point) }
        }
        if closed {
            path.addLine(to: CGPoint(x: last.x, y: rect.maxY))
            path.addLine(to: CGPoint(x: first.x, y: rect.maxY))
            path.closeSubpath()
        }
        return path
    }
}

#Preview {
    VStack(spacing: 30) {
        GrowingBarChart(values: [3, 7, 4, 9, 6, 8]).frame(width: 220, height: 100)
        DrawingLineChart(values: [3, 5, 4, 8, 6, 9, 7, 11]).frame(width: 220, height: 100)
    }
    .padding()
}
```

---

## Needle Gauge (`needle-gauge`)

A speedometer needle swings to the value, overshoots and settles.

- API: `NeedleGauge(value:)`
- Component: Number
- Trigger: Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Number/NeedleGauge.swift
- Files: Sources/MotionLibrary/Number/NeedleGauge.swift

### Usage

```swift
NeedleGauge(value: cpuLoad)
    .frame(width: 200, height: 200)
```

### How it works

Create a SwiftUI NeedleGauge(value:colors:label:) view (iOS 17+), value 0…1, square. Draw a 240° arc (from 150° to 30° clockwise, radius 42 % of the side, line width 8 %, round caps): a secondary-15 % track, and on top an animatable arc Shape trimmed to the value and stroked with an AngularGradient of green, yellow, red over the same angles. Add 11 tick capsules (longer every fifth) rotated from −120° to +120°, a primary-coloured needle capsule rotated −120° + 240° × value, and a hub circle. Animate the needle with .spring(duration: 0.9, bounce: 0.45) so it overshoots and settles. Below the hub, show the label or the percentage with .contentTransition(.numericText). With Reduce Motion on, use a 0.3 s ease.

### Source

```swift
import SwiftUI

/// A speedometer-style gauge: a 240° arc that fills with a gradient, tick
/// marks, and a needle that swings to the value on a spring, overshooting a
/// little before it settles.
public struct NeedleGauge: View {
    private let value: Double
    private let colors: [Color]
    private let label: String?

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    private let sweep = 240.0

    /// - Parameters:
    ///   - value: 0 (needle far left) to 1 (far right). Clamped.
    ///   - colors: Gradient along the arc, from 0 to 1.
    ///   - label: Optional caption under the needle; defaults to a percentage.
    public init(value: Double, colors: [Color] = [.green, .yellow, .red], label: String? = nil) {
        self.value = min(1, max(0, value))
        self.colors = colors
        self.label = label
    }

    public var body: some View {
        GeometryReader { geometry in
            let side = min(geometry.size.width, geometry.size.height)
            let lineWidth = side * 0.08
            ZStack {
                arc(to: 1)
                    .stroke(Color.secondary.opacity(0.15), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
                arc(to: value)
                    .stroke(
                        AngularGradient(colors: colors, center: .center, startAngle: .degrees(90 + (360 - sweep) / 2), endAngle: .degrees(90 + (360 - sweep) / 2 + sweep)),
                        style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
                    )
                ForEach(0...10, id: \.self) { tick in
                    Capsule()
                        .fill(Color.secondary.opacity(0.6))
                        .frame(width: tick.isMultiple(of: 5) ? 2.5 : 1.5, height: side * (tick.isMultiple(of: 5) ? 0.08 : 0.05))
                        .offset(y: -side * 0.32)
                        .rotationEffect(.degrees(-sweep / 2 + sweep * Double(tick) / 10))
                }
                Capsule()
                    .fill(Color.primary)
                    .frame(width: side * 0.025, height: side * 0.36)
                    .offset(y: -side * 0.14)
                    .rotationEffect(.degrees(-sweep / 2 + sweep * value))
                    .animation(reduceMotion ? .easeInOut(duration: 0.3) : .spring(duration: 0.9, bounce: 0.45), value: value)
                Circle()
                    .fill(Color.primary)
                    .frame(width: side * 0.08, height: side * 0.08)
                Text(label ?? "\(Int((value * 100).rounded()))%")
                    .font(.system(size: side * 0.11, weight: .bold, design: .rounded))
                    .monospacedDigit()
                    .contentTransition(.numericText(value: value))
                    .animation(.snappy, value: value)
                    .offset(y: side * 0.24)
            }
            .frame(width: side, height: side)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
        .aspectRatio(1, contentMode: .fit)
        .accessibilityElement()
        .accessibilityLabel("Gauge")
        .accessibilityValue(label ?? "\(Int((value * 100).rounded())) percent")
    }

    /// The arc from the left end to `fraction` of the sweep.
    private func arc(to fraction: Double) -> some Shape {
        GaugeArc(sweep: sweep, fraction: fraction)
    }
}

struct GaugeArc: Shape {
    var sweep: Double
    var fraction: Double

    nonisolated var animatableData: Double {
        get { fraction }
        set { fraction = newValue }
    }

    nonisolated func path(in rect: CGRect) -> Path {
        let radius = min(rect.width, rect.height) * 0.42
        let start = 90 + (360 - sweep) / 2
        var path = Path()
        path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: radius, startAngle: .degrees(start), endAngle: .degrees(start + sweep * fraction), clockwise: false)
        return path
    }
}

#Preview {
    struct Demo: View {
        @State private var value = 0.3
        var body: some View {
            NeedleGauge(value: value)
                .frame(width: 200, height: 200)
                .onTapGesture { value = Double.random(in: 0...1) }
        }
    }
    return Demo()
}
```

---

## Contribution Grid (`contribution-grid`)

Activity squares pop in column by column, shaded by how busy each day was.

- API: `ContributionGrid(values:)`
- Component: Number
- Trigger: Appear / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Number/ContributionGrid.swift
- Files: Sources/MotionLibrary/Number/ContributionGrid.swift

### Usage

```swift
ContributionGrid(values: commitsPerDay, color: .green)
```

### How it works

Create a SwiftUI ContributionGrid(values:rows:color:cellSize:spacing:) view (iOS 17+) like a contributions calendar: values fill columns of rows (7) top to bottom, left to right, as 11 pt squares with 3 pt gaps and corner radius 0.22 × size. Shade each square by its share of the largest value, rounded up to quarters: 0 is secondary at 15 %, otherwise the colour at 0.25 + 0.75 × level. On appear, scale each square from 0.2 and fade it in with .spring(duration: 0.4, bounce: 0.45) delayed by 0.035 s per column + 0.02 s per row, so it sweeps in diagonally; changes of shade use the same staggered spring. With Reduce Motion on, no scaling and a plain 0.3 s ease. Make it one accessibility element whose value is "N of M days active".

### Source

```swift
import SwiftUI

/// A grid of small squares shaded by value, like a contributions calendar.
/// On appear the squares pop in column by column in a diagonal wave, and
/// when the values change the new shades ripple through the same way.
///
/// ```swift
/// ContributionGrid(values: commitsPerDay, color: .green)
/// ```
public struct ContributionGrid: View {
    private let values: [Double]
    private let rows: Int
    private let color: Color
    private let cellSize: CGFloat
    private let spacing: CGFloat

    @State private var shown = false
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - values: One value per day, filled top to bottom, then left to right.
    ///     Shades are relative to the largest value.
    ///   - rows: Squares per column (7 for a week).
    ///   - color: Colour of the busiest squares.
    ///   - cellSize: Side of a square, in points.
    ///   - spacing: Gap between squares.
    public init(values: [Double], rows: Int = 7, color: Color = .green, cellSize: CGFloat = 11, spacing: CGFloat = 3) {
        self.values = values
        self.rows = max(1, rows)
        self.color = color
        self.cellSize = cellSize
        self.spacing = spacing
    }

    public var body: some View {
        let columns = Int(ceil(Double(values.count) / Double(rows)))
        let peak = max(values.max() ?? 0, .leastNonzeroMagnitude)
        HStack(alignment: .top, spacing: spacing) {
            ForEach(0..<columns, id: \.self) { column in
                VStack(spacing: spacing) {
                    ForEach(0..<rows, id: \.self) { row in
                        let index = column * rows + row
                        if index < values.count {
                            let level = shade(values[index] / peak)
                            RoundedRectangle(cornerRadius: cellSize * 0.22, style: .continuous)
                                .fill(level == 0 ? Color.secondary.opacity(0.15) : color.opacity(0.25 + 0.75 * level))
                                .frame(width: cellSize, height: cellSize)
                                .scaleEffect(shown || reduceMotion ? 1 : 0.2)
                                .opacity(shown ? 1 : 0)
                                .animation(wave(column: column, row: row), value: shown)
                                .animation(wave(column: column, row: row), value: level)
                        }
                    }
                }
            }
        }
        .onAppear { shown = true }
        .accessibilityElement()
        .accessibilityLabel("Activity grid")
        .accessibilityValue("\(values.filter { $0 > 0 }.count) of \(values.count) days active")
    }

    /// Rounds a 0…1 share to one of five shades, like a contributions calendar.
    private func shade(_ share: Double) -> Double {
        guard share > 0 else { return 0 }
        return min(1, ceil(share * 4) / 4)
    }

    private func wave(column: Int, row: Int) -> Animation {
        if reduceMotion { return .easeInOut(duration: 0.3) }
        return .spring(duration: 0.4, bounce: 0.45).delay(Double(column) * 0.035 + Double(row) * 0.02)
    }
}

#Preview {
    struct Demo: View {
        @State private var seed = 1
        var body: some View {
            ContributionGrid(values: (0..<(7 * 20)).map { index in
                let x = sin(Double(index * seed) * 12.9898) * 43758.5453
                let r = x - floor(x)
                return r < 0.3 ? 0 : r * 10
            })
            .onTapGesture { seed += 1 }
        }
    }
    return Demo()
}
```

---

## Analog Clock (`analog-clock`)

A clock with a sweeping second hand; set a time and the hands spring to it.

- API: `AnalogClock(date:)`
- Component: Number
- Trigger: Loop / Value change
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Number/AnalogClock.swift
- Files: Sources/MotionLibrary/Number/AnalogClock.swift

### Usage

```swift
AnalogClock()                       // live
    .frame(width: 160, height: 160)

AnalogClock(date: meeting, accent: .pink)
```

### How it works

Create a SwiftUI AnalogClock(date:color:accent:timeZone:) view (iOS 17+) drawn in a Canvas at a 1:1 aspect ratio. Convert the date to local seconds = timeIntervalSinceReferenceDate + the zone's offset, so the hands only ever move forward. Face: a ring at 25 % opacity and 60 ticks (every fifth 2.5 pt and longer). Hands from s = seconds mod 43 200: hour angle s / 43 200 × 2π (length 0.5r, width 4.5 % of the side), minute (s mod 3600) / 3600 × 2π (0.75r, 3 %), second (s mod 60) / 60 × 2π in the accent colour with a short tail and a centre dot. With no date, update from TimelineView(.animation) so the second hand sweeps; with a date, make the face Animatable on seconds and animate changes with .spring(duration: 0.9, bounce: 0.3) so the hands turn forward to the new time. With Reduce Motion on, tick once per second with TimelineView(.periodic). Label it "Clock" with the time as its value.

### Source

```swift
import SwiftUI

/// A clock face with a smoothly sweeping second hand. Live by default; give
/// it a date and the hands spring forward to the new time when it changes,
/// handy for time zones, alarms or "time travel" pickers.
///
/// ```swift
/// AnalogClock()                         // live
/// AnalogClock(date: meetingTime)        // hands spring to each new time
///     .frame(width: 160, height: 160)
/// ```
public struct AnalogClock: View {
    private let date: Date?
    private let color: Color
    private let accent: Color
    private let timeZone: TimeZone

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - date: The time to show. Leave nil for the current time, live.
    ///   - color: Colour of the face markings and the hour and minute hands.
    ///   - accent: Colour of the second hand.
    ///   - timeZone: Time zone the hands show.
    public init(date: Date? = nil, color: Color = .primary, accent: Color = .orange, timeZone: TimeZone = .current) {
        self.date = date
        self.color = color
        self.accent = accent
        self.timeZone = timeZone
    }

    public var body: some View {
        Group {
            if let date {
                ClockFace(seconds: localSeconds(date), color: color, accent: accent)
                    .animation(reduceMotion ? .easeInOut(duration: 0.3) : .spring(duration: 0.9, bounce: 0.3), value: date)
            } else if reduceMotion {
                // Reduce Motion: the second hand ticks instead of sweeping.
                TimelineView(.periodic(from: .now, by: 1)) { timeline in
                    ClockFace(seconds: floor(localSeconds(timeline.date)), color: color, accent: accent)
                }
            } else {
                TimelineView(.animation) { timeline in
                    ClockFace(seconds: localSeconds(timeline.date), color: color, accent: accent)
                }
            }
        }
        .aspectRatio(1, contentMode: .fit)
        .accessibilityElement()
        .accessibilityLabel("Clock")
        .accessibilityValue((date ?? .now).formatted(date: .omitted, time: .shortened))
    }

    /// Seconds since a local midnight long ago, so the hands only ever move
    /// forward as time goes on.
    private func localSeconds(_ date: Date) -> Double {
        date.timeIntervalSinceReferenceDate + Double(timeZone.secondsFromGMT(for: date))
    }
}

/// The face and hands for a count of local seconds. Animatable so a change of
/// time turns the hands rather than jumping.
struct ClockFace: View, Animatable {
    var seconds: Double
    var color: Color
    var accent: Color

    nonisolated var animatableData: Double {
        get { seconds }
        set { seconds = newValue }
    }

    var body: some View {
        Canvas { context, size in
            let side = min(size.width, size.height)
            let centre = CGPoint(x: size.width / 2, y: size.height / 2)
            let radius = side / 2
            context.stroke(Path(ellipseIn: CGRect(x: centre.x - radius + 1, y: centre.y - radius + 1, width: side - 2, height: side - 2)), with: .color(color.opacity(0.25)), lineWidth: 2)
            for tick in 0..<60 {
                let major = tick % 5 == 0
                let angle = Double(tick) / 60 * 2 * .pi
                let outer = radius * 0.92, inner = radius * (major ? 0.8 : 0.87)
                var path = Path()
                path.move(to: point(centre, angle, inner))
                path.addLine(to: point(centre, angle, outer))
                context.stroke(path, with: .color(color.opacity(major ? 0.9 : 0.35)), style: StrokeStyle(lineWidth: major ? 2.5 : 1, lineCap: .round))
            }
            let s = seconds.truncatingRemainder(dividingBy: 43_200) // 12 h
            hand(&context, centre, angle: s / 43_200 * 2 * .pi, length: radius * 0.5, width: side * 0.045, color: color)
            hand(&context, centre, angle: s.truncatingRemainder(dividingBy: 3600) / 3600 * 2 * .pi, length: radius * 0.75, width: side * 0.03, color: color)
            let secondAngle = s.truncatingRemainder(dividingBy: 60) / 60 * 2 * .pi
            var second = Path()
            second.move(to: point(centre, secondAngle + .pi, radius * 0.18))
            second.addLine(to: point(centre, secondAngle, radius * 0.85))
            context.stroke(second, with: .color(accent), style: StrokeStyle(lineWidth: max(1, side * 0.012), lineCap: .round))
            let dot = side * 0.035
            context.fill(Path(ellipseIn: CGRect(x: centre.x - dot, y: centre.y - dot, width: dot * 2, height: dot * 2)), with: .color(accent))
        }
    }

    /// A point `distance` from the centre at a clock angle (0 = 12 o'clock).
    private func point(_ centre: CGPoint, _ angle: Double, _ distance: Double) -> CGPoint {
        CGPoint(x: centre.x + sin(angle) * distance, y: centre.y - cos(angle) * distance)
    }

    private func hand(_ context: inout GraphicsContext, _ centre: CGPoint, angle: Double, length: Double, width: Double, color: Color) {
        var path = Path()
        path.move(to: point(centre, angle + .pi, length * 0.12))
        path.addLine(to: point(centre, angle, length))
        context.stroke(path, with: .color(color), style: StrokeStyle(lineWidth: width, lineCap: .round))
    }
}

#Preview {
    struct Demo: View {
        @State private var date = Date()
        var body: some View {
            HStack(spacing: 30) {
                AnalogClock().frame(width: 120)
                AnalogClock(date: date, accent: .pink)
                    .frame(width: 120)
                    .onTapGesture { date.addTimeInterval(3 * 3600 + 1200) }
            }
        }
    }
    return Demo()
}
```

---

## Particle Field (`particle-field`)

Drifting particles linked by faint lines, like a living constellation.

- API: `ParticleField()`
- Component: Particle & 3D
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/ParticleField.swift
- Files: Sources/MotionLibrary/Particle/ParticleField.swift, Sources/MotionLibrary/Support/SeededRandom.swift

### Usage

```swift
ZStack {
    Color.black.ignoresSafeArea()
    ParticleField(count: 60, color: .cyan)
        .ignoresSafeArea()
    Text("Connected").font(.largeTitle.bold())
}
```

### How it works

Create a SwiftUI ParticleField view (iOS 17+) with count (50), color, speed (a multiplier, default 1, on 14 pt/s) and linkDistance (70 pt). Generate each particle's traits once from a seeded SplitMix64 generator: start position (0…1 in each axis), direction, speed factor (0.4–1.2), radius (1–2.8 pt) and opacity (0.4–1). In TimelineView(.animation) with a Canvas, compute each position as start + direction × time × 14 × speed × factor, wrapped around the canvas edges, so there is no stored state. Stroke a 0.8 pt line between every pair closer than linkDistance with opacity 0.35 × (1 − distance / linkDistance), then fill each particle as a circle. Hide it from accessibility, and with Reduce Motion on, freeze time.

### Source

```swift
import SwiftUI

/// Particles drifting slowly through the view, joined by faint lines when they
/// come close, like a constellation. A calm, techy background.
public struct ParticleField: View {
    private let count: Int
    private let color: Color
    private let speed: Double
    private let linkDistance: CGFloat
    private let seeds: [ParticleSeed]

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - count: Number of particles.
    ///   - color: Particle and line colour.
    ///   - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast.
    ///   - linkDistance: Particles closer than this are joined by a line (0 turns lines off).
    public init(count: Int = 50, color: Color = .white, speed: Double = 1, linkDistance: CGFloat = 70) {
        self.count = count
        self.color = color
        self.speed = speed * 14
        self.linkDistance = linkDistance
        var generator = SplitMix64(seed: 42)
        seeds = (0..<max(0, count)).map { _ in ParticleSeed(using: &generator) }
    }

    public var body: some View {
        TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in
            let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate
            Canvas { context, size in
                let positions = seeds.map { $0.position(at: time, speed: speed, in: size) }
                if linkDistance > 0 {
                    for i in positions.indices {
                        for j in positions.indices where j > i {
                            let distance = hypot(positions[i].x - positions[j].x, positions[i].y - positions[j].y)
                            guard distance < linkDistance else { continue }
                            var line = Path()
                            line.move(to: positions[i])
                            line.addLine(to: positions[j])
                            context.stroke(line, with: .color(color.opacity(0.35 * (1 - distance / linkDistance))), lineWidth: 0.8)
                        }
                    }
                }
                for (seed, point) in zip(seeds, positions) {
                    let r = seed.radius
                    context.fill(
                        Path(ellipseIn: CGRect(x: point.x - r, y: point.y - r, width: r * 2, height: r * 2)),
                        with: .color(color.opacity(seed.opacity))
                    )
                }
            }
        }
        .accessibilityHidden(true)
    }
}

/// Fixed random traits of one particle. Its position is a pure function of
/// time, so the field needs no state and never drifts out of the view.
struct ParticleSeed {
    var start: CGPoint      // 0…1 in both axes
    var direction: Double   // radians
    var speedFactor: Double
    var radius: CGFloat
    var opacity: Double

    init(using generator: inout SplitMix64) {
        start = CGPoint(x: generator.unit(), y: generator.unit())
        direction = generator.unit() * 2 * .pi
        speedFactor = 0.4 + generator.unit() * 0.8
        radius = 1 + generator.unit() * 1.8
        opacity = 0.4 + generator.unit() * 0.6
    }

    func position(at time: Double, speed: Double, in size: CGSize) -> CGPoint {
        guard size.width > 0, size.height > 0 else { return .zero }
        let travel = time * speed * speedFactor
        let x = (start.x * size.width + CGFloat(cos(direction) * travel)).wrapped(to: size.width)
        let y = (start.y * size.height + CGFloat(sin(direction) * travel)).wrapped(to: size.height)
        return CGPoint(x: x, y: y)
    }
}

#Preview {
    ParticleField()
        .background(Color(red: 0.05, green: 0.07, blue: 0.15))
        .frame(width: 320, height: 220)
}

// MARK: - SeededRandom.swift
// Needed by this effect: save it as its own file next to the one above.

// Shared by the particle and background effects that need stable random
// layouts. Copy this file along with any effect that uses `SplitMix64`.

import SwiftUI

/// Small deterministic random generator, so particle layouts are stable
/// between launches and previews.
struct SplitMix64: RandomNumberGenerator {
    private var state: UInt64

    init(seed: UInt64) { state = seed }

    mutating func next() -> UInt64 {
        state &+= 0x9E37_79B9_7F4A_7C15
        var z = state
        z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
        z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
        return z ^ (z >> 31)
    }

    /// A Double in 0..<1.
    mutating func unit() -> Double { Double.random(in: 0..<1, using: &self) }
}

extension CGFloat {
    /// Wraps the value into 0..<length.
    func wrapped(to length: CGFloat) -> CGFloat {
        let r = truncatingRemainder(dividingBy: length)
        return r < 0 ? r + length : r
    }
}
```

---

## Starfield (`starfield`)

Stars stream out of the centre and stretch into streaks: warp speed.

- API: `Starfield()`
- Component: Particle & 3D
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/Starfield.swift
- Files: Sources/MotionLibrary/Particle/Starfield.swift, Sources/MotionLibrary/Support/SeededRandom.swift

### Usage

```swift
Starfield()
    .background(.black)
    .ignoresSafeArea()

Starfield(count: 300, speed: 4) // warp jump
```

### How it works

Create a SwiftUI Starfield view (iOS 17+) with count (200), speed (a multiplier, default 1, on 0.35 depth units per second) and color. Give each star a seeded random x and y in −1…1 and a depth in 0…1. In a Canvas inside TimelineView(.animation), compute z = 0.05 + 0.95 × fract(depth − time × 0.35 × speed), so stars approach the camera and wrap back to the far plane. Project with perspective: point = centre + (x, y) / z × 0.15 × half the larger side. Draw each star as a round-capped streak from its projection at a slightly larger z (z + min(0.08, 0.35 × speed × 0.05)) to its current projection, with opacity and line width growing as it gets closer. Hide it from accessibility, and with Reduce Motion on, freeze time.

### Source

```swift
import SwiftUI

/// Flying through space: stars stream out from the centre and stretch into
/// streaks as they pass the camera. Raise `speed` for a warp jump.
public struct Starfield: View {
    private let speed: Double
    private let color: Color
    private let stars: [Star]

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - count: Number of stars.
    ///   - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast.
    ///   - color: Star colour.
    public init(count: Int = 200, speed: Double = 1, color: Color = .white) {
        self.speed = speed * 0.35
        self.color = color
        var generator = SplitMix64(seed: 7)
        stars = (0..<max(0, count)).map { _ in
            Star(
                x: generator.unit() * 2 - 1,
                y: generator.unit() * 2 - 1,
                depth: generator.unit()
            )
        }
    }

    public var body: some View {
        TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in
            let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate
            Canvas { context, size in
                let centre = CGPoint(x: size.width / 2, y: size.height / 2)
                let scale = max(size.width, size.height) * 0.5
                // Streak length: where the star was a moment ago.
                let trail = min(0.08, speed * 0.05)
                for star in stars {
                    // Depth runs 1 (far) → 0.05 (at the camera), then wraps.
                    let z = 0.05 + 0.95 * (star.depth - time * speed).wrappedUnit
                    let zBehind = min(1, z + trail)
                    let head = CGPoint(x: centre.x + star.x / z * scale * 0.15, y: centre.y + star.y / z * scale * 0.15)
                    let tail = CGPoint(x: centre.x + star.x / zBehind * scale * 0.15, y: centre.y + star.y / zBehind * scale * 0.15)
                    let brightness = min(1, (1 - z) * 1.4)
                    var streak = Path()
                    streak.move(to: tail)
                    streak.addLine(to: head)
                    context.stroke(
                        streak,
                        with: .color(color.opacity(brightness)),
                        style: StrokeStyle(lineWidth: max(0.6, (1 - z) * 2.4), lineCap: .round)
                    )
                }
            }
        }
        .accessibilityHidden(true)
    }

    private struct Star {
        var x: Double
        var y: Double
        var depth: Double
    }
}

private extension Double {
    /// Wraps the value into 0..<1.
    var wrappedUnit: Double {
        let r = truncatingRemainder(dividingBy: 1)
        return r < 0 ? r + 1 : r
    }
}

#Preview {
    Starfield()
        .background(.black)
        .frame(width: 320, height: 220)
}

// MARK: - SeededRandom.swift
// Needed by this effect: save it as its own file next to the one above.

// Shared by the particle and background effects that need stable random
// layouts. Copy this file along with any effect that uses `SplitMix64`.

import SwiftUI

/// Small deterministic random generator, so particle layouts are stable
/// between launches and previews.
struct SplitMix64: RandomNumberGenerator {
    private var state: UInt64

    init(seed: UInt64) { state = seed }

    mutating func next() -> UInt64 {
        state &+= 0x9E37_79B9_7F4A_7C15
        var z = state
        z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
        z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
        return z ^ (z >> 31)
    }

    /// A Double in 0..<1.
    mutating func unit() -> Double { Double.random(in: 0..<1, using: &self) }
}

extension CGFloat {
    /// Wraps the value into 0..<length.
    func wrapped(to length: CGFloat) -> CGFloat {
        let r = truncatingRemainder(dividingBy: length)
        return r < 0 ? r + length : r
    }
}
```

---

## Cover Flow (`cover-flow`)

Side items turn away in 3D, shrink and dim; the centre faces you.

- API: `CoverFlow(items) { item in }`
- Component: Particle & 3D
- Trigger: Scroll
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/CoverFlow.swift
- Files: Sources/MotionLibrary/Particle/CoverFlow.swift

### Usage

```swift
CoverFlow(albums) { album in
    Image(album.artwork)
        .resizable()
        .aspectRatio(1, contentMode: .fit)
        .clipShape(RoundedRectangle(cornerRadius: 12))
}
.frame(height: 220)
```

### How it works

Create a generic SwiftUI CoverFlow carousel (iOS 17+) over Identifiable items with an optional selection binding, itemWidth (140), spacing (−10) and maxAngle (50°). Use a horizontal ScrollView + LazyHStack with .scrollTargetLayout(), .scrollTargetBehavior(.viewAligned), hidden indicators, .scrollPosition(id:anchor: .center) and contentMargins that centre the first and last items. In .visualEffect, read the item's frame in the .scrollView coordinate space, compute p = clamp((midX − viewport centre) / itemWidth, −1, 1) and apply rotation3DEffect of −p × maxAngle around the y axis (perspective 0.6), scale 1 − |p| × 0.18 and brightness −|p| × 0.25. Raise the selected item's zIndex. With Reduce Motion on, drop the rotation.

### Source

```swift
import SwiftUI

/// A horizontal carousel in the style of Cover Flow: the centred item faces
/// you, and items to either side turn away in 3D, recede and dim. Items snap
/// to the centre.
public struct CoverFlow<Data: RandomAccessCollection, Content: View>: View where Data.Element: Identifiable {
    private let items: Data
    private let selection: Binding<Data.Element.ID?>?
    private let itemWidth: CGFloat
    private let spacing: CGFloat
    private let maxAngle: Double
    private let content: (Data.Element) -> Content

    @State private var internalSelection: Data.Element.ID?
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - items: One item per element.
    ///   - selection: Optional binding to the centred item; set it to scroll.
    ///   - itemWidth: Width of each item in points.
    ///   - spacing: Gap between items (negative values overlap them).
    ///   - maxAngle: How far side items turn, in degrees.
    ///   - content: Builds one item, usually artwork.
    public init(
        _ items: Data,
        selection: Binding<Data.Element.ID?>? = nil,
        itemWidth: CGFloat = 140,
        spacing: CGFloat = -10,
        maxAngle: Double = 50,
        @ViewBuilder content: @escaping (Data.Element) -> Content
    ) {
        self.items = items
        self.selection = selection
        self.itemWidth = itemWidth
        self.spacing = spacing
        self.maxAngle = maxAngle
        self.content = content
    }

    public var body: some View {
        GeometryReader { geometry in
            // Plain values for the visualEffect closure, which can't touch the view.
            let fallbackWidth = geometry.size.width
            let itemWidth = itemWidth
            let maxAngle = reduceMotion ? 0 : maxAngle
            ScrollView(.horizontal) {
                LazyHStack(spacing: spacing) {
                    ForEach(items) { item in
                        content(item)
                            .frame(width: itemWidth)
                            .visualEffect { view, proxy in
                                // -1 (one item left of centre) … 0 (centred) … 1.
                                let frame = proxy.frame(in: .scrollView(axis: .horizontal))
                                let viewport = proxy.bounds(of: .scrollView(axis: .horizontal))?.width ?? fallbackWidth
                                let clamped = max(-1, min(1, (frame.midX - viewport / 2) / itemWidth))
                                let angle = -clamped * maxAngle
                                return view
                                    .rotation3DEffect(.degrees(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.6)
                                    .scaleEffect(1 - abs(clamped) * 0.18)
                                    .brightness(-abs(clamped) * 0.25)
                            }
                            // Centred item draws on top of its neighbours.
                            .zIndex(isSelected(item) ? 1 : 0)
                            .id(item.id)
                    }
                }
                .scrollTargetLayout()
                .frame(height: geometry.size.height)
            }
            .contentMargins(.horizontal, max(0, (geometry.size.width - itemWidth) / 2), for: .scrollContent)
            .scrollTargetBehavior(.viewAligned)
            .scrollIndicators(.hidden)
            .scrollPosition(id: selection ?? $internalSelection, anchor: .center)
        }
    }

    private func isSelected(_ item: Data.Element) -> Bool {
        (selection?.wrappedValue ?? internalSelection) == item.id
    }
}

#Preview {
    struct Album: Identifiable {
        let id: Int
        let color: Color
    }
    let albums = [Color.red, .orange, .yellow, .green, .teal, .blue, .indigo, .purple]
        .enumerated().map { Album(id: $0, color: $1) }
    return CoverFlow(albums) { album in
        RoundedRectangle(cornerRadius: 12)
            .fill(album.color.gradient)
            .aspectRatio(1, contentMode: .fit)
            .overlay(Image(systemName: "music.note").font(.largeTitle).foregroundStyle(.white))
    }
    .frame(height: 200)
}
```

---

## Float 3D (`float-3d`)

The view hovers: a gentle bob, a slow 3D sway and a breathing shadow.

- API: `.float3D()`
- Component: Particle & 3D
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/Float3D.swift
- Files: Sources/MotionLibrary/Particle/Float3D.swift

### Usage

```swift
Image("product")
    .resizable()
    .scaledToFit()
    .frame(width: 180)
    .float3D()

BadgeView()
    .float3D(height: 6, angle: 8, period: 4, shadow: false)
```

### How it works

Create a SwiftUI .float3D(height:angle:period:shadow:) view modifier (iOS 17+). Inside TimelineView(.animation), let t = time × 2π / period (3 s) and lift = 0.5 + 0.5·sin t. Rotate the content in 3D by sin(t/2) × angle (12°) around the y axis and cos(0.7t) × angle/2 around the x axis (perspective 0.5), and move it up by height (10 pt) × lift. Behind it, at the bottom, draw a black ellipse 12 pt tall, 18 pt below, that gets lighter (opacity 0.25 − 0.1·lift), narrower (scale x 0.75 − 0.15·lift) and blurrier (6 + 4·lift) as the view rises. With Reduce Motion on, pause the timeline.

### Source

```swift
import SwiftUI

/// Makes a view hover in space: it bobs up and down and slowly sways in 3D,
/// while its shadow shrinks and grows underneath. For hero images, product
/// shots, badges and empty-state illustrations.
struct Float3DModifier: ViewModifier {
    var height: CGFloat
    var angle: Double
    var period: Double
    var shadow: Bool

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        TimelineView(.animation(paused: reduceMotion)) { timeline in
            let t = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * 2 * .pi / period
            // Bob: 0 at rest height, 1 at the top.
            let lift = 0.5 + 0.5 * sin(t)
            content
                .rotation3DEffect(.degrees(sin(t * 0.5) * angle), axis: (x: 0, y: 1, z: 0), perspective: 0.5)
                .rotation3DEffect(.degrees(cos(t * 0.7) * angle * 0.5), axis: (x: 1, y: 0, z: 0), perspective: 0.5)
                .offset(y: -height * lift)
                .background(alignment: .bottom) {
                    if shadow {
                        Ellipse()
                            .fill(.black.opacity(0.25 - 0.1 * lift))
                            .frame(height: 12)
                            .scaleEffect(x: 0.75 - 0.15 * lift, y: 1)
                            .blur(radius: 6 + 4 * lift)
                            .offset(y: 18)
                    }
                }
        }
    }
}

public extension View {
    /// Floats the view in 3D: a gentle bob, a slow sway and a breathing shadow.
    ///
    /// ```swift
    /// Image("product")
    ///     .resizable()
    ///     .scaledToFit()
    ///     .frame(width: 180)
    ///     .float3D()
    /// ```
    ///
    /// - Parameters:
    ///   - height: How high the view rises, in points.
    ///   - angle: Maximum sway angle, in degrees.
    ///   - period: Seconds for one bob up and down.
    ///   - shadow: Draws a soft ground shadow under the view.
    func float3D(height: CGFloat = 10, angle: Double = 12, period: Double = 3, shadow: Bool = true) -> some View {
        modifier(Float3DModifier(height: height, angle: angle, period: period, shadow: shadow))
    }
}

#Preview {
    RoundedRectangle(cornerRadius: 24, style: .continuous)
        .fill(LinearGradient(colors: [.cyan, .blue], startPoint: .top, endPoint: .bottom))
        .frame(width: 140, height: 140)
        .overlay(Image(systemName: "cube.fill").font(.system(size: 56)).foregroundStyle(.white))
        .float3D()
        .padding(60)
}
```

---

## Fireworks (`fireworks`)

Rockets climb and burst into rings of falling, fading sparks.

- API: `Fireworks()`
- Component: Particle & 3D
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/Fireworks.swift
- Files: Sources/MotionLibrary/Particle/Fireworks.swift, Sources/MotionLibrary/Support/SeededRandom.swift

### Usage

```swift
ZStack {
    Color.black.ignoresSafeArea()
    Fireworks()
        .ignoresSafeArea()
    Text("Happy New Year").font(.largeTitle.bold())
}
```

### How it works

Create a SwiftUI Fireworks view (iOS 17+) with colors, interval (0.9 s between launches) and sparks (36 per burst), drawn statelessly in a Canvas inside TimelineView(.animation). Rocket k launches at k × interval; for every rocket younger than 0.8 s climb + 1.5 s fade, seed a SplitMix64 generator with k to pick x (15–85 % of the width), peak height (15–45 %), colour and reach (22–34 % of the smaller side). Climbing: ease-out from the bottom to the peak and draw a 14 pt trail. Bursting, with t = time since the burst / 1.5: place each spark at angle i / sparks × 2π (plus jitter), distance reach × (1 − (1 − min(1, 1.6 t))³) × random 0.75–1, add gravity fall t² × 0.6 × reach, draw a circle of radius 1.8 × (1 − 0.6 t) at opacity 1 − t, and add a white flash for the first 15 %. Hide from accessibility; with Reduce Motion on, freeze on a frame with a burst in bloom.

### Source

```swift
import SwiftUI

/// Rockets climb from the bottom of the view and burst into rings of falling,
/// fading sparks, one after another.
public struct Fireworks: View {
    private let colors: [Color]
    private let interval: Double
    private let sparks: Int

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - colors: Burst colours, picked per rocket.
    ///   - interval: Seconds between launches.
    ///   - sparks: Sparks per burst.
    public init(
        colors: [Color] = [.pink, .orange, .yellow, .mint, .cyan, .purple],
        interval: Double = 0.9,
        sparks: Int = 36
    ) {
        self.colors = colors.isEmpty ? [.white] : colors
        self.interval = max(0.2, interval)
        self.sparks = max(1, sparks)
    }

    /// Seconds from launch to burst, and from burst to fully faded.
    private static let climb = 0.8
    private static let fade = 1.5

    public var body: some View {
        // Reduce Motion: a still frame with a burst in full bloom.
        TimelineView(.animation(paused: reduceMotion)) { timeline in
            let time = reduceMotion ? 1.3 : timeline.date.timeIntervalSinceReferenceDate
            Canvas { context, size in
                let lifetime = Self.climb + Self.fade
                let newest = Int((time / interval).rounded(.down))
                let oldest = newest - Int((lifetime / interval).rounded(.up))
                for rocket in max(0, oldest)...max(0, newest) {
                    let age = time - Double(rocket) * interval
                    guard age >= 0, age < lifetime else { continue }
                    draw(rocket: rocket, age: age, in: &context, size: size)
                }
            }
        }
        .accessibilityHidden(true)
    }

    private func draw(rocket: Int, age: Double, in context: inout GraphicsContext, size: CGSize) {
        var random = SplitMix64(seed: UInt64(truncatingIfNeeded: rocket) &* 2_654_435_761)
        let x = size.width * CGFloat(0.15 + 0.7 * random.unit())
        let peak = size.height * CGFloat(0.15 + 0.3 * random.unit())
        let color = colors[Int(random.unit() * Double(colors.count)) % colors.count]
        let reach = min(size.width, size.height) * CGFloat(0.22 + 0.12 * random.unit())

        if age < Self.climb {
            // Climbing: ease out toward the peak, with a short trail.
            let t = age / Self.climb
            let eased = 1 - pow(1 - t, 2)
            let y = size.height - (size.height - peak) * CGFloat(eased)
            var trail = Path()
            trail.move(to: CGPoint(x: x, y: y + 14))
            trail.addLine(to: CGPoint(x: x, y: y))
            context.stroke(trail, with: .color(color.opacity(0.8)), style: StrokeStyle(lineWidth: 2, lineCap: .round))
            return
        }

        // Burst: sparks fly out, slow down, fall under gravity and fade.
        let t = (age - Self.climb) / Self.fade
        let spread = CGFloat(1 - pow(1 - min(1, t * 1.6), 3))
        let fall = CGFloat(t * t) * reach * 0.6
        let opacity = max(0, 1 - t)
        for spark in 0..<sparks {
            let angle = Double(spark) / Double(sparks) * 2 * .pi + random.unit() * 0.1
            let distance = reach * spread * CGFloat(0.75 + 0.25 * random.unit())
            let point = CGPoint(x: x + CGFloat(cos(angle)) * distance, y: peak + CGFloat(sin(angle)) * distance + fall)
            let r = CGFloat(1.8 * (1 - t * 0.6))
            context.fill(
                Path(ellipseIn: CGRect(x: point.x - r, y: point.y - r, width: r * 2, height: r * 2)),
                with: .color(color.opacity(opacity))
            )
        }
        // A flash at the moment of the burst.
        if t < 0.15 {
            let r = reach * 0.35 * CGFloat(t / 0.15)
            context.fill(
                Path(ellipseIn: CGRect(x: x - r, y: peak - r, width: r * 2, height: r * 2)),
                with: .color(.white.opacity(0.5 * (1 - t / 0.15)))
            )
        }
    }
}

#Preview {
    Fireworks()
        .background(Color(red: 0.03, green: 0.03, blue: 0.1))
        .frame(width: 320, height: 220)
}

// MARK: - SeededRandom.swift
// Needed by this effect: save it as its own file next to the one above.

// Shared by the particle and background effects that need stable random
// layouts. Copy this file along with any effect that uses `SplitMix64`.

import SwiftUI

/// Small deterministic random generator, so particle layouts are stable
/// between launches and previews.
struct SplitMix64: RandomNumberGenerator {
    private var state: UInt64

    init(seed: UInt64) { state = seed }

    mutating func next() -> UInt64 {
        state &+= 0x9E37_79B9_7F4A_7C15
        var z = state
        z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
        z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
        return z ^ (z >> 31)
    }

    /// A Double in 0..<1.
    mutating func unit() -> Double { Double.random(in: 0..<1, using: &self) }
}

extension CGFloat {
    /// Wraps the value into 0..<length.
    func wrapped(to length: CGFloat) -> CGFloat {
        let r = truncatingRemainder(dividingBy: length)
        return r < 0 ? r + length : r
    }
}
```

---

## Snowfall & Rain (`snowfall`)

Drifting, swaying snowflakes or fast slanted rain, with depth.

- API: `Snowfall(.snow / .rain)`
- Component: Particle & 3D
- Trigger: Loop
- Requires: iOS 17+ · macOS 14+ · visionOS 1+
- Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/Snowfall.swift
- Files: Sources/MotionLibrary/Particle/Snowfall.swift, Sources/MotionLibrary/Support/SeededRandom.swift

### Usage

```swift
Snowfall()
    .background(.indigo)
    .ignoresSafeArea()

Snowfall(.rain, count: 150, color: .white.opacity(0.8))
```

### How it works

Create a SwiftUI Snowfall view (iOS 17+) with a style (.snow or .rain), count (80), color and speed. Give each particle seeded random traits: start x and y (0–1), depth (0–1) and phase. In a Canvas inside TimelineView(.animation), compute positions as pure functions of time, wrapped around the view so nothing is stored. Snow: fall speed 18 + 30 × depth pt/s, horizontal sway sin(time × (0.8 + depth) + phase) × (6 + 10 × depth) plus a slow 4 pt/s drift, radius 1 + 2.4 × depth, opacity 0.35 + 0.6 × depth, so small flakes look far away. Rain: fall speed 380 + 320 × depth, a streak 8 + 14 × depth long slanted by 0.25, width 0.8 + 0.8 × depth, opacity 0.25 + 0.5 × depth. Hide from accessibility; Reduce Motion pauses it.

### Source

```swift
import SwiftUI

/// Falling weather: snowflakes that drift and sway, or slanted streaks of rain.
public struct Snowfall: View {
    /// What falls.
    public enum Style: Sendable {
        /// Soft round flakes that sway side to side.
        case snow
        /// Fast, thin, slanted streaks.
        case rain
    }

    private let style: Style
    private let color: Color
    private let speed: Double
    private let seeds: [FallSeed]

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameters:
    ///   - style: `.snow` or `.rain`.
    ///   - count: Number of flakes or drops.
    ///   - color: Flake or drop colour.
    ///   - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast.
    public init(_ style: Style = .snow, count: Int = 80, color: Color = .white, speed: Double = 1) {
        self.style = style
        self.color = color
        self.speed = speed
        var generator = SplitMix64(seed: style == .snow ? 11 : 23)
        seeds = (0..<max(0, count)).map { _ in FallSeed(using: &generator) }
    }

    public var body: some View {
        TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in
            let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * speed
            Canvas { context, size in
                for seed in seeds {
                    switch style {
                    case .snow: drawFlake(seed, time: time, in: &context, size: size)
                    case .rain: drawDrop(seed, time: time, in: &context, size: size)
                    }
                }
            }
        }
        .accessibilityHidden(true)
    }

    private func drawFlake(_ seed: FallSeed, time: Double, in context: inout GraphicsContext, size: CGSize) {
        // Small flakes are "far away": slower and fainter, for depth.
        let fallSpeed = 18 + 30 * seed.depth
        let travel = size.height + 20
        let y = (seed.start.y * travel + CGFloat(time * fallSpeed)).wrapped(to: travel) - 10
        let sway = CGFloat(sin(time * (0.8 + seed.depth) + seed.phase)) * (6 + 10 * seed.depth)
        let x = (seed.start.x * size.width + sway + CGFloat(time * 4)).wrapped(to: size.width)
        let r = 1 + 2.4 * seed.depth
        context.fill(
            Path(ellipseIn: CGRect(x: x - r, y: y - r, width: r * 2, height: r * 2)),
            with: .color(color.opacity(0.35 + 0.6 * seed.depth))
        )
    }

    private func drawDrop(_ seed: FallSeed, time: Double, in context: inout GraphicsContext, size: CGSize) {
        let fallSpeed = 380 + 320 * seed.depth
        let length = 8 + 14 * seed.depth
        let slant: CGFloat = 0.25
        let travel = size.height + length + 20
        let y = (seed.start.y * travel + CGFloat(time * fallSpeed)).wrapped(to: travel) - length
        let x = (seed.start.x * (size.width + 40) + y * slant).wrapped(to: size.width + 40) - 20
        var path = Path()
        path.move(to: CGPoint(x: x, y: y))
        path.addLine(to: CGPoint(x: x + length * slant, y: y + length))
        context.stroke(path, with: .color(c

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.