Skip to content
Status Morph preview
An animated render of the SwiftUI source on this page.

Status Morph

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

A status pill that morphs between named states, its colour, corner radius, and width on one spring.

Badges · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • badge
  • status
  • pill
  • morph
  • state
  • label
  • chip

The actual source

StatusMorphPill.swift
// Status Morph · syxUI · https://syxui.dev/components/status-morph-pill
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

#if canImport(UIKit)
import UIKit
private typealias StatusPillFont = UIFont
#else
import AppKit
private typealias StatusPillFont = NSFont
#endif

/// The draft corner, in points, and deliberately *not* derived from `height`.
///
/// A fixed 5pt reads as "squarish chip" at every badge size, and because the
/// capsule end is `height / 2`, keeping this constant makes the corner travel
/// longer as the badge grows — which is what you want, since a larger badge is
/// where the shape change has room to be seen.
private let statusDraftRadius: CGFloat = 5

/// Where the colour finishes, as a fraction of the spring's travel.
///
/// The fill is a pure function of the one spring, so it cannot drift out of
/// step with the width or the corner — but it is *compressed* to land at 84% of
/// the travel rather than at 100%. That matters because every two-step blend in
/// this palette passes through a colour nobody chose: half amber, half green is
/// olive, and slate to amber is a dead khaki. Riding the raw spring the pill
/// sits in that midpoint for around 95 ms; at 0.84 it is through it in 45.
/// The colour announces the new state, the shape confirms it.
private let statusColourLead = 0.84

/// The word swap, in spring-travel space — and the one place this component is
/// deliberately asymmetric.
///
/// Two words of different lengths crossfading inside a pill that is *itself*
/// resizing has one failure mode, and the filmstrip is where it showed up: the
/// long word wants more room than the pill currently has, and cropping it at the
/// rim reads as a truncated label rather than as motion. So each word is scaled
/// to the room it actually has — see `fit` in the body — which makes cropping
/// impossible at any bound string or text size, and the window is then placed so
/// that scale never has far to travel:
///
/// - **Growing** (`Draft` → `In review`): the incoming word is the long one, so
///   it must not arrive before the room does, or it arrives visibly undersized.
///   Swap over the back half, 0.44…0.94.
/// - **Shrinking** (`In review` → `Live`): the outgoing word is the long one, so
///   it leaves while the room is still there, shrinking as it goes. Swap over
///   the front, 0.02…0.44.
///
/// One rule underneath both: **the word changes where the room changes.** One
/// window used for both directions leaves the long word 24% undersized while it
/// is still fully opaque, measured off the rendered still; direction-aware it is
/// within 3%, and the 13% it reaches at the crossover is masked by being half
/// faded out.
///
/// The two alphas are complements and always sum to 1. Sequencing them
/// out-then-in instead leaves a window with neither word drawn, and a status pill
/// that goes blank for a frame and a half reads as a failed render — the exact
/// defect `build-state-badge` shipped and had to be rebuilt for.
///
/// The counter-roll is 0.52 of the height rather than the third `build-state-badge`
/// settled on, and that came out of rendered frames too. A third is fine beside
/// a mark that is also moving; here the words are the only content, and at a
/// third they sit close enough at the crossover to read as one double-exposed
/// word. Half the height separates them cleanly and still keeps both fully
/// inside the pill, so nothing is clipped and there is never a frame without ink
/// in it.
private let statusWordGrowFrom = 0.44
private let statusWordGrowTo = 0.94
private let statusWordShrinkFrom = 0.02
private let statusWordShrinkTo = 0.44
private let statusWordRoll = 0.52
private let statusWordScale = 0.06

/// How much of one morph `progress` spans, in multiples of `response`.
///
/// Only the preview override uses this; the live badge runs a real spring with
/// no duration at all. 1.6 response-periods is where a 0.86-damped spring has
/// arrived to within 0.03%, so `progress: 1` is the settled state. Note that
/// the spring's own frequency is `2π / response`, so `response` cancels out
/// here: a rendered frame at a given `progress` shows the same point of the
/// morph no matter where the slider is.
private let statusPreviewSpan = 1.6

/// A status pill that morphs between named states — colour, corner radius and
/// width all on one spring.
///
/// The whole badge is a pure function of a single number, `position`, which is
/// a fractional index into the state list: 1.0 is settled on state 1, 1.6 is
/// 60% of the way from state 1 to state 2. Everything visible is read off it —
/// the fill is `palette[from]` blended toward `palette[to]`, the corner runs
/// from a 5pt draft chip to a full capsule as the state gets closer to final,
/// and the width runs between the two measured label widths. One spring moves
/// `position`, so the three cannot disagree; the pill reads as one object
/// changing rather than as three properties animating beside each other.
///
/// That is also why `position` lives on an `Animatable` view rather than on
/// three separate `.animation` calls. `AnimatedSize` and a bare
/// `.animation(_:value:)` on a frame both take a *curve*, and a curve-driven
/// width next to a spring-driven colour is precisely the mismatch that reads as
/// broken. A second tap mid-morph retargets the same spring from its current
/// value and velocity, so no number in here can be caught between two
/// animations.
///
/// The width sits on the badge's own frame, so a timestamp dropped to its right
/// in a table row slides out as `Draft` becomes `In review` and pulls back in
/// when it becomes `Live`. Reflow is the effect, not an afterthought.
struct StatusMorphPill: View {
    /// The states, in order, separated by `·`. A plain `String` because a list
    /// of words is not a bindable type — the site replaces the literal and the
    /// badge splits it.
    var states: String = "Draft · In review · Live"
    /// Which state the badge rests on. Read modulo the state count, so a value
    /// past the end of a shortened list still names a real state.
    var index: Int = 2
    var height: CGFloat = 22
    /// The spring's period. Hoisted to a stored property because written inline
    /// inside `.spring(response:)` there would be nothing for a slider to bind.
    var response: Double = 0.38
    var palette: [Color] = [Color(red: 0.545, green: 0.553, blue: 0.596), Color(red: 0.961, green: 0.647, blue: 0.141), Color(red: 0.188, green: 0.643, blue: 0.424)]
    var text: Color = Color(white: 1.0)

    /// Preview override. When set, the badge renders that point of the single
    /// morph `index - 1` → `index` instead of driving its own spring. 0 is the
    /// previous state settled, 1 is `index` settled.
    var progress: Double? = nil

    /// Not exposed as a control — six is the ceiling and damping is the one a
    /// reader is least likely to want. Kept a plain stored property anyway so
    /// the call site can retune it. 0.86 is a firm settle with roughly half a
    /// percent of overshoot: a status pill that visibly bounces past its width
    /// reads as a layout bug, not as a spring.
    var damping: Double = 0.86

    @Environment(\.self) private var env
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// Net taps since the badge appeared. Kept separate from `index` so the
    /// site's slider and a finger both move the same target.
    @State private var stepped = 0
    @State private var measured: [Int: CGFloat] = [:]

    var body: some View {
        let names = statusNames(states)
        let fontSize = height * 0.58
        let probe = StatusPillFont.systemFont(ofSize: fontSize, weight: .semibold)

        StatusMorphBody(
            position: position(count: names.count),
            names: names,
            widths: names.indices.map { width(of: names[$0], at: $0, probe: probe) },
            tints: names.indices.map { tint($0).resolve(in: env) },
            ink: text.resolve(in: env),
            height: height,
            fontSize: fontSize,
            nudge: (probe.capHeight - probe.ascender - probe.descender) / 2
        )
        .animation(motion, value: index + stepped)
        .background(alignment: .topLeading) { rack(names, fontSize: fontSize) }
        .contentShape(Rectangle())
        .onLongPressGesture(minimumDuration: 0.45) { advance(-1) }
        .onTapGesture { advance(1) }
        .sensoryFeedback(trigger: stepped) { _, _ in
            // The last state in the list is conventionally the terminal one, so
            // landing there is worth more than a click.
            wrap(index + stepped, names.count) == names.count - 1 ? .success : .selection
        }
        .accessibilityElement()
        .accessibilityLabel(Text("Status"))
        .accessibilityValue(Text(names[wrap(progress == nil ? index + stepped : index, names.count)]))
    }

    private var motion: Animation? {
        reduceMotion ? nil : .spring(response: response, dampingFraction: damping)
    }

    /// Where on the state list the badge currently sits.
    ///
    /// Live, that is just the integer target — the `Animatable` body is what
    /// makes the number between two targets exist at all. Pinned, it is the
    /// spring evaluated by hand at a normalised time, which is the only way a
    /// renderer can ask for "35% of the way through" and get the shape the
    /// spring would actually have been in.
    private func position(count: Int) -> Double {
        guard let progress else { return Double(index + stepped) }
        let travel = Spring(response: response, dampingRatio: damping).value(
            fromValue: 0,
            toValue: 1,
            initialVelocity: 0,
            time: min(max(progress, 0), 1) * statusPreviewSpan * response
        )
        return Double(index) - 1 + travel
    }

    /// Tap forward, long-press back. The target moves; the spring works out how
    /// to get there, which is what makes a burst of taps one continuous slide
    /// instead of a queue of morphs.
    private func advance(_ delta: Int) {
        guard progress == nil else { return }
        stepped += delta
    }

    private func tint(_ position: Int) -> Color {
        guard !palette.isEmpty else { return Color(white: 0.5) }
        return palette[position % palette.count]
    }

    /// The exact advance of one label, measured rather than estimated.
    ///
    /// Two measurements, and the order matters. The platform font gives an exact
    /// synchronous answer inside `body`, so frame one is already the right width
    /// — without it a badge appears as a padding-wide stub for a frame, which at
    /// 22pt is a visible pop. The `GeometryReader` in `rack` then overrides it
    /// with what `Text` *actually drew*, which is the number that survives a
    /// larger accessibility text size, a font swapped in from the environment,
    /// or a bound state string in a language whose words are longer. A guessed
    /// glyph advance survives none of those.
    private func width(of name: String, at position: Int, probe: StatusPillFont) -> CGFloat {
        if let value = measured[position], value > 0 { return value }
        return (name as NSString).size(withAttributes: [.font: probe]).width
    }

    /// The measuring rack: every state laid out once, drawn never.
    ///
    /// It lives in a `.background`, so it takes its size from itself rather than
    /// from the badge and cannot feed its own measurement back into the width it
    /// is measuring. `.hidden()` keeps it out of the render and out of the
    /// accessibility tree while still laying out.
    @ViewBuilder
    private func rack(_ names: [String], fontSize: CGFloat) -> some View {
        VStack(spacing: 0) {
            ForEach(names.indices, id: \.self) { position in
                Text(names[position])
                    .font(.system(size: fontSize, weight: .semibold))
                    .lineLimit(1)
                    .fixedSize()
                    .background {
                        GeometryReader { geometry in
                            Color.clear.preference(
                                key: StatusWidthKey.self,
                                value: [position: geometry.size.width]
                            )
                        }
                    }
            }
        }
        .hidden()
        .onPreferenceChange(StatusWidthKey.self) { measured = $0 }
    }
}

// MARK: - One frame

/// Every visible property of the badge at one point of the morph.
///
/// `Animatable` on the *view* rather than on a shape is what makes the single
/// spring work: SwiftUI interpolates `position` and re-evaluates this body per
/// frame, so the re-timed colour curve and the two label alphas are computed
/// fresh at each step instead of being interpolated between their endpoints.
/// Written the obvious way — `.opacity(curve(v))` on a plainly animated view —
/// the shorter windows silently evaporate and everything lands on one timing.
private struct StatusMorphBody: View, Animatable {
    var position: Double
    var names: [String]
    var widths: [CGFloat]
    var tints: [Color.Resolved]
    var ink: Color.Resolved
    var height: CGFloat
    var fontSize: CGFloat
    var nudge: CGFloat

    /// `View` is `@MainActor` and `Animatable` is not, so without the
    /// annotation this is a hard error under Swift 6.
    nonisolated var animatableData: Double {
        get { position }
        set { position = newValue }
    }

    var body: some View {
        let count = max(1, names.count)
        let lower = position.rounded(.down)
        let travel = min(1, max(0, position - lower))
        let from = wrap(Int(lower), count)
        let to = wrap(Int(lower) + 1, count)

        let pad = height * 0.45
        let colour = smooth(min(1, travel / statusColourLead))
        let grows = widths[to] >= widths[from]
        let opens = grows ? statusWordGrowFrom : statusWordShrinkFrom
        let closes = grows ? statusWordGrowTo : statusWordShrinkTo
        let swap = smooth((travel - opens) / (closes - opens))

        // A hump that peaks halfway through the colour's window and is spent
        // when the colour lands. A colour lerp on its own reads as a slow drift;
        // a rim that flares while it happens is what makes it read as an event.
        let heat = min(1, travel / statusColourLead)
        let flare = 4 * heat * (1 - heat)

        let firm = firmness(from, count) + (firmness(to, count) - firmness(from, count)) * travel
        let radius = min(height / 2, statusDraftRadius + (height / 2 - statusDraftRadius) * firm)
        let span = widths[to] - widths[from]
        let width = max(height, widths[from] + span * travel + pad * 2)
        let fill = blend(tints[from], tints[to], colour)
        let shape = RoundedRectangle(cornerRadius: radius, style: .continuous)

        ZStack {
            shape.fill(fill)
            // The rim sits at only 10% toward white at rest, so the pill's edge
            // against a light page is still its own colour and stays crisp; the
            // flare is what the reader is meant to notice, not the rim.
            shape.strokeBorder(
                lift(fill, 0.10 + 0.40 * flare),
                lineWidth: max(1, height * 0.04) * (1 + 0.55 * flare)
            )

            // Each word is scaled to the room the pill currently offers it. The
            // short one is never scaled at all; the long one shrinks out of the
            // way or grows into place, which is the same spring again and the
            // reason no glyph ever meets the rim.
            let room = Double(width - pad * 2)
            word(
                names[from],
                alpha: 1 - swap,
                lift: -height * statusWordRoll * swap,
                fit: min(1, room / Double(max(1, widths[from])))
            )
            word(
                names[to],
                alpha: swap,
                lift: height * statusWordRoll * (1 - swap),
                fit: min(1, room / Double(max(1, widths[to])))
            )
        }
        .frame(width: width, height: height)
        // The same `radius` feeds the fill, the rim and the clip, so the corner
        // cannot round at three slightly different rates — which shows up as a
        // one-pixel halo and reads as a rendering fault, not as a morph.
        .clipShape(shape)
    }

    private func word(_ string: String, alpha: Double, lift: CGFloat, fit: Double) -> some View {
        Text(string)
            .font(.system(size: fontSize, weight: .semibold))
            .foregroundStyle(ink)
            .lineLimit(1)
            .fixedSize()
            // Text sits high in its line box, so a geometrically centred label
            // reads low. Computed from the font rather than guessed.
            .offset(y: nudge + lift)
            .scaleEffect(CGFloat(fit * (1 - statusWordScale * (1 - alpha))))
            .opacity(alpha)
    }

    /// 0 for the first state, 1 for the last. The corner is read straight off
    /// this, so the shape firms up from draft chip to capsule as the state gets
    /// closer to final — and un-firms on the way back round to the first.
    private func firmness(_ position: Int, _ count: Int) -> Double {
        Double(position) / Double(max(1, count - 1))
    }
}

// MARK: - Measurement

private struct StatusWidthKey: PreferenceKey {
    // `let`, not `var`: under Swift 6 a mutable static is shared mutable state
    // and will not compile. A PreferenceKey's default never changes.
    static let defaultValue: [Int: CGFloat] = [:]
    static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) {
        value.merge(nextValue()) { _, new in new }
    }
}

// MARK: - Helpers

/// The bound string, split and tidied. Never empty: a badge with no states is
/// a blank rectangle, which is worse than a placeholder.
private func statusNames(_ raw: String) -> [String] {
    let parts = raw
        .split(separator: "·")
        .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
        .filter { !$0.isEmpty }
    return parts.isEmpty ? ["Status"] : parts
}

/// Modulo that stays positive, so stepping back off the front of the list lands
/// on the last state rather than crashing.
private func wrap(_ value: Int, _ count: Int) -> Int {
    guard count > 0 else { return 0 }
    let remainder = value % count
    return remainder < 0 ? remainder + count : remainder
}

private func smooth(_ t: Double) -> Double {
    let x = min(max(t, 0), 1)
    return x * x * (3 - 2 * x)
}

/// Interpolated on the sRGB components, which is what `Color.lerp` does on the
/// Flutter side — so both files pass through the same midpoint.
private func blend(_ from: Color.Resolved, _ to: Color.Resolved, _ t: Double) -> Color.Resolved {
    let f = Float(min(max(t, 0), 1))
    return Color.Resolved(
        red: from.red + (to.red - from.red) * f,
        green: from.green + (to.green - from.green) * f,
        blue: from.blue + (to.blue - from.blue) * f,
        opacity: from.opacity + (to.opacity - from.opacity) * f
    )
}

private func lift(_ colour: Color.Resolved, _ toward: Double) -> Color.Resolved {
    let t = Float(min(max(toward, 0), 1))
    return Color.Resolved(
        red: colour.red + (1 - colour.red) * t,
        green: colour.green + (1 - colour.green) * t,
        blue: colour.blue + (1 - colour.blue) * t,
        opacity: colour.opacity
    )
}

#Preview {
    // The first is live — tap it to advance, press and hold to step back.
    VStack(alignment: .leading, spacing: 18) {
        StatusMorphPill(height: 50)
        StatusMorphPill(index: 1, height: 50, progress: 0)
        StatusMorphPill(index: 2, height: 50, progress: 0)
        StatusMorphPill(index: 2, height: 50, progress: 0.183)
        StatusMorphPill(index: 2, height: 50, progress: 1)
    }
    .padding(24)
}
iOS 17 · No dependencies

SwiftUI note. The animated width sits on the badge's own frame, so a timestamp dropped to its right in a table row slides on the same spring rather than jumping when the morph ends. Label widths are measured once per state and cached in @State; if you bind a long list, that is the cost that scales, not the per-frame one.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27