Docs

Step TrailNew

A liquid glass progress trail for checkout, booking, account setup and verification flows: every step a glass stop joined to the next by a short neck, done steps carrying a check, the current step a red capsule with its number and title, steps people already reached in full text and the rest muted; moving on stretches the red from one stop to the next and gathers it there while the old title blurs into a check, a tap goes back to any step already reached, a tap ahead of that shakes the stop and nothing moves, a sideways drag carries the capsule stop by stop with a tick each and gives at the last step it may reach, and when the titles don't fit the capsule keeps its number, the caption names the step and the stops shrink rather than wrap or clip.

Free · MIT + Commons ClauseiOS 17.0+Liquid Glassstepsprogresscheckoutwizardonboardingbookingnavigationglasshapticsaccessibility
Type
StepTrail
Files
StepTrail.swift
Depends on
Nothing (Apple frameworks only)
Version
1.0.0

A step progress indicator for multi-step flows that shows where you are, what is done, and lets people go back without letting them jump ahead of what they have filled in. The stops are liquid glass resting joined, so the whole trail reads as one shape with short necks. Done steps carry a check, the current step is a red capsule with its number and title, steps people have already reached beyond the current one show their number in full text, and steps not reached yet show it muted. Moving to another step stretches the red from one stop to the other, its leading edge first, and gathers it there, while the titles and checks blur over. Tap a done or reached step to go there. A tap on a step that hasn't been reached shakes that stop and nothing moves. Drag sideways along the trail to carry the capsule across the reachable steps, with a tick on each; it gives at the last one it may reach and lands where you let go. Vertical swipes still scroll the screen. When the trail doesn't fit, the capsule drops its title (the caption names the step instead), and then the stops shrink. It never wraps and never clips. The trail hugs its content and starts at the leading edge, keeping the width of its widest step so nothing around it moves.

Notes

  • Put it at the top of a checkout, booking, account setup or verification flow and bind selection to the step your flow shows: StepTrail(steps: ["Cart", "Address", "Payment", "Review"], selection: $step). Your Continue button moves on with step += 1; the trail never changes steps on its own except when a person taps or drags it.
  • To let people return forward after going back, keep the furthest step they have filled in and pass it as reached: .onChange(of: step) { _, value in furthest = max(furthest, value) }. Without it, only earlier steps can be revisited. Lower furthest yourself when an edit invalidates later steps.
  • Every stop is liquid glass, resting joined, so the trail reads as one shape with short necks. Done steps carry a check, the current step is a red capsule with its number and title in dark ink, steps already reached beyond the current one show their number in full text and steps not reached yet show it muted. Red marks the current step and nothing else.
  • Moving to another step closes one capsule and opens the other while the red stretches across: the edge facing the move leads and the far edge lets go a beat later, so the red reaches over the neck and gathers on the new stop. The old title blurs out as its capsule shrinks into a circle and its check blurs in; the new title blurs in as the capsule opens. A move made while another is still landing picks up from where everything is.
  • Tap a done or reached step to go there, with a selection tick. A tap on a step that hasn't been reached shakes that stop with a firm tick and nothing moves. A tap on the current step does nothing.
  • Drag sideways along the trail to carry the capsule across the reachable steps. It lifts under the finger, moves to a stop once the finger is a little past the midpoint (so it never flickers between two), ticks on each, and between stops its facing edge reaches toward the finger with rubber-band resistance. At the last step it may reach it only gives, with one firm tick, and let go it lands where it is: the binding changes once, on release. The drag starts only when it runs sideways, so vertical swipes that begin on the trail still scroll the screen or move a sheet.
  • Set selection to steps.count when the flow completes: every stop shows a check, the red drains from the last capsule and the trail lands with one small beat. The caption and VoiceOver read "Complete". Tapping a done step from there goes back to it, like any other done step; disable the trail if the flow is final.
  • When the titles don't fit, the capsule keeps only its number and the caption names the step ("ID photo Step 4 of 6"); when the numbers don't fit either, the stops shrink to fit. It never wraps and never clips. The trail hugs its content, starts at the leading edge and keeps the width of its widest step, so nothing around it moves as the capsule changes. Whether titles show is decided by the longest title, so the form never flips between steps. Titles longer than Style.maximumTitleWidth truncate.
  • An empty steps draws nothing; a single step shows its capsule. Values of selection outside 0...steps.count are drawn clamped, and a change to steps puts the trail on the selection without motion.
  • VoiceOver reads the trail as one adjustable element: the label "Progress" and a value such as "Step 2 of 4, Address". Swipe up and down to move within the reached steps, or use the Go to actions for each reachable step. With a hardware keyboard, focus the trail and use the left and right arrow keys (mirrored right to left).
  • Reduce Motion: the red stays in the stops and crossfades from one to the other with no stretch, the capsules resize on a short spring with no overshoot, the checks, numbers and titles fade, the shake and the lift stay still, and a press shades instead of sinking.
  • Dynamic Type grows the stops, numbers and titles until they are a little over twice their size, and the caption all the way; when they no longer fit, the title moves to the caption and the stops shrink to fit. Right to left mirrors the trail: it starts at the right, the number sits at the capsule's right end and a drag runs the other way.
  • Haptics: a selection tick when a tap, a key or a drag lands on a step and for each stop a drag crosses, and a firm tick for a refused tap or a drag pushed against its last step. Changes your app makes stay silent, so the trail never doubles your own feedback.
  • On iOS 26 the stops are Liquid Glass and merge through their necks; before that, and under Reduce Transparency, they draw as frosted or solid stops on their own. .disabled(true) shows the progress dimmed and ignores touches.
  • Style sets the accent (the current capsule) and the ink on it, the label and secondary label colors, the stop height (34 by default, at least 28) and the longest title. Messages replaces any line of copy: the VoiceOver label, the caption, the spoken value, the finished state and the Go to action, for example .init(label: "Checkout progress"). All of them are localizable through your String Catalog.

Usage

StepTrailExample()

Parameters

ParameterDescription
stepsThe step titles, in order, such as ["Cart", "Address", "Payment", "Review"]. They show in the current step's capsule and are read by VoiceOver. An empty list draws nothing. Titles longer than Style.maximumTitleWidth truncate in the capsule.
selectionThe index of the current step. A tap, a drag or VoiceOver writes it; set it yourself to move on (a Continue button). steps.count shows every step done, for the moment a flow completes. Values outside 0...steps.count are drawn clamped.
reachedThe furthest step people have filled in, so they can return forward to it after going back. Steps up to it stay tappable and show their number in full text. nil (the default) is the selection, so only earlier steps can be revisited. Keep it in your own state, for example furthest = max(furthest, step).
showsCaptionShows a quiet caption under the trail, such as "Step 2 of 4". When the titles don't fit in the trail, the caption names the current step too.
messagesThe built-in copy: the VoiceOver label ("Progress"), the caption, the spoken value, the finished state and the VoiceOver actions. Defaults are localizable through your String Catalog.
styleColors and size. Defaults to the SwiftPieces house palette (a red current step on neutral glass), adapting to light and dark.

Source

StepTrail.swift
// Copyright (c) 2026 Saivion Hayes
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, and distribute the Software as part of
// an application, website, or product, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// Commons Clause Restriction
//
// You may use this Software, including for any commercial purpose, so long as
// you do not sell, sublicense, or redistribute the components themselves, whether
// alone, in a bundle, or as a ported version.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

import SwiftUI
import UIKit
import UIKit.UIGestureRecognizerSubclass

/// A step progress indicator for multi-step flows that shows where you are, what is done, and lets people go back
/// without letting them jump ahead of what they have filled in.
///
/// The stops are liquid glass resting joined, so the whole trail reads as one shape with short necks. Done steps
/// carry a check, the current step is a red capsule with its number and title, steps people have already reached
/// beyond the current one show their number in full text, and steps not reached yet show it muted. Moving to another
/// step stretches the red from one stop to the other, its leading edge first, and gathers it there, while the titles
/// and checks blur over.
///
/// Tap a done or reached step to go there. A tap on a step that hasn't been reached shakes that stop and nothing
/// moves. Drag sideways along the trail to carry the capsule across the reachable steps, with a tick on each; it
/// gives at the last one it may reach and lands where you let go. Vertical swipes still scroll the screen.
///
/// When the trail doesn't fit, the capsule drops its title (the caption names the step instead), and then the stops
/// shrink. It never wraps and never clips. The trail hugs its content and starts at the leading edge, keeping the
/// width of its widest step so nothing around it moves.
///
/// - Parameters:
///   - steps: The step titles, in order, such as `["Cart", "Address", "Payment", "Review"]`. They show in the current step's capsule and are read by VoiceOver. An empty list draws nothing. Titles longer than `Style.maximumTitleWidth` truncate in the capsule.
///   - selection: The index of the current step. A tap, a drag or VoiceOver writes it; set it yourself to move on (a Continue button). `steps.count` shows every step done, for the moment a flow completes. Values outside `0...steps.count` are drawn clamped.
///   - reached: The furthest step people have filled in, so they can return forward to it after going back. Steps up to it stay tappable and show their number in full text. `nil` (the default) is the selection, so only earlier steps can be revisited. Keep it in your own state, for example `furthest = max(furthest, step)`.
///   - showsCaption: Shows a quiet caption under the trail, such as "Step 2 of 4". When the titles don't fit in the trail, the caption names the current step too.
///   - messages: The built-in copy: the VoiceOver label ("Progress"), the caption, the spoken value, the finished state and the VoiceOver actions. Defaults are localizable through your String Catalog.
///   - style: Colors and size. Defaults to the SwiftPieces house palette (a red current step on neutral glass), adapting to light and dark.
public struct StepTrail: View {
    /// Colors and size. `.standard` is the house palette.
    public struct Style: Sendable {
        /// The current step's capsule, painted inside its glass. Red in the house palette, and the only red.
        public var accent: Color
        /// The number and title on the accent.
        public var accentInk: Color
        /// Checks on done steps, numbers of reached steps and the caption's step title.
        public var label: Color
        /// Numbers of steps not reached yet, and the caption.
        public var secondaryLabel: Color
        /// The stop diameter and capsule height at the default text size, at least 28. It follows Dynamic Type.
        public var height: CGFloat
        /// The longest a title runs in the capsule before it truncates, at the default text size.
        public var maximumTitleWidth: CGFloat

        /// Pass only what you want to change; `nil` keeps the house palette value.
        public init(accent: Color? = nil, accentInk: Color? = nil, label: Color? = nil, secondaryLabel: Color? = nil, height: CGFloat = 34, maximumTitleWidth: CGFloat = 160) {
            self.accent = accent ?? Color(red: 1, green: 0, blue: 0)
            self.accentInk = accentInk ?? stepTrailColor(light: 0x141414, dark: 0x141414)
            self.label = label ?? stepTrailColor(light: 0x141414, dark: 0xF4F3EF)
            self.secondaryLabel = secondaryLabel ?? stepTrailColor(light: 0x5C5A56, dark: 0xA6A49F)
            self.height = max(height, 28)
            self.maximumTitleWidth = max(maximumTitleWidth, 40)
        }

        public static let standard = Style()
    }

    /// The trail's copy. Every default goes through `String(localized:)`, so it can be translated in your String
    /// Catalog, and any line can be replaced: `.init(label: "Checkout progress")`.
    public struct Messages: Sendable {
        /// What VoiceOver calls the trail.
        public var label: String
        /// The caption under the trail, such as "Step 2 of 4".
        public var caption: @Sendable (_ position: Int, _ count: Int) -> String
        /// What VoiceOver reads as the trail's value, such as "Step 2 of 4, Address".
        public var value: @Sendable (_ position: Int, _ count: Int, _ title: String) -> String
        /// The caption and the spoken value once every step is done.
        public var complete: String
        /// The VoiceOver action that goes to a step, such as "Go to Cart".
        public var goTo: @Sendable (_ title: String) -> String

        /// Pass only the lines you want to change.
        public init(
            label: String = String(localized: "Progress", comment: "Step trail: what VoiceOver calls the trail"),
            caption: @escaping @Sendable (_ position: Int, _ count: Int) -> String = { position, count in
                String(localized: "Step \(position) of \(count)", comment: "Step trail caption, such as Step 2 of 4")
            },
            value: @escaping @Sendable (_ position: Int, _ count: Int, _ title: String) -> String = { position, count, title in
                String(localized: "Step \(position) of \(count), \(title)", comment: "Step trail value read by VoiceOver, such as Step 2 of 4, Address")
            },
            complete: String = String(localized: "Complete", comment: "Step trail caption and value once every step is done"),
            goTo: @escaping @Sendable (_ title: String) -> String = { title in
                String(localized: "Go to \(title)", comment: "Step trail VoiceOver action that goes to a step, such as Go to Cart")
            }
        ) {
            self.label = label
            self.caption = caption
            self.value = value
            self.complete = complete
            self.goTo = goTo
        }

        public static var standard: Messages { Messages() }
    }

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @Environment(\.layoutDirection) private var layoutDirection
    @Environment(\.isEnabled) private var isEnabled
    /// How far the stops and their text follow Dynamic Type.
    @ScaledMetric(relativeTo: .subheadline) private var textScale: CGFloat = 1
    @ScaledMetric(relativeTo: .footnote) private var captionSize: CGFloat = 13
    @Binding private var selection: Int

    private let steps: [String]
    private let reached: Int?
    private let showsCaption: Bool
    private let messages: Messages
    private let style: Style

    /// The step drawn as current: the selection, or the stop a drag is over. `steps.count` once every step is done.
    @State private var shown: Int
    /// How open each stop is, 0 a circle and 1 the capsule with its title. Animated on its own spring.
    @State private var open: StepTrailOpenness
    /// The red puck's two edges as step positions, each sprung on its own so the puck stretches between stops.
    @State private var lowerEdge: Double
    @State private var upperEdge: Double
    /// How far, in points, a dragging finger pulls the puck's facing edge. Set directly under the finger.
    @State private var pull: Double = 0
    /// The stop a drag is over, while one is.
    @State private var scrubbing: Int?
    @State private var grabbed = false
    @State private var pressed: Int?
    @State private var pushing = false
    /// Each refused stop's shake count.
    @State private var shakes: [Int: Int] = [:]
    @State private var titleWidths: [Int: CGFloat] = [:]
    @State private var landings = 0
    @State private var refusals = 0
    @State private var completions = 0

    public init(steps: [String], selection: Binding<Int>, reached: Int? = nil, showsCaption: Bool = true, messages: Messages = .standard, style: Style = .standard) {
        self.steps = steps
        self._selection = selection
        self.reached = reached
        self.showsCaption = showsCaption
        self.messages = messages
        self.style = style
        let count = steps.count
        let start = min(max(selection.wrappedValue, 0), count)
        self._shown = State(initialValue: start)
        self._open = State(initialValue: StepTrailOpenness(open: start, count: count))
        let edge = Double(min(start, max(count - 1, 0)))
        self._lowerEdge = State(initialValue: edge)
        self._upperEdge = State(initialValue: edge)
    }

    public var body: some View {
        if steps.isEmpty {
            EmptyView()
        } else {
            trail
        }
    }

    private var trail: some View {
        let rtl = layoutDirection == .rightToLeft
        return ViewThatFits(in: .horizontal) {
            block(metrics(titled: true, rtl: rtl))
            block(metrics(titled: false, rtl: rtl))
        }
        .fontWeight(.semibold)
        .background { titleProbe }
        .saturation(isEnabled ? 1 : 0)
        .opacity(isEnabled ? 1 : 0.45)
        .sensoryFeedback(.selection, trigger: landings)
        .sensoryFeedback(.impact(flexibility: .rigid), trigger: refusals)
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(messages.label)
        .accessibilityValue(spokenValue)
        .accessibilityAdjustableAction { direction in
            switch direction {
            case .increment: step(by: 1, haptic: false)
            case .decrement: step(by: -1, haptic: false)
            @unknown default: break
            }
        }
        .accessibilityActions {
            ForEach(actionIndices, id: \.self) { index in
                Button(messages.goTo(steps[index])) { go(to: index) }
            }
        }
        .focusable(isEnabled)
        .onKeyPress(.leftArrow) { step(by: rtl ? 1 : -1, haptic: true) ? .handled : .ignored }
        .onKeyPress(.rightArrow) { step(by: rtl ? -1 : 1, haptic: true) ? .handled : .ignored }
        .onChange(of: selection) { _, value in
            // A drag owns the trail until it lands; the landing reconciles with whatever the selection is then.
            guard scrubbing == nil else { return }
            move(to: clamped(value))
        }
        .onChange(of: steps) { _, _ in place(clamped(selection)) }
    }

    // MARK: Layout

    /// The trail and its caption in one size: titled, or numbers only (shrinking further when it must).
    private func block(_ metrics: StepTrailMetrics) -> some View {
        VStack(alignment: .leading, spacing: (10 * metrics.scale).rounded()) {
            track(metrics)
            if showsCaption {
                caption(titled: metrics.titled)
                    .frame(maxWidth: metrics.naturalWidth, alignment: .leading)
            }
        }
    }

    @ViewBuilder private func track(_ metrics: StepTrailMetrics) -> some View {
        if metrics.titled {
            trackView(metrics)
        } else {
            // Numbers only: as wide as the stops at full size, and narrower when that's all there is, the stops
            // shrinking to fit rather than wrapping or running off the edge.
            GeometryReader { proxy in
                trackView(metrics.fitted(to: proxy.size.width))
                    .frame(width: proxy.size.width, height: proxy.size.height)
            }
            .frame(idealWidth: metrics.naturalWidth, maxWidth: metrics.naturalWidth)
            .frame(height: metrics.rowHeight)
        }
    }

    private func trackView(_ metrics: StepTrailMetrics) -> some View {
        StepTrailTrack(
            steps: steps,
            metrics: metrics,
            shown: shown,
            furthest: furthest,
            open: open,
            lower: lowerEdge,
            upper: upperEdge,
            pull: pull,
            grabbed: grabbed,
            pressed: pressed,
            shakes: shakes,
            completions: completions,
            style: style,
            isEnabled: isEnabled,
            onTouch: handle
        )
    }

    /// Sizes for one form of the trail, at the current text size.
    private func metrics(titled: Bool, rtl: Bool) -> StepTrailMetrics {
        // Past the second accessibility size the stops stop growing: the caption carries the larger text.
        let scale = min(max(textScale, 1), 2.2)
        let d = (style.height * scale).rounded()
        let label = 15 * scale
        let lead = (d * 0.85).rounded()
        let tail = (d * 0.41).rounded()
        let cap = style.maximumTitleWidth * scale
        let titles = steps.indices.map { index in
            min(titleWidths[index] ?? CGFloat(steps[index].count) * label * 0.56, cap).rounded(.up)
        }
        let widths = titled ? titles.map { lead + $0 + tail } : Array(repeating: d, count: steps.count)
        let gap = PieceLiquid.joined
        let rest = CGFloat(steps.count - 1) * (d + gap)
        let natural = rest + (widths.max() ?? d)
        return StepTrailMetrics(d: d, gap: gap, label: label, titleLead: lead, titled: titled, rtl: rtl, openWidths: widths, titleWidths: titles, naturalWidth: natural, frameWidth: natural, scale: scale)
    }

    /// Measures every title once at the capsule's font, so the trail can size its capsule and reserve its widest form.
    private var titleProbe: some View {
        let size = 15 * min(max(textScale, 1), 2.2)
        return ZStack {
            ForEach(Array(steps.enumerated()), id: \.offset) { index, title in
                Text(title)
                    .font(.system(size: size, weight: .semibold))
                    .fixedSize()
                    .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width in
                        var quiet = Transaction()
                        quiet.disablesAnimations = true
                        withTransaction(quiet) { titleWidths[index] = width }
                    }
            }
        }
        .hidden()
        .accessibilityHidden(true)
    }

    // MARK: Caption

    @ViewBuilder private func caption(titled: Bool) -> some View {
        let count = steps.count
        let font = Font.system(size: captionSize, weight: .semibold)
        if shown >= count {
            Text(messages.complete)
                .font(font)
                .foregroundStyle(style.secondaryLabel)
                .transition(PieceMotion(reduceMotion: reduceMotion).swap)
        } else if titled {
            position(font: font)
                .transition(PieceMotion(reduceMotion: reduceMotion).swap)
        } else {
            // The capsule has no room for its title here, so the caption names the step.
            ViewThatFits(in: .horizontal) {
                HStack(alignment: .firstTextBaseline, spacing: 6) {
                    PieceMorphText(text: steps[shown], font: font)
                        .foregroundStyle(style.label)
                    position(font: font)
                }
                VStack(alignment: .leading, spacing: 2) {
                    Text(steps[shown])
                        .font(font)
                        .foregroundStyle(style.label)
                        .fixedSize(horizontal: false, vertical: true)
                    position(font: font)
                }
            }
        }
    }

    private func position(font: Font) -> some View {
        let position = min(shown, steps.count - 1) + 1
        return Text(messages.caption(position, steps.count))
            .font(font)
            .foregroundStyle(style.secondaryLabel)
            .fixedSize(horizontal: false, vertical: true)
            .contentTransition(reduceMotion ? .opacity : .numericText(value: Double(position)))
            // A count people read: it rolls without overshoot, whatever spring the trail moves on.
            .animation(PieceMotion(reduceMotion: reduceMotion).value, value: position)
    }

    // MARK: State

    private func clamped(_ value: Int) -> Int { min(max(value, 0), steps.count) }

    /// The last step people may go to: the furthest reached, never past the last step.
    private var furthest: Int {
        let current = clamped(selection)
        return min(max(current, reached ?? current), steps.count - 1)
    }

    private var actionIndices: [Int] {
        let current = clamped(selection)
        return (0...furthest).filter { $0 != current }
    }

    private var spokenValue: String {
        guard shown < steps.count else { return messages.complete }
        return messages.value(shown + 1, steps.count, steps[shown])
    }

    /// Draws `target` as current. The capsule closes on one stop and opens on the other on the morph spring, while
    /// the puck's edge facing the target leads on the snap spring and the far edge follows a beat later on a looser
    /// one, so the red stretches toward the target and gathers in on it. Under Reduce Motion the puck stays out of it:
    /// the stops carry the tint themselves and it crossfades.
    private func move(to target: Int) {
        let count = steps.count
        let from = shown
        guard target != from, count > 0 else { return }
        let motion = PieceMotion(reduceMotion: reduceMotion)
        withAnimation(motion.morph) {
            shown = target
            open = StepTrailOpenness(open: target, count: count)
        }
        let edge = Double(min(target, count - 1))
        if reduceMotion {
            withTransaction(Self.instant) {
                lowerEdge = edge
                upperEdge = edge
            }
        } else {
            let forward = edge >= (lowerEdge + upperEdge) / 2
            // The far edge waits a beat and follows on a looser spring, so the red reaches across the neck before it
            // lets go of the stop it leaves.
            let lead = motion.snap, trail = motion.follow(PieceMotion.responsive, rank: 4).delay(0.05)
            withAnimation(forward ? lead : trail) { upperEdge = edge }
            withAnimation(forward ? trail : lead) { lowerEdge = edge }
        }
        if target == count, from < count { completions += 1 }
    }

    /// Puts the trail on `index` with no motion: new steps, or a size change.
    private func place(_ index: Int) {
        let count = steps.count
        let edge = Double(min(index, max(count - 1, 0)))
        withTransaction(Self.instant) {
            shown = index
            open = StepTrailOpenness(open: index, count: count)
            lowerEdge = edge
            upperEdge = edge
            pull = 0
            scrubbing = nil
            grabbed = false
            pressed = nil
        }
    }

    /// A tap, a key or a VoiceOver action going to a step. Only steps up to `furthest` can be chosen.
    private func go(to index: Int) {
        guard index >= 0, index <= furthest, index != clamped(selection) else { return }
        selection = index
    }

    /// One step back or on, within what has been reached. Returns whether it moved.
    @discardableResult
    private func step(by delta: Int, haptic: Bool) -> Bool {
        let current = clamped(selection)
        let next = min(current, steps.count - 1) + (current >= steps.count && delta < 0 ? 0 : delta)
        guard next >= 0, next <= furthest, next != current else { return false }
        selection = next
        if haptic { landings += 1 }
        return true
    }

    private func handle(_ touch: StepTrailGesture) {
        guard isEnabled else { return }
        switch touch {
        case .press(let index):
            pressed = index.flatMap { $0 <= furthest ? $0 : nil }
        case .tap(let index):
            pressed = nil
            tap(index)
        case .scrubBegan(let index):
            scrubBegan(at: index)
        case .scrubMoved(let position, let pitch, let limit):
            scrubMoved(to: position, pitch: pitch, limit: limit)
        case .scrubEnded(let velocity, let pitch, let limit):
            scrubEnded(velocity: velocity, pitch: pitch, limit: limit)
        case .scrubCancelled:
            scrubEnded(velocity: 0, pitch: 1, limit: 1, cancelled: true)
        }
    }

    /// A step that can be reached goes there with a tick; one that can't shakes, and nothing moves.
    private func tap(_ index: Int) {
        guard steps.indices.contains(index) else { return }
        if index <= furthest {
            guard index != clamped(selection) else { return }
            selection = index
            landings += 1
        } else {
            shakes[index, default: 0] += 1
            refusals += 1
        }
    }

    private func scrubBegan(at index: Int) {
        guard furthest > 0 else { return }
        let start = min(max(index, 0), furthest)
        pressed = nil
        pushing = false
        scrubbing = start
        withAnimation(PieceMotion(reduceMotion: reduceMotion).press) { grabbed = true }
        if start != shown {
            move(to: start)
            landings += 1
        }
    }

    /// `position` is where the finger holds the capsule's number, in steps (1.5 is halfway from the second stop to
    /// the third). The capsule moves to a stop once the finger is a little past the midpoint, so it never flickers
    /// between two, and between stops the puck's facing edge reaches toward the finger with rubber-band resistance,
    /// which is all it does past the last step it may reach.
    private func scrubMoved(to position: Double, pitch: CGFloat, limit: CGFloat) {
        guard let current = scrubbing else { return }
        var next = current
        let nearest = Int(position.rounded())
        if nearest != current, abs(position - Double(nearest)) < 0.35 { next = nearest }
        next = min(max(next, 0), furthest)
        if next != current {
            scrubbing = next
            move(to: next)
            landings += 1
        }
        let reach = (position - Double(next)) * Double(pitch)
        withTransaction(Self.instant) {
            pull = reduceMotion ? 0 : Double(PieceMotion.rubberBand(CGFloat(reach), limit: limit))
        }
        // One rigid tick as the capsule is pushed against the last step it may reach, or the first; it rearms once
        // the finger comes back.
        let against = (next == furthest && position > Double(next) + 0.45) || (next == 0 && position < -0.45)
        if against, !pushing {
            pushing = true
            refusals += 1
        } else if !against, abs(position - Double(next)) < 0.3 {
            pushing = false
        }
    }

    /// The finger lifts: the capsule lands where it is and the binding changes once. The pull springs back at the
    /// speed the finger left it.
    private func scrubEnded(velocity: CGFloat, pitch: CGFloat, limit: CGFloat, cancelled: Bool = false) {
        guard let landing = scrubbing else { return }
        let motion = PieceMotion(reduceMotion: reduceMotion)
        scrubbing = nil
        pushing = false
        withAnimation(motion.release) { grabbed = false }
        let raw = CGFloat(pull)
        let frame: CGFloat = 1 / 120
        let speed = (PieceMotion.rubberBand(raw + velocity * frame, limit: limit) - raw) / frame
        withAnimation(motion.settle(velocity: cancelled ? 0 : speed, from: raw, to: 0)) { pull = 0 }
        if !cancelled, landing != clamped(selection) {
            selection = landing
        } else {
            move(to: clamped(selection))
        }
    }

    private static var instant: Transaction {
        var transaction = Transaction(animation: nil)
        transaction.disablesAnimations = true
        return transaction
    }
}

// MARK: - Track

/// What a touch on the trail means, already resolved to stops.
private enum StepTrailGesture {
    /// A finger is down on a stop, or lifted (`nil`).
    case press(Int?)
    case tap(Int)
    case scrubBegan(Int)
    /// Where the finger holds the capsule's number, in steps; the stop pitch and the pull's limit, in points.
    case scrubMoved(position: Double, pitch: CGFloat, limit: CGFloat)
    case scrubEnded(velocity: CGFloat, pitch: CGFloat, limit: CGFloat)
    case scrubCancelled
}

/// Sizes for one form of the trail. Lengths run from the trail's leading edge.
private struct StepTrailMetrics {
    var d: CGFloat
    var gap: CGFloat
    var label: CGFloat
    var titleLead: CGFloat
    var titled: Bool
    var rtl: Bool
    /// Each stop's width while it is the open capsule (the diameter when titles don't show).
    var openWidths: [CGFloat]
    var titleWidths: [CGFloat]
    /// The widest the trail gets at full size: every other stop a circle, the widest capsule open.
    var naturalWidth: CGFloat
    /// The width it is drawn in. The trail starts at its leading edge.
    var frameWidth: CGFloat
    var scale: CGFloat

    var rowHeight: CGFloat { max(d, 44) }
    var pitch: CGFloat { d + gap }

    /// Numbers only, shrunk to `width` when the stops at full size don't fit, never below 22 points.
    func fitted(to width: CGFloat) -> StepTrailMetrics {
        let count = CGFloat(openWidths.count)
        guard count > 0, width > 0 else { return self }
        let room = (width - (count - 1) * gap) / count
        let size = min(d, max(room, 22)).rounded(.down)
        var fitted = self
        fitted.label = label * size / d
        fitted.d = size
        fitted.titleLead = (size * 0.85).rounded()
        fitted.openWidths = Array(repeating: size, count: openWidths.count)
        fitted.frameWidth = min(count * size + (count - 1) * gap, max(width, 0))
        return fitted
    }
}

/// Where everything sits for one frame of the openness.
private struct StepTrailGeometry {
    let metrics: StepTrailMetrics
    /// Leading edge and width of each stop.
    let x: [CGFloat]
    let w: [CGFloat]
    let openness: [Double]

    init(metrics: StepTrailMetrics, open: [Double]) {
        self.metrics = metrics
        self.openness = open
        var x: [CGFloat] = [], w: [CGFloat] = []
        var cursor: CGFloat = 0
        for (index, wide) in metrics.openWidths.enumerated() {
            let amount = CGFloat(index < open.count ? max(open[index], 0) : 0)
            let width = metrics.d + (wide - metrics.d) * amount
            x.append(cursor)
            w.append(width)
            cursor += width + metrics.gap
        }
        self.x = x
        self.w = w
    }

    /// How open a stop is this frame.
    func open(_ index: Int) -> Double { index < openness.count ? min(max(openness[index], 0), 1) : 0 }

    /// A stop's left edge on screen.
    func screenX(_ index: Int) -> CGFloat {
        metrics.rtl ? metrics.frameWidth - x[index] - w[index] : x[index]
    }

    /// An edge at a step position: between stops it runs from one stop's edge to the next, and past the ends it
    /// carries on at the last spacing, so a spring's give reads past the first or last stop.
    func edge(at position: Double, trailing: Bool) -> CGFloat {
        let count = x.count
        func at(_ index: Int) -> CGFloat { trailing ? x[index] + w[index] : x[index] }
        guard count > 1 else { return at(0) + CGFloat(position) * metrics.pitch }
        let index = min(max(Int(position.rounded(.down)), 0), count - 2)
        let t = CGFloat(position - Double(index))
        return at(index) + (at(index + 1) - at(index)) * t
    }

    /// The puck between its two edges, with a finger's pull on the edge facing it. Never narrower than a stop.
    func puck(lower: Double, upper: Double, pull: Double) -> ClosedRange<CGFloat> {
        var low = edge(at: min(lower, upper), trailing: false)
        var high = edge(at: max(lower, upper), trailing: true)
        if pull < 0 { low += CGFloat(pull) } else { high += CGFloat(pull) }
        if high - low < metrics.d {
            let mid = (low + high) / 2
            low = mid - metrics.d / 2
            high = mid + metrics.d / 2
        }
        return low...high
    }

    /// The puck on screen.
    func screen(_ range: ClosedRange<CGFloat>) -> ClosedRange<CGFloat> {
        metrics.rtl ? (metrics.frameWidth - range.upperBound)...(metrics.frameWidth - range.lowerBound) : range
    }

    /// The stop under a point on the trail, measured from its leading edge. The neck between two stops splits.
    func stop(at leading: CGFloat) -> Int {
        for index in x.indices where leading < x[index] + w[index] + metrics.gap / 2 { return index }
        return max(x.count - 1, 0)
    }
}

private enum StepTrailStatus { case done, current, ahead, unreached }

/// The trail itself: glass stops joined by necks, and the red puck over them carrying every face in its ink, cut
/// to its outline, while each stop's own face is cut away under it, so a face turns exactly under the puck's edge.
/// Everything is laid out from three animated values (the openness and the puck's two edges), so the puck stays
/// glued to the stops while they change width.
private struct StepTrailTrack: View {
    let steps: [String]
    let metrics: StepTrailMetrics
    let shown: Int
    let furthest: Int
    let open: StepTrailOpenness
    let lower: Double
    let upper: Double
    let pull: Double
    let grabbed: Bool
    let pressed: Int?
    let shakes: [Int: Int]
    let completions: Int
    let style: StepTrail.Style
    let isEnabled: Bool
    let onTouch: (StepTrailGesture) -> Void

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    /// Where in the capsule the finger took hold, from its number, so the capsule never jumps to the finger.
    @State private var grabOffset: CGFloat = 0

    var body: some View {
        StepTrailAnimatedOpenness(value: open) { open in
            StepTrailAnimatedNumber(value: lower) { lower in
                StepTrailAnimatedNumber(value: upper) { upper in
                    StepTrailAnimatedNumber(value: pull) { pull in
                        drawing(open: open.values, lower: lower, upper: upper, pull: pull)
                    }
                }
            }
        }
        .frame(width: metrics.frameWidth, height: metrics.rowHeight)
        // Laid out on screen coordinates; the stops and faces mirror themselves for right to left.
        .environment(\.layoutDirection, .leftToRight)
        .overlay {
            StepTrailTouchSurface(isEnabled: isEnabled, scrubs: furthest > 0) { event in interpret(event) }
        }
    }

    /// Under Reduce Motion the stops carry the red themselves and it crossfades; the puck stays home.
    private var usesPuck: Bool { !reduceMotion }
    private var complete: Bool { shown >= steps.count }

    private func status(_ index: Int) -> StepTrailStatus {
        if index < shown { return .done }
        if index == shown { return .current }
        return index <= furthest ? .ahead : .unreached
    }

    private func drawing(open: [Double], lower: Double, upper: Double, pull: Double) -> some View {
        let geometry = StepTrailGeometry(metrics: metrics, open: open)
        let puck = geometry.screen(geometry.puck(lower: lower, upper: upper, pull: pull))
        return PieceLiquidGroup {
            ZStack(alignment: .leading) {
                ForEach(steps.indices, id: \.self) { index in
                    stop(index, geometry: geometry, puck: puck)
                }
                puckView(geometry: geometry, puck: puck)
            }
            .frame(width: metrics.frameWidth, height: metrics.d, alignment: .leading)
            // The flow completing lands as one beat across every stop.
            .pieceLiquidPop(trigger: completions, amount: 0.06)
        }
    }

    private func stop(_ index: Int, geometry: StepTrailGeometry, puck: ClosedRange<CGFloat>) -> some View {
        let status = status(index)
        let left = geometry.screenX(index)
        let width = geometry.w[index]
        let carriesRed = !usesPuck && status == .current
        let color: Color = switch status {
        case .done, .ahead: style.label
        case .current: carriesRed ? style.accentInk : style.label
        case .unreached: style.secondaryLabel
        }
        let cut = usesPuck && puck.upperBound > left && puck.lowerBound < left + width
        return face(index, status: status, width: width, openness: geometry.open(index))
            .foregroundStyle(color)
            .mask {
                if cut {
                    StepTrailCutout(lower: puck.lowerBound - left, upper: puck.upperBound - left, height: metrics.d)
                        .fill(style: FillStyle(eoFill: true))
                } else {
                    Rectangle()
                }
            }
            .clipShape(.capsule)
            .pieceLiquid(.capsule, tint: carriesRed ? style.accent : nil, interactive: false)
            .pieceLiquidPress(pressed == index && status != .current, depth: 2)
            .pieceShake(trigger: shakes[index] ?? 0, distance: 6)
            .offset(x: left)
    }

    /// The red puck: every face in the accent's ink, laid out exactly as the stops lay theirs out, cut to its outline.
    /// Once every step is done it is neutral glass over the last stop, drawing the same check the stop does.
    private func puckView(geometry: StepTrailGeometry, puck: ClosedRange<CGFloat>) -> some View {
        let motion = PieceMotion(reduceMotion: reduceMotion)
        let ink = complete ? style.label : style.accentInk
        return ZStack(alignment: .leading) {
            ForEach(steps.indices, id: \.self) { index in
                face(index, status: status(index), width: geometry.w[index], openness: geometry.open(index))
                    .offset(x: geometry.screenX(index) - puck.lowerBound)
            }
        }
        .foregroundStyle(ink)
        .frame(width: puck.upperBound - puck.lowerBound, height: metrics.d, alignment: .leading)
        .clipShape(.capsule)
        .pieceLiquid(.capsule, tint: complete ? nil : style.accent, interactive: false)
        .pieceLiquidPress(pressed == shown && !complete, depth: 2)
        // Grabbed, it lifts a little; let go, it lands with a little give.
        .pieceLiquidScale(grabbed && !reduceMotion ? 1.06 : 1)
        .animation(grabbed ? motion.press : motion.release, value: grabbed)
        // Inside a group glass ignores opacity, so under Reduce Motion the puck closes to nothing instead.
        .pieceLiquidScale(usesPuck ? 1 : 0.001)
        .offset(x: puck.lowerBound)
        .allowsHitTesting(false)
    }

    private func face(_ index: Int, status: StepTrailStatus, width: CGFloat, openness: Double) -> some View {
        StepTrailFace(
            number: index + 1,
            title: steps[index],
            done: status == .done,
            openness: openness,
            width: width,
            titleWidth: index < metrics.titleWidths.count ? metrics.titleWidths[index] : 0,
            metrics: metrics
        )
    }

    // MARK: Touch

    /// Turns touches on screen into stops. Taps hit the stop under the finger as the trail is drawn now; a drag
    /// holds the capsule where it was taken, and reports where that point is in steps, at an even pitch: with every
    /// stop before the capsule a circle, its number always sits a whole pitch from the next.
    private func interpret(_ event: StepTrailTouchSurface.Event) {
        let target = StepTrailGeometry(metrics: metrics, open: StepTrailOpenness(open: shown, count: steps.count).values)
        let leading = { (x: CGFloat) in metrics.rtl ? metrics.frameWidth - x : x }
        let limit = metrics.d * 0.35
        switch event {
        case .down(let x):
            onTouch(.press(target.stop(at: leading(x))))
        case .up:
            onTouch(.press(nil))
        case .tap(let x):
            onTouch(.tap(target.stop(at: leading(x))))
        case .began(let x):
            let point = leading(x)
            let index = min(target.stop(at: point), furthest)
            grabOffset = point - (CGFloat(index) * metrics.pitch + metrics.d / 2)
            onTouch(.scrubBegan(index))
        case .moved(let x):
            let position = (leading(x) - grabOffset - metrics.d / 2) / metrics.pitch
            onTouch(.scrubMoved(position: Double(position), pitch: metrics.pitch, limit: limit))
        case .ended(let velocity):
            onTouch(.scrubEnded(velocity: metrics.rtl ? -velocity : velocity, pitch: metrics.pitch, limit: limit))
        case .cancelled:
            onTouch(.scrubCancelled)
        }
    }
}

/// One stop's content: its number or check centred in the leading circle, and, in the titled form, its title after
/// it. The title's presence follows the stop's openness frame by frame, so it blurs in as the capsule opens and out
/// as it closes, identically in the stop and in the puck's copy; the number and the check blur over.
private struct StepTrailFace: View {
    let number: Int
    let title: String
    let done: Bool
    /// How open the stop is this frame: 0 a circle, 1 the capsule.
    let openness: Double
    let width: CGFloat
    let titleWidth: CGFloat
    let metrics: StepTrailMetrics
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    var body: some View {
        let motion = PieceMotion(reduceMotion: reduceMotion)
        let edge: Alignment = metrics.rtl ? .trailing : .leading
        // The title arrives over the last two thirds of the opening and is gone in the first third of the closing.
        let shows = min(max((openness - 0.35) / 0.65, 0), 1)
        ZStack(alignment: edge) {
            ZStack {
                if done {
                    Image(systemName: "checkmark")
                        .font(.system(size: metrics.label * 0.9, weight: .semibold))
                        .transition(motion.swap)
                } else {
                    Text(number, format: .number)
                        .font(.system(size: metrics.label, weight: .semibold).monospacedDigit())
                        .lineLimit(1)
                        .minimumScaleFactor(0.6)
                        .transition(motion.swap)
                }
            }
            .frame(width: metrics.d, height: metrics.d)
            if metrics.titled {
                Text(title)
                    .font(.system(size: metrics.label, weight: .semibold))
                    .lineLimit(1)
                    .frame(width: titleWidth, alignment: edge)
                    .blur(radius: reduceMotion ? 0 : (1 - shows) * 5)
                    .opacity(shows)
                    .padding(metrics.rtl ? .trailing : .leading, metrics.titleLead)
            }
        }
        .frame(width: max(width, 0), height: metrics.d, alignment: edge)
    }
}

/// Everything in a stop but the puck's stretch over it, for an even-odd fill: the stop's own face shows only
/// where the puck is not.
private struct StepTrailCutout: Shape {
    var lower: CGFloat
    var upper: CGFloat
    var height: CGFloat

    func path(in rect: CGRect) -> Path {
        var path = Path(rect)
        let width = max(upper - lower, 0)
        path.addRoundedRect(in: CGRect(x: rect.minX + lower, y: rect.midY - height / 2, width: width, height: height), cornerSize: CGSize(width: height / 2, height: height / 2), style: .continuous)
        return path
    }
}

// MARK: - Animation

/// How open each stop is, as one value SwiftUI can animate: every stop's openness springs on its own from wherever it
/// is, so a step chosen while another is still opening retargets smoothly.
#if compiler(>=6.2)
nonisolated private struct StepTrailOpenness: VectorArithmetic, Sendable {
    var values: [Double]
}
#else
private struct StepTrailOpenness: VectorArithmetic, Sendable {
    var values: [Double]
}
#endif

extension StepTrailOpenness {
    /// Stop `open` open, every other one a circle. Past the last step, none.
    nonisolated init(open: Int, count: Int) {
        values = (0..<max(count, 0)).map { $0 == open ? 1 : 0 }
    }

    nonisolated static var zero: StepTrailOpenness { StepTrailOpenness(values: []) }

    nonisolated static func + (lhs: StepTrailOpenness, rhs: StepTrailOpenness) -> StepTrailOpenness {
        combine(lhs, rhs, +)
    }

    nonisolated static func - (lhs: StepTrailOpenness, rhs: StepTrailOpenness) -> StepTrailOpenness {
        combine(lhs, rhs, -)
    }

    nonisolated mutating func scale(by rhs: Double) {
        values = values.map { $0 * rhs }
    }

    nonisolated var magnitudeSquared: Double {
        values.reduce(0) { $0 + $1 * $1 }
    }

    /// Element by element; a shorter list counts as zeros, so a change in the number of steps still adds up.
    nonisolated private static func combine(_ lhs: StepTrailOpenness, _ rhs: StepTrailOpenness, _ operation: (Double, Double) -> Double) -> StepTrailOpenness {
        let count = max(lhs.values.count, rhs.values.count)
        return StepTrailOpenness(values: (0..<count).map { index in
            operation(index < lhs.values.count ? lhs.values[index] : 0, index < rhs.values.count ? rhs.values[index] : 0)
        })
    }
}

/// The openness, animated: SwiftUI interpolates it frame by frame and rebuilds the trail with it.
private struct StepTrailAnimatedOpenness<Content: View>: View, Animatable {
    var value: StepTrailOpenness
    @ViewBuilder let content: (StepTrailOpenness) -> Content

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

    var body: some View { content(value) }
}

/// One number animated on its own spring. Nested, each of the puck's edges keeps its own.
private struct StepTrailAnimatedNumber<Content: View>: View, Animatable {
    var value: Double
    @ViewBuilder let content: (Double) -> Content

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

    var body: some View { content(value) }
}

// MARK: - Touch surface

/// UIKit's recognizers under the trail, because SwiftUI can't start a drag only when it runs sideways: a tap, a
/// sideways pan that leaves vertical swipes to the scroll view or sheet around it, and a touch-down observer that
/// never claims the touch, so a press shows at once without blocking a scroll.
private struct StepTrailTouchSurface: UIViewRepresentable {
    enum Event {
        case down(CGFloat), up, tap(CGFloat), began(CGFloat), moved(CGFloat), ended(velocity: CGFloat), cancelled
    }

    var isEnabled: Bool
    var scrubs: Bool
    var onEvent: (Event) -> Void

    func makeCoordinator() -> Coordinator { Coordinator() }

    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        view.backgroundColor = .clear
        view.isAccessibilityElement = false
        view.accessibilityElementsHidden = true
        let coordinator = context.coordinator
        let down = StepTrailTouchDown()
        down.onChange = { [weak coordinator] point in coordinator?.onEvent(point.map { .down($0.x) } ?? .up) }
        down.delegate = coordinator
        let tap = UITapGestureRecognizer(target: coordinator, action: #selector(Coordinator.tap(_:)))
        let pan = UIPanGestureRecognizer(target: coordinator, action: #selector(Coordinator.pan(_:)))
        pan.delegate = coordinator
        for recognizer in [down, tap, pan] { view.addGestureRecognizer(recognizer) }
        coordinator.pan = pan
        return view
    }

    func updateUIView(_ view: UIView, context: Context) {
        context.coordinator.onEvent = onEvent
        context.coordinator.pan?.isEnabled = scrubs
        view.isUserInteractionEnabled = isEnabled
    }

    @MainActor
    final class Coordinator: NSObject, UIGestureRecognizerDelegate {
        var onEvent: (Event) -> Void = { _ in }
        weak var pan: UIPanGestureRecognizer?

        @objc func tap(_ recognizer: UITapGestureRecognizer) {
            if recognizer.state == .ended { onEvent(.tap(recognizer.location(in: recognizer.view).x)) }
        }

        @objc func pan(_ recognizer: UIPanGestureRecognizer) {
            let x = recognizer.location(in: recognizer.view).x
            switch recognizer.state {
            case .began: onEvent(.began(x))
            case .changed: onEvent(.moved(x))
            case .ended: onEvent(.ended(velocity: recognizer.velocity(in: recognizer.view).x))
            case .cancelled, .failed: onEvent(.cancelled)
            default: break
            }
        }

        /// The pan starts only for a mostly sideways drag, so vertical swipes stay with the screen.
        func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool {
            guard let pan = recognizer as? UIPanGestureRecognizer else { return true }
            let velocity = pan.velocity(in: pan.view)
            return abs(velocity.x) > abs(velocity.y)
        }

        /// The touch-down observer watches alongside everything else.
        func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool {
            recognizer is StepTrailTouchDown || other is StepTrailTouchDown
        }
    }
}

/// Reports a finger arriving and leaving without ever recognizing, so it never takes a touch from anything else.
private final class StepTrailTouchDown: UIGestureRecognizer {
    var onChange: ((CGPoint?) -> Void)?
    private var start: CGPoint?

    init() {
        super.init(target: nil, action: nil)
        cancelsTouchesInView = false
        delaysTouchesEnded = false
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesBegan(touches, with: event)
        guard let touch = touches.first, start == nil else { return }
        start = touch.location(in: view)
        onChange?(start)
    }

    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesMoved(touches, with: event)
        // A finger that travels lets its stop go: a drag or a scroll takes over from here.
        if let touch = touches.first, let start, hypot(touch.location(in: view).x - start.x, touch.location(in: view).y - start.y) > 12 {
            finish()
        }
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesEnded(touches, with: event)
        finish()
    }

    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesCancelled(touches, with: event)
        finish()
    }

    private func finish() {
        if start != nil { onChange?(nil) }
        start = nil
        state = .failed
    }

    override func reset() {
        super.reset()
        start = nil
    }
}

/// A house-palette color that follows the interface style.
private func stepTrailColor(light: UInt32, dark: UInt32) -> Color {
    Color(uiColor: UIColor { @Sendable traits in
        let hex = traits.userInterfaceStyle == .dark ? dark : light
        return UIColor(red: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: 1)
    })
}

// MARK: - Example

/// A checkout people went back in (Payment still reachable), and a six-step verification compressed to numbers.
private struct StepTrailExample: View {
    @State private var checkout = 1
    @State private var furthest = 2
    @State private var verification = 3

    var body: some View {
        VStack(alignment: .leading, spacing: 36) {
            StepTrail(steps: ["Cart", "Address", "Payment", "Review"], selection: $checkout, reached: furthest)
                .onChange(of: checkout) { _, step in furthest = max(furthest, step) }
            StepTrail(steps: ["Email", "Phone", "Address", "ID photo", "Selfie", "Review"], selection: $verification)
                .frame(maxWidth: 240, alignment: .leading)
            Button("Continue") { checkout = min(checkout + 1, 4) }
                .fontWeight(.semibold)
                .tint(Color(red: 1, green: 0, blue: 0))
        }
        .padding(24)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(stepTrailColor(light: 0xF3F2EE, dark: 0x121212))
    }
}

#Preview("Light") {
    StepTrailExample()
}

#Preview("Dark") {
    StepTrailExample()
        .preferredColorScheme(.dark)
}

// MARK: - Piece motion
//
// The SwiftPieces motion language: shared spring tokens and gesture physics, with the same values in every
// piece. Each piece carries a copy of only the parts it uses, so this file stands alone. Generated from
// registry/foundation/PieceMotion.swift in the SwiftPieces repo; edit it there, not here.
// swiftpieces-motion: 1.1.0 (core, follow, momentum, rubberBand, pressMath, shake)

/// The SwiftPieces motion language: five spring tiers, and a named role for every moment a piece moves,
/// each with its Reduce Motion substitute.
///
/// Build one from the environment, `PieceMotion(reduceMotion: reduceMotion)`, and pick the role that names
/// what just happened. Never animate the tracking of a finger: set gesture state directly in `onChanged`, so the
/// surface stays under the finger, and spring only the release.
private struct PieceMotion {
    var reduceMotion = false

    // Tiers. Overshoot and settle times are measured from rest with SwiftUI's Spring.
    /// No overshoot, 90% in about 90ms. A press arriving under the finger.
    static var tight: Spring { Spring(duration: 0.14, bounce: 0) }
    /// About 2.8% overshoot, 90% in about 140ms. Snapping to a detent, page or segment.
    static var responsive: Spring { Spring(duration: 0.32, bounce: 0.25) }
    /// About 8.4% overshoot. Visible give: a release, a return from past an edge, a drag settling home.
    static var elastic: Spring { Spring(duration: 0.42, bounce: 0.38) }
    /// About 15% overshoot. A resolved action landing. At most once per interaction.
    static var expressive: Spring { Spring(duration: 0.48, bounce: 0.48) }
    /// About 1.5% overshoot, unhurried. Opening large surfaces and ambient change.
    static var calm: Spring { Spring(duration: 0.5, bounce: 0.2) }

    /// Reduce Motion: settles become this short spring with no overshoot.
    private static var still: Animation { .spring(duration: 0.25, bounce: 0) }

    // Roles.
    /// Touch-down. Starts on the same frame and never bounces under the finger.
    var press: Animation { .spring(Self.tight) }
    /// The finger lifts off a pressed surface, which springs back through rest.
    var release: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
    /// A dragged thing comes to rest. With a gesture's velocity, use `settle(velocity:from:to:)` instead.
    var settle: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
    /// Lands on a detent, page or segment.
    var snap: Animation { reduceMotion ? Self.still : .spring(Self.responsive) }
    /// A number, a chart value or anything else people read moves to its new value. Never overshoots, so it
    /// never shows a value that isn't true.
    var value: Animation { reduceMotion ? Self.still : .spring(duration: 0.35, bounce: 0) }
    /// Comes back from past a limit: a pull beyond the edge, a value pushed against its bound.
    var rebound: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
    /// A shape or container changes size, corner radius or form, with a little give at the end.
    var morph: Animation { reduceMotion ? Self.still : .spring(duration: 0.4, bounce: 0.2) }
    /// Something appears, opens or expands. Opening is a little slower than closing.
    var reveal: Animation { reduceMotion ? .easeOut(duration: 0.2) : .spring(Self.calm) }
    /// Something leaves, closes or collapses. Quick and firm, out of the way.
    var dismiss: Animation { reduceMotion ? .easeIn(duration: 0.18) : .spring(duration: 0.3, bounce: 0.08) }
    /// A resolved action lands: a check, a sent state, a reaction.
    var success: Animation { reduceMotion ? .easeOut(duration: 0.24) : .spring(Self.expressive) }
    /// A refused action. Firm, no wobble; `pieceShake` adds the movement.
    var error: Animation { reduceMotion ? .easeOut(duration: 0.2) : .spring(Self.responsive) }
    /// Slow ambient change. Loops themselves stop under Reduce Motion: check `allowsAmbient`.
    var ambient: Animation { reduceMotion ? .easeInOut(duration: 0.3) : .spring(Self.calm) }

    /// Loops, drifts, idle breathing and parallax run only when this is true.
    var allowsAmbient: Bool { !reduceMotion }

    /// A moving transition, or a plain fade under Reduce Motion.
    func transition(_ transition: AnyTransition) -> AnyTransition {
        reduceMotion ? .opacity : transition
    }

    /// For content replaced inside a container that stays put (a label, a glyph, a count): the old content
    /// blurs out as the new one sharpens in. A fade under Reduce Motion.
    @MainActor var swap: AnyTransition {
        reduceMotion ? .opacity : AnyTransition(.blurReplace)
    }
}

extension PieceMotion {
    /// Follow-through: rank 0 leads, each later rank arrives a beat later on a slightly looser spring. Safe to reverse.
    func follow(_ spring: Spring = PieceMotion.elastic, rank: Int) -> Animation {
        guard !reduceMotion else { return .spring(duration: 0.25, bounce: 0) }
        let k = Double(min(max(rank, 0), 6))
        return .spring(duration: spring.duration + 0.04 * k, bounce: min(spring.bounce + 0.02 * k, 0.55))
    }

    /// One-shot entrances only: item `index` waits 30ms per place, capped at the seventh. Exits go together.
    func cascade(_ animation: Animation, index: Int, step: Double = 0.03) -> Animation {
        guard !reduceMotion, index > 0 else { return animation }
        return animation.delay(step * Double(min(index, 7)))
    }
}

extension PieceMotion {
    /// Where a flick at `velocity` (pt/s) coasts to. 0.998 coasts like a scroll view; 0.99 suits detents.
    nonisolated static func project(_ position: CGFloat, velocity: CGFloat, decelerationRate: CGFloat = 0.99) -> CGFloat {
        position + velocity / 1000 * decelerationRate / (1 - decelerationRate)
    }

    /// The candidate closest to `value`.
    nonisolated static func nearest(_ value: CGFloat, in candidates: [CGFloat]) -> CGFloat {
        candidates.min { abs($0 - value) < abs($1 - value) } ?? value
    }

    /// A settle that leaves at the finger's speed (pt/s). One per axis, each on its own `.offset(x:)` / `.offset(y:)`:
    /// a spring takes one velocity. SwiftUI's own velocity carry-over is unreliable; this always carries it.
    func settle(velocity: CGFloat, from current: CGFloat, to target: CGFloat, spring: Spring = PieceMotion.elastic) -> Animation {
        let spring = reduceMotion ? Spring(duration: 0.25, bounce: 0) : spring
        let distance = target - current
        guard abs(distance) >= 1 else { return .spring(spring) }
        // In whole distances per second, capped near the spring's frequency: a hard flick adds give, not a slingshot.
        // At exactly the frequency a critically damped spring cannot pass its target, so Reduce Motion stops there.
        let cap = 2 * Double.pi / spring.duration * (reduceMotion ? 1 : 1.5)
        let relative = min(max(Double(velocity / distance), -cap), cap)
        return .interpolatingSpring(spring, initialVelocity: relative)
    }
}

extension PieceMotion {
    /// A scroll view's edge resistance for a pull `overshoot` points past a limit; never reaches `limit`.
    /// About 24 to 40 for thumbs and toggles, 60 to 120 for cards and sheets. Band the total pull, not deltas.
    nonisolated static func rubberBand(_ overshoot: CGFloat, limit: CGFloat, coefficient: CGFloat = 0.55) -> CGFloat {
        guard limit > 0, overshoot != 0 else { return 0 }
        let banded = (1 - 1 / (abs(overshoot) * coefficient / limit + 1)) * limit
        return overshoot < 0 ? -banded : banded
    }

    /// `value` inside `range` passes through unchanged; past either end it moves with rubber-band resistance.
    nonisolated static func rubberBand(_ value: CGFloat, in range: ClosedRange<CGFloat>, limit: CGFloat, coefficient: CGFloat = 0.55) -> CGFloat {
        if value < range.lowerBound { return range.lowerBound + rubberBand(value - range.lowerBound, limit: limit, coefficient: coefficient) }
        if value > range.upperBound { return range.upperBound + rubberBand(value - range.upperBound, limit: limit, coefficient: coefficient) }
        return value
    }
}

extension PieceMotion {
    /// About `depth` points per edge, not a fixed percentage: an icon sinks to 0.92, a pill 0.95, a card 0.985.
    nonisolated static func pressScale(for size: CGSize, depth: CGFloat = 2.5) -> CGFloat {
        let side = (max(size.width, 1) * max(size.height, 1)).squareRoot()
        return min(max(1 - depth * 2 / side, 0.92), 0.985)
    }

    /// An anchor partway from the center toward the touch, so the press leans into the finger without tipping.
    nonisolated static func pressAnchor(touch: CGPoint?, in size: CGSize, lean: CGFloat = 0.6) -> UnitPoint {
        guard let touch, size.width > 0, size.height > 0 else { return .center }
        let x = min(max(touch.x / size.width, 0), 1)
        let y = min(max(touch.y / size.height, 0), 1)
        return UnitPoint(x: 0.5 + (x - 0.5) * lean, y: 0.5 + (y - 0.5) * lean)
    }
}

/// A short decaying side-to-side shake for refused input, each time `trigger` changes. Under Reduce Motion it stays
/// still (same view, no identity change): pair it with a color or message change and the error haptic.
private struct PieceShake: ViewModifier {
    let trigger: AnyHashable
    var distance: CGFloat = 8
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        let distance = reduceMotion ? 0 : distance
        content.keyframeAnimator(initialValue: CGFloat(0), trigger: trigger) { view, x in
            view.offset(x: x)
        } keyframes: { _ in
            KeyframeTrack {
                CubicKeyframe(-distance, duration: 0.06)
                CubicKeyframe(distance * 0.75, duration: 0.07)
                CubicKeyframe(-distance * 0.5, duration: 0.07)
                CubicKeyframe(distance * 0.25, duration: 0.06)
                SpringKeyframe(0, duration: 0.12, spring: Spring(duration: 0.18, bounce: 0))
            }
        }
    }
}

private extension View {
    /// Shakes this view side to side once each time `trigger` changes, for input that was refused.
    func pieceShake(trigger: some Hashable & Sendable, distance: CGFloat = 8) -> some View {
        modifier(PieceShake(trigger: AnyHashable(trigger), distance: distance))
    }
}

// swiftpieces-motion: end

// MARK: - Piece liquid
//
// The SwiftPieces liquid glass language: glass shapes that merge through a neck, bubbles that bud out of
// and melt back into each other, and the frosted fallback before iOS 26. Each piece carries a copy of only
// the parts it uses, so this file stands alone. Generated from registry/foundation/PieceLiquid.swift in the
// SwiftPieces repo; edit it there, not here. The rules are in LIQUID_GLASS.md.
// swiftpieces-liquid: 1.7.1 (liquid, liquidPress, liquidPop, morphText)

/// The liquid glass language: one merge distance, two rest gaps, and the springs a bubble leaves and comes home on.
///
/// Glass shapes inside one `PieceLiquidGroup` melt into each other through a neck when they come within `merge`
/// points. Parts of one control rest `joined`, inside that distance, so the neck holds; separate actions rest
/// `apart`, outside it, so they only goo while one buds out of, or melts back into, another.
private enum PieceLiquid {
    /// Glass shapes closer than this share a neck.
    static let merge: CGFloat = 20
    /// The gap between parts of one control (a stepper's buttons, a progress pill and its stop): the neck holds,
    /// short and smooth, about two thirds of the shapes' height at its waist. Joined parts read best at one height.
    static let joined: CGFloat = 4
    /// The gap between separate actions (menu items, confirm and cancel, chips): they rest as their own bubbles.
    static let apart: CGFloat = 26
    /// How far a bubble shrinks while it is home inside its parent.
    static let homeScale: CGFloat = 0.72

    /// A bubble leaving its parent: slow enough that the neck's stretch and snap read.
    static func split(reduceMotion: Bool) -> Animation {
        reduceMotion ? .easeOut(duration: 0.2) : .spring(duration: 0.62, bounce: 0.22)
    }

    /// A bubble going home. No bounce: a bounce would carry it out through the far side of its parent.
    static func home(reduceMotion: Bool) -> Animation {
        reduceMotion ? .easeIn(duration: 0.18) : .spring(duration: 0.5, bounce: 0)
    }
}

/// A group of glass shapes that merge into one liquid surface. On iOS 26 it is a `GlassEffectContainer`; before
/// that, and under Reduce Transparency, the shapes draw on their own and simply don't merge. `lift` adds the soft
/// shadow liquid glass floats on in light mode.
private struct PieceLiquidGroup<Content: View>: View {
    var spacing: CGFloat = PieceLiquid.merge
    var lift = true
    @ViewBuilder var content: Content
    @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
    @Environment(\.colorScheme) private var colorScheme

    var body: some View {
        container
            .shadow(color: .black.opacity(lift && colorScheme == .light ? 0.07 : 0), radius: 18, y: 8)
    }

    @ViewBuilder private var container: some View {
        #if compiler(>=6.2)
        if #available(iOS 26, *), !reduceTransparency {
            GlassEffectContainer(spacing: spacing) { content }
        } else {
            content
        }
        #else
        content
        #endif
    }
}

/// One liquid glass shape: Liquid Glass on iOS 26, carrying `tint` as a solid colour and swelling under a press when
/// `interactive`; a frosted Material with a light rim and a soft shadow before that; a solid fill under Reduce
/// Transparency.
///
/// The tint is painted inside clear glass rather than tinting the glass. Tinted glass in a group bleeds its colour
/// through every neck as a smear, so a red button would glow into the white pill it is joined to; painted inside,
/// the colour stays crisp to the shape's edge, the necks between shapes are clear glass, and a tint change animates
/// like any colour (tinted glass snaps).
private struct PieceLiquidSurface<S: Shape>: ViewModifier {
    var shape: S
    var tint: Color?
    var interactive: Bool
    @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
    @Environment(\.colorScheme) private var colorScheme
    @Environment(\.pieceLiquidScale) private var scale

    func body(content: Content) -> some View {
        #if compiler(>=6.2)
        if #available(iOS 26, *), !reduceTransparency {
            // Scaled as two parts, content and outline, about the same centre. A scaleEffect on a glass view inside a
            // GlassEffectContainer shrinks the glass but leaves what it carries full size, pinned to its corner.
            content
                .background { shape.fill(tint ?? .clear) }
                .scaleEffect(scale)
                .glassEffect(glass, in: shape.scale(scale))
        } else {
            fallback(content).scaleEffect(scale)
        }
        #else
        fallback(content).scaleEffect(scale)
        #endif
    }

    #if compiler(>=6.2)
    @available(iOS 26, *)
    private var glass: Glass {
        interactive ? Glass.regular.interactive() : .regular
    }
    #endif

    private func fallback(_ content: Content) -> some View {
        let dark = colorScheme == .dark
        return content
            .background {
                if reduceTransparency {
                    shape.fill(tint ?? (dark ? Color(white: 0.17) : Color(white: 0.97)))
                } else {
                    ZStack {
                        shape.fill(.regularMaterial)
                        if let tint { shape.fill(tint.opacity(0.88)) }
                    }
                }
            }
            .overlay { shape.stroke(Color.white.opacity(dark ? 0.14 : 0.7), lineWidth: 0.5) }
            .shadow(color: .black.opacity(dark ? 0.32 : 0.08), radius: 10, y: 5)
    }
}

private struct PieceLiquidScaleKey: EnvironmentKey {
    static let defaultValue: CGFloat = 1
}

private extension EnvironmentValues {
    /// How much the liquid shapes below are scaled, about their own centres. Nested scales multiply.
    var pieceLiquidScale: CGFloat {
        get { self[PieceLiquidScaleKey.self] }
        set { self[PieceLiquidScaleKey.self] = newValue }
    }
}

private extension View {
    /// Draws this view on a liquid glass `shape`. Put it inside a `PieceLiquidGroup` so it can merge with its neighbours.
    func pieceLiquid<S: Shape>(_ shape: S, tint: Color? = nil, interactive: Bool = true) -> some View {
        modifier(PieceLiquidSurface(shape: shape, tint: tint, interactive: interactive))
    }

    /// Scales the liquid glass shapes in this view, content and outline together, about their own centres. Use it
    /// instead of `scaleEffect` on a glass view (a press, a lift, a swell): inside a group a plain `scaleEffect` shrinks
    /// the glass but leaves its content full size and off centre. Animates like any other value.
    func pieceLiquidScale(_ scale: CGFloat) -> some View {
        transformEnvironment(\.pieceLiquidScale) { $0 *= scale }
    }
}

/// The press for a glass control: the same size-aware sink and lean as `piecePress`, applied through
/// `pieceLiquidScale` so the glass and what it carries sink together (a plain scaleEffect on glass leaves the content
/// behind). Put the glass inside what it presses: the label of a button, the view this modifies. Under Reduce Motion
/// it shades instead of moving.
private struct PieceLiquidPress: ViewModifier {
    let pressed: Bool
    var touch: CGPoint?
    var depth: CGFloat = 2.5
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @Environment(\.colorScheme) private var colorScheme
    @State private var size: CGSize = .zero
    @State private var anchor: UnitPoint = .center

    func body(content: Content) -> some View {
        let motion = PieceMotion(reduceMotion: reduceMotion)
        let scale = pressed && !reduceMotion ? PieceMotion.pressScale(for: size, depth: depth) : 1
        // A scale about `anchor` is a scale about the centre plus this shift toward the anchor.
        let lean = CGSize(width: (anchor.x - 0.5) * size.width * (1 - scale), height: (anchor.y - 0.5) * size.height * (1 - scale))
        content
            .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 }
            .brightness(pressed && reduceMotion ? (colorScheme == .dark ? 0.1 : -0.08) : 0)
            .pieceLiquidScale(scale)
            .offset(lean)
            .animation(pressed ? motion.press : motion.release, value: pressed)
            .onChange(of: pressed) { _, isPressed in
                if isPressed { anchor = PieceMotion.pressAnchor(touch: touch, in: size) }
            }
            .onChange(of: touch) { _, touch in
                if pressed, let touch { anchor = PieceMotion.pressAnchor(touch: touch, in: size) }
            }
    }
}

/// `PiecePressStyle` for glass buttons: the label (with its `.pieceLiquid` inside) sinks as one.
private struct PieceLiquidPressStyle: ButtonStyle {
    var depth: CGFloat = 2.5

    func makeBody(configuration: Configuration) -> some View {
        configuration.label.pieceLiquidPress(configuration.isPressed, depth: depth)
    }
}

private extension View {
    /// Sinks this view's glass while `pressed`, leaning toward `touch` (in this view's coordinates) when given.
    func pieceLiquidPress(_ pressed: Bool, touch: CGPoint? = nil, depth: CGFloat = 2.5) -> some View {
        modifier(PieceLiquidPress(pressed: pressed, touch: touch, depth: depth))
    }
}

/// `piecePop` for glass: dips, swells past full size and lands each time `trigger` changes, on the same timings,
/// through `pieceLiquidScale` so the glass and what it carries pop together. Driven from state rather than a
/// keyframe animator, since the liquid scale can't be set from inside one. Still under Reduce Motion.
private struct PieceLiquidPop: ViewModifier {
    let trigger: AnyHashable
    var amount: CGFloat = 0.08
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var scale: CGFloat = 1
    @State private var run = 0

    func body(content: Content) -> some View {
        content
            .pieceLiquidScale(scale)
            .onChange(of: trigger) { _, _ in
                guard !reduceMotion else { return }
                run += 1
                let mine = run
                withAnimation(.easeInOut(duration: 0.08)) { scale = 1 - amount * 0.4 }
                Task { @MainActor in
                    try? await Task.sleep(for: .milliseconds(80))
                    guard run == mine else { return }
                    withAnimation(.spring(duration: 0.18, bounce: 0)) { scale = 1 + amount }
                    try? await Task.sleep(for: .milliseconds(140))
                    guard run == mine else { return }
                    withAnimation(.spring(PieceMotion.expressive)) { scale = 1 }
                }
            }
    }
}

private extension View {
    /// Pops this view's glass each time `trigger` changes. Use a counter, never a Bool that can flip back before it fires.
    func pieceLiquidPop(trigger: some Hashable & Sendable, amount: CGFloat = 0.08) -> some View {
        modifier(PieceLiquidPop(trigger: AnyHashable(trigger), amount: amount))
    }
}

/// A label that changes letter by letter: letters both strings share hold still, the rest blur out and the new ones
/// blur in a few milliseconds apart. Under Reduce Motion it cross-fades. VoiceOver reads the whole string.
///
/// Only text whose letters stand on their own is cut into letters: Latin, Greek, Cyrillic, CJK, digits and symbols.
/// Those letters are laid out left to right whatever the layout direction, the order they read in, so "1.5×" stays
/// "1.5×" in an Arabic or Hebrew interface instead of coming out backwards. Text in a right-to-left script, or in one
/// whose letters join or reorder (Arabic, Hebrew, Devanagari, Thai and their neighbours), can't be cut apart without
/// breaking it, so it changes as one: the old string blurs out where it stood while the new one blurs in.
private struct PieceMorphText: View {
    var text: String
    var font: Font = .body.weight(.semibold)
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    var body: some View {
        Group {
            if Self.splits(text) {
                let glyphs = Array(text)
                HStack(spacing: 0) {
                    ForEach(glyphs.indices, id: \.self) { i in
                        Text(String(glyphs[i]))
                            .id("\(i)\(glyphs[i])")
                            .transition(transition(i, scale: 0.6))
                    }
                }
                // The letters' reading order, not the layout's: an HStack lays its children out backwards under a
                // right-to-left layout.
                .environment(\.layoutDirection, .leftToRight)
            } else {
                ZStack(alignment: .leading) {
                    Text(text)
                        .id(text)
                        .transition(transition(0, scale: 0.9))
                }
            }
        }
        .font(font)
        .fixedSize()
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(text)
    }

    private func transition(_ i: Int, scale: CGFloat) -> AnyTransition {
        guard !reduceMotion else { return .opacity }
        return AnyTransition(.blurReplace(.downUp)).combined(with: .scale(scale: scale, anchor: .bottom))
            .animation(.spring(duration: 0.42, bounce: 0.3).delay(Double(i) * 0.022))
    }

    /// Whether `text` can be cut into letters that each read left to right on their own: no right-to-left script, no
    /// script that joins or reorders its letters, and no bidirectional control characters.
    private static func splits(_ text: String) -> Bool {
        !text.unicodeScalars.contains { scalar in
            switch scalar.value {
            case 0x0590...0x08FF, // Hebrew, Arabic, Syriac, Thaana, NKo, Samaritan, Mandaic
                 0x0900...0x0DFF, // Devanagari to Sinhala
                 0x0E00...0x0FFF, // Thai, Lao, Tibetan
                 0x1000...0x109F, // Myanmar
                 0x1780...0x17FF, // Khmer
                 0xA8E0...0xA8FF, // Devanagari Extended
                 0xFB1D...0xFDFF, // Hebrew and Arabic presentation forms
                 0xFE70...0xFEFF, // Arabic presentation forms B
                 0x200E...0x200F, 0x202A...0x202E, 0x2066...0x2069, // bidirectional marks, embeddings and isolates
                 0x10800...0x10FFF, // historic right-to-left scripts
                 0x1E800...0x1EFFF: // Adlam and other right-to-left scripts
                return true
            default:
                return false
            }
        }
    }
}

// swiftpieces-liquid: end

Install

Pick one. Run it from the folder that contains your .xcodeproj and the files land inside your app.

Terminal

$npx swiftpieces add StepTrail

Or ask your coding agent · Claude Code, Cursor or Xcode, once the MCP server is connected

>Add the SwiftPieces "Step Trail" piece to my app

Or copy the source above into your app.

New to SwiftUI?If you know React, CSS or Tailwind, SwiftUI is easier to pick up than it looks.Try real app patterns first →

Building a whole app? See SwiftPieces Pro →

All navigation →

Build it yourself: SwiftUI animations guide

What's new

On this page

SwiftPiecesPro

Build the whole app with one library.

Production-ready screens, whole flows, complete app templates, a Build Kit your agent follows and Pro remixing.

54 screens · 48 flows · 12 templates · 31 Build Kit skills

Explore Pro

Sponsors

Keep it free

Your logo hereBecome a sponsor