Outcome Screen
A success, failure or empty state in one choreography: a ring draws, floods and lands with a haptic, then a glass action rises.
Live preview
// 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
/// Success, failure or empty outcome view with one shared choreography: a ring, a solid block, an ink mark and one
/// liquid glass action.
///
/// The action is a signal-tinted glass capsule. While a retry runs, its label morphs to "Retrying" and it gives up a
/// little of its width to a progress bubble that buds out of its trailing end and rests joined to it by a liquid
/// neck; when the retry returns, the bubble melts back in and the capsule takes its width back.
///
/// - Parameters:
/// - outcome: `.success`, `.failure` or `.empty`. Sets the block, mark, and haptic.
/// - title: Headline, set large and tight.
/// - message: Supporting copy.
/// - primaryTitle: Primary button title when `retry` is not used.
/// - primaryAction: Primary button handler when `retry` is not used.
/// - retry: Optional async handler for failures; the primary button becomes "Try again", and until it returns the label reads "Retrying" with a progress bubble budded out of the button's end.
/// - secondary: Optional quiet second action.
/// - details: Optional technical text behind a "Details" disclosure on failures, on a glass panel.
/// - accent: Overrides the block color behind the mark. Defaults to the outcome's color in `style`.
/// - eyebrow: Optional small uppercase line above the title, such as "Synced 10:42".
/// - style: Block, signal and text colors. `.standard` is sage for success, butter for failure and sky for empty, with the brand red tinting the primary button's glass.
public struct OutcomeScreen: View {
public enum Outcome: Sendable { case success, failure, empty }
/// A secondary button.
public struct Action {
public let title: String
public let handler: () -> Void
public init(_ title: String, handler: @escaping () -> Void) {
self.title = title
self.handler = handler
}
}
/// Colors for the outcome. Defaults follow the SwiftPieces house palette and adapt to light and dark.
public struct Style: Sendable {
/// Block behind the check.
public var success: Color
/// Block behind the cross.
public var failure: Color
/// Block behind the empty tray.
public var empty: Color
/// Mark color on the blocks.
public var ink: Color
/// The glass tint of the primary button: the one action color.
public var signal: Color
/// Primary button text.
public var signalInk: Color
/// Headline, drawing ring, progress bubble and details text color.
public var text: Color
/// Message, eyebrow and secondary action color.
public var muted: Color
/// The glass tint of the details block. `.clear`, the default, leaves it neutral glass.
public var raised: Color
public init(
success: Color = Style.sage,
failure: Color = Style.butter,
empty: Color = Style.sky,
ink: Color = Style.blockInk,
signal: Color = Style.signalRed,
signalInk: Color = Style.blockInk,
text: Color = Style.adaptive(0x141414, 0xF4F3EF),
muted: Color = Style.adaptive(0x5C5A56, 0xA6A49F),
raised: Color = .clear
) {
self.success = success
self.failure = failure
self.empty = empty
self.ink = ink
self.signal = signal
self.signalInk = signalInk
self.text = text
self.muted = muted
self.raised = raised
}
public static let standard = Style()
public static let sage = Color(red: 0xA9 / 255, green: 0xDC / 255, blue: 0xB7 / 255)
/// The brand red, `#FF0000`, kept under its earlier name so existing code still compiles.
public static let tangerine = Color(red: 1, green: 0, blue: 0)
public static let butter = Color(red: 1, green: 0xD9 / 255, blue: 0x76 / 255)
public static let sky = Color(red: 0x9C / 255, green: 0xC2 / 255, blue: 1)
/// The brand red, `#FF0000`: the one action color.
public static let signalRed = Color(red: 1, green: 0, blue: 0)
public static let blockInk = Color(red: 0x14 / 255, green: 0x14 / 255, blue: 0x14 / 255)
/// A color that resolves to `light` or `dark` hex by the current appearance.
public static func adaptive(_ light: UInt32, _ dark: UInt32) -> Color {
Color(uiColor: UIColor { @Sendable traits in traits.userInterfaceStyle == .dark ? rgb(dark) : rgb(light) })
}
nonisolated private static func rgb(_ hex: UInt32) -> UIColor {
UIColor(red: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: 1)
}
}
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ScaledMetric(relativeTo: .largeTitle) private var markSize: CGFloat = 112
/// The outcome the block shows. It changes inside the choreography, so a new outcome recolors on its beat.
@State private var shown: Outcome?
@State private var ringTrim: CGFloat = 0
/// True once color has pooled into the drawn ring, which then wears the block's color.
@State private var pooled = false
/// How far the block has pooled inward from the ring: 0 is the ring alone, 1 a solid disc.
@State private var flood: CGFloat = 0
/// Each mark draws on its own, so a new outcome lifts the old mark while the new one is pressed in.
@State private var drawn: [Outcome: CGFloat] = [:]
/// Bumped as each outcome lands. The haptic, the thud and the ripple key off it.
@State private var landing = Landing()
@State private var copyVisible = false
@State private var actionsVisible = false
@State private var isRetrying = false
@State private var showsDetails = false
/// The progress bubble: home inside the primary button's trailing end, out beside it while a retry runs.
@State private var buds = PieceBuds()
@ScaledMetric(relativeTo: .headline) private var buttonHeight: CGFloat = 56
private let outcome: Outcome
private let title: String
private let message: String?
private let primaryTitle: String
private let primaryAction: () -> Void
private let retry: (() async -> Void)?
private let secondary: Action?
private let details: String?
private let accentOverride: Color?
private let eyebrow: String?
private let style: Style
public init(
outcome: Outcome,
title: String,
message: String? = nil,
primaryTitle: String = "Continue",
primaryAction: @escaping () -> Void = {},
retry: (() async -> Void)? = nil,
secondary: Action? = nil,
details: String? = nil,
accent: Color? = nil,
eyebrow: String? = nil,
style: Style = .standard
) {
self.outcome = outcome
self.title = title
self.message = message
self.primaryTitle = primaryTitle
self.primaryAction = primaryAction
self.retry = retry
self.secondary = secondary
self.details = details
self.accentOverride = accent
self.eyebrow = eyebrow
self.style = style
}
private func block(for outcome: Outcome) -> Color {
if let accentOverride { return accentOverride }
switch outcome {
case .success: return style.success
case .failure: return style.failure
case .empty: return style.empty
}
}
private var usesRetry: Bool { outcome == .failure && retry != nil }
public var body: some View {
let motion = PieceMotion(reduceMotion: reduceMotion)
// Under Reduce Motion the copy and actions fade in where they sit instead of rising.
let rise: CGFloat = reduceMotion ? 0 : 1
VStack(spacing: 0) {
mark
.padding(.bottom, 28)
.accessibilityHidden(true)
VStack(spacing: 10) {
if let eyebrow {
Text(eyebrow.uppercased())
.font(.caption.weight(.semibold))
.tracking(1)
.foregroundStyle(style.muted)
}
Text(title)
.font(.system(.largeTitle, weight: .semibold))
.tracking(-1.2)
.foregroundStyle(style.text)
.accessibilityAddTraits(.isHeader)
if let message {
Text(message)
.font(.body)
.foregroundStyle(style.muted)
}
}
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.opacity(copyVisible ? 1 : 0)
.offset(y: copyVisible ? 0 : 12 * rise)
// Every glass shape of the actions in one group: the button and its progress bubble melt into each other.
PieceLiquidGroup {
VStack(spacing: 6) {
primaryRow
if let secondary {
Button(secondary.title, action: secondary.handler)
.font(.body.weight(.semibold))
.foregroundStyle(style.muted)
.frame(maxWidth: .infinity, minHeight: 48)
.contentShape(.rect)
.disabled(isRetrying)
}
if outcome == .failure, let details {
detailsBlock(details)
// A separate control: with the stack's 6pt it rests apart from the button, so the two
// never share a neck.
.padding(.top, secondary == nil ? PieceLiquid.apart - 6 : 2)
}
}
}
.padding(.top, 32)
.opacity(actionsVisible ? 1 : 0)
.offset(y: actionsVisible ? 0 : 10 * rise)
}
.padding(28)
.frame(maxWidth: .infinity)
.fontWeight(.semibold)
// A new outcome passed in place swaps the copy and actions and reflows them together, without a jump.
.animation(motion.morph, value: outcome)
.onChange(of: isRetrying) { _, retrying in
Task {
if retrying {
await buds.bloom(["progress"], reduceMotion: reduceMotion)
} else {
await buds.gather(["progress"], reduceMotion: reduceMotion)
}
}
}
.sensoryFeedback(trigger: landing) { _, new in
switch new.outcome {
case .success: .success
case .failure: .error
case .empty: .impact(weight: .light)
case nil: nil
}
}
.task(id: outcome) { await play(outcome) }
}
/// The stamp: one ring that is drawn in ink and pools into the block, the ink marks, and the ripple behind them.
private var mark: some View {
let line = max(3, markSize * 0.03)
let tone = block(for: shown ?? outcome)
// Copied out of the environment for the ripple's animator, which re-runs its closure every frame.
let ripples = !reduceMotion
return ZStack {
// One ripple as each outcome lands. Under Reduce Motion it never shows.
Circle()
.stroke(tone, lineWidth: 2)
.keyframeAnimator(initialValue: CGFloat(1), trigger: landing.count) { ring, spread in
ring.scaleEffect(1 + 0.45 * spread).opacity(ripples ? 1 - spread : 0)
} keyframes: { _ in
KeyframeTrack {
MoveKeyframe(0)
SpringKeyframe(1, duration: 0.7, spring: PieceMotion.calm)
}
}
ZStack {
// Color pools inward from the ring as the ring takes the block's color, so the ring becomes the block.
Flood(level: flood)
.fill(tone, style: FillStyle(eoFill: true))
Circle()
.trim(from: 0, to: ringTrim)
.stroke(pooled ? tone : style.text, style: StrokeStyle(lineWidth: line, lineCap: .round))
.rotationEffect(.degrees(-90))
.padding(line / 2)
ForEach(Self.kinds, id: \.self) { kind in
Glyph(outcome: kind, progress: drawn[kind] ?? 0, lineWidth: markSize * 0.075)
.fill(style.ink)
.padding(markSize * (kind == .failure ? 0.34 : 0.3))
}
}
// The thud: the block is pressed in as the outcome lands, and the ripple spreads from under it.
.piecePop(trigger: landing.count, amount: 0.06)
}
.frame(width: markSize, height: markSize)
// Hidden until a run starts, so Reduce Motion can fade the whole stamp in.
.opacity(shown == nil ? 0 : 1)
}
/// The primary action and, while a retry runs, its progress bubble. The button gives up the bubble's width plus a
/// neck while the bubble is out, on the same spring, so the two rest joined; the bubble sits under the button, so at
/// home it is inside the button's end.
private var primaryRow: some View {
let out = buds.isOut("progress")
return ZStack(alignment: .trailing) {
if buds.contains("progress") { progressBubble(out: out) }
primaryButton
.padding(.trailing, out ? buttonHeight + PieceLiquid.joined : 0)
}
}
/// The one signal action on tinted glass. Its label morphs letter by letter: "Try again" to "Retrying" and back, or
/// to the new title when a retry lands as a success.
private var primaryButton: some View {
let motion = PieceMotion(reduceMotion: reduceMotion)
let label = isRetrying ? "Retrying" : (usesRetry ? "Try again" : primaryTitle)
return Button {
if usesRetry { Task { await performRetry() } } else { primaryAction() }
} label: {
PieceMorphText(text: label, font: .headline.weight(.semibold))
.foregroundStyle(style.signalInk)
.animation(motion.morph, value: label)
.padding(.horizontal, 20)
.frame(maxWidth: .infinity, minHeight: buttonHeight)
.pieceLiquid(.capsule, tint: style.signal, interactive: false)
.contentShape(.capsule)
}
// The liquid press, so the label sinks with its glass.
.buttonStyle(PieceLiquidPressStyle())
.disabled(isRetrying)
.accessibilityLabel(label)
}
/// A round glass bubble with a spinner, out beside the button's trailing end while a retry runs. Home is just
/// inside that end, shrunk, where the two are one shape. The button already says "Retrying", so it stays silent.
private func progressBubble(out: Bool) -> some View {
let home = buttonHeight / 2 + PieceLiquid.joined + buttonHeight * PieceLiquid.homeScale / 2
return ProgressView()
.tint(style.text)
.pieceBudContent(out: out)
.frame(width: buttonHeight, height: buttonHeight)
.pieceLiquid(.circle, interactive: false)
.pieceBud(out: out, home: CGSize(width: -home, height: 0))
.accessibilityHidden(true)
}
private func detailsBlock(_ details: String) -> some View {
let motion = PieceMotion(reduceMotion: reduceMotion)
let panel = RoundedRectangle(cornerRadius: 20, style: .continuous)
return VStack(alignment: .leading, spacing: 0) {
Button {
withAnimation(showsDetails ? motion.dismiss : motion.reveal) { showsDetails.toggle() }
} label: {
HStack {
Text("Details").font(.subheadline.weight(.semibold))
Spacer()
Image(systemName: "chevron.down")
.font(.caption.weight(.semibold))
.rotationEffect(.degrees(showsDetails ? 180 : 0))
}
.foregroundStyle(style.text)
.frame(minHeight: 44)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityValue(showsDetails ? "Expanded" : "Collapsed")
if showsDetails {
Text(details)
.font(.footnote.monospaced())
.foregroundStyle(style.muted)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 14)
.transition(motion.transition(.opacity.combined(with: .move(edge: .top))))
}
}
.padding(.horizontal, 16)
// The text slides in under the header, so it is clipped to the panel before the glass is drawn around it.
.clipShape(panel)
.pieceLiquid(panel, tint: style.raised == .clear ? nil : style.raised, interactive: false)
}
/// The stamp, in beats. The ring is drawn in ink, color pools inward from it once it closes, the mark is pressed
/// in, and the outcome lands: its haptic, the thud and one ripple together, then the copy rises and the actions
/// a beat behind. A new outcome restamps in place: the old mark lifts as the block recolors, then the new mark
/// lands. A run cut short by a newer outcome or by the view leaving stops where it is; the next carries on from there.
private func play(_ outcome: Outcome) async {
// Already landed and showing, as when the view reappears: nothing replays and nothing is felt twice.
guard landing.outcome != outcome || shown != outcome else { return }
let motion = PieceMotion(reduceMotion: reduceMotion)
// Any other mark lifts first, quickly.
withAnimation(motion.dismiss) {
for kind in Self.kinds where kind != outcome { drawn[kind] = 0 }
}
if pooled {
// Restamp: the block keeps its place and recolors, finishing a stamp that was cut short.
withAnimation(motion.morph) {
shown = outcome
ringTrim = 1
flood = 1
}
} else if reduceMotion {
// Reduce Motion: the whole stamp fades in, then lands with its haptic. Nothing draws, pools or moves.
var instant = Transaction()
instant.disablesAnimations = true
withTransaction(instant) {
ringTrim = 1
pooled = true
flood = 1
drawn[outcome] = 1
}
withAnimation(motion.reveal) { shown = outcome }
guard await beat(0.2) else { return }
land(outcome)
return
} else {
shown = outcome
withAnimation(motion.reveal) { ringTrim = 1 }
// Color pools only once the ring has closed.
guard await beat(0.42) else { return }
withAnimation(motion.morph) {
pooled = true
flood = 1
}
}
// A beat behind the block, the mark is pressed in; the outcome lands as it finishes.
guard await beat(0.16) else { return }
withAnimation(motion.morph) { drawn[outcome] = 1 }
guard await beat(0.24) else { return }
land(outcome)
}
/// The outcome lands: its haptic, the thud and the ripple on one beat, and the copy and actions arrive.
private func land(_ outcome: Outcome) {
let motion = PieceMotion(reduceMotion: reduceMotion)
landing = Landing(count: landing.count + 1, outcome: outcome)
withAnimation(motion.reveal) { copyVisible = true }
// The copy is read, so it rises on the calm reveal; the actions follow a beat behind with a follower's give.
withAnimation(motion.cascade(motion.follow(rank: 1), index: 1, step: 0.12)) { actionsVisible = true }
}
/// Waits `seconds`; false when the run was cancelled meanwhile, so it stops instead of rushing the rest.
private func beat(_ seconds: Double) async -> Bool {
try? await Task.sleep(for: .seconds(seconds))
return !Task.isCancelled
}
private func performRetry() async {
guard let retry, !isRetrying else { return }
isRetrying = true
await retry()
isRetrying = false
}
private static let kinds: [Outcome] = [.success, .failure, .empty]
/// A landed outcome. Counted, so the same outcome landing twice is felt twice.
private struct Landing: Equatable {
var count = 0
var outcome: Outcome?
}
/// The block, pooled inward from the ring: 0 is nothing, 1 a solid disc. Fill it even-odd.
private struct Flood: Shape {
var level: CGFloat
var animatableData: CGFloat {
get { level }
set { level = newValue }
}
func path(in rect: CGRect) -> Path {
var path = Path()
guard level > 0 else { return path }
path.addEllipse(in: rect)
// The center closes by 90%, so the spring's long tail never leaves a speck under the mark as it draws.
let depth = min(level / 0.9, 1) * min(rect.width, rect.height) / 2
let hole = rect.insetBy(dx: depth, dy: depth)
if !hole.isEmpty { path.addEllipse(in: hole) }
return path
}
}
/// Check, cross, or an open tray, each one path stroked to `progress` so it draws in order. The line thins to
/// nothing over the first few percent, like a pen landing and lifting, so a lifted mark never leaves its round
/// cap behind while the spring settles.
private struct Glyph: Shape {
var outcome: Outcome
var progress: CGFloat
var lineWidth: CGFloat
var animatableData: CGFloat {
get { progress }
set { progress = newValue }
}
func path(in rect: CGRect) -> Path {
let width = lineWidth * min(progress / 0.06, 1)
guard width > 0 else { return Path() }
return outline(in: rect)
.trimmedPath(from: 0, to: progress)
.strokedPath(StrokeStyle(lineWidth: width, lineCap: .round, lineJoin: .round))
}
private func outline(in rect: CGRect) -> Path {
var path = Path()
let w = rect.width, h = rect.height, x = rect.minX, y = rect.minY
switch outcome {
case .failure:
path.move(to: CGPoint(x: x, y: y))
path.addLine(to: CGPoint(x: x + w, y: y + h))
path.move(to: CGPoint(x: x + w, y: y))
path.addLine(to: CGPoint(x: x, y: y + h))
case .success:
path.move(to: CGPoint(x: x, y: y + h * 0.54))
path.addLine(to: CGPoint(x: x + w * 0.36, y: y + h * 0.88))
path.addLine(to: CGPoint(x: x + w, y: y + h * 0.14))
case .empty:
path.move(to: CGPoint(x: x, y: y + h * 0.56))
path.addLine(to: CGPoint(x: x + w * 0.3, y: y + h * 0.56))
path.addLine(to: CGPoint(x: x + w * 0.38, y: y + h * 0.74))
path.addLine(to: CGPoint(x: x + w * 0.62, y: y + h * 0.74))
path.addLine(to: CGPoint(x: x + w * 0.7, y: y + h * 0.56))
path.addLine(to: CGPoint(x: x + w, y: y + h * 0.56))
path.addLine(to: CGPoint(x: x + w, y: y + h))
path.addLine(to: CGPoint(x: x, y: y + h))
path.closeSubpath()
path.move(to: CGPoint(x: x + w * 0.18, y: y + h * 0.3))
path.addLine(to: CGPoint(x: x + w * 0.82, y: y + h * 0.3))
path.move(to: CGPoint(x: x + w * 0.3, y: y + h * 0.06))
path.addLine(to: CGPoint(x: x + w * 0.7, y: y + h * 0.06))
}
return path
}
}
}
// MARK: - Example
/// A sync that fails, then lands as a success in place once "Try again" works. Tap the background to replay it.
private struct OutcomeScreenExample: View {
@State private var synced = false
@State private var run = 0
var body: some View {
OutcomeScreen(
outcome: synced ? .success : .failure,
title: synced ? "Backup complete" : "Couldn't sync",
message: synced ? "2,418 photos and 36 videos are safe in your library." : "Check your connection and try again.",
primaryTitle: "Done",
retry: { [run] in await sync(run) },
secondary: synced ? nil : .init("Not now") {},
details: "URLSessionTask failed: The Internet connection appears to be offline. (NSURLErrorDomain -1009)",
eyebrow: synced ? "Synced 10:42" : nil
)
.id(run)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(OutcomeScreen.Style.adaptive(0xF3F2EE, 0x121212))
.onTapGesture {
synced = false
run += 1
}
}
/// Lands only for the run that started it: a replay mid-sync leaves the old retry running, and it must not
/// turn the new run into a success.
private func sync(_ attempt: Int) async {
try? await Task.sleep(for: .seconds(1.2))
if attempt == run { synced = true }
}
}
#Preview("Light") {
OutcomeScreenExample()
}
#Preview("Dark") {
OutcomeScreenExample().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, pressMath, pop)
/// 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 {
/// 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)
}
}
/// Anticipation, overshoot, settle: dips, swells past full size and lands each time `trigger` changes. Under
/// Reduce Motion it stays still (same view, no identity change) and the color or symbol carries the meaning.
private struct PiecePop: ViewModifier {
let trigger: AnyHashable
var amount: CGFloat = 0.08
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func body(content: Content) -> some View {
let amount = reduceMotion ? 0 : amount
content.keyframeAnimator(initialValue: CGFloat(1), trigger: trigger) { view, scale in
view.scaleEffect(scale)
} keyframes: { _ in
KeyframeTrack {
CubicKeyframe(1 - amount * 0.4, duration: 0.08)
SpringKeyframe(1 + amount, duration: 0.14, spring: Spring(duration: 0.18, bounce: 0))
SpringKeyframe(1, duration: 0.42, spring: PieceMotion.expressive)
}
}
}
}
private extension View {
/// Pops each time `trigger` changes. Use a counter, never a Bool that can flip back before it fires.
func piecePop(trigger: some Hashable & Sendable, amount: CGFloat = 0.08) -> some View {
modifier(PiecePop(trigger: AnyHashable(trigger), amount: amount))
}
}
// 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.8.0 (liquid, liquidPress, bud, 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))
}
}
/// The bud: how a bubble leaves and rejoins its parent, driven explicitly so every bubble shows the whole cycle.
///
/// A bubble is born at `home`, inside its parent, where the two glass shapes are one. It springs out to `rest`, and
/// while it is inside the merge distance a neck holds it to the parent, thinning as it goes, until it snaps free.
/// Going home it springs back on a spring with no bounce, the neck reaches out and re-forms, and only once it has
/// melted all the way in is it removed. Both offsets are relative to where the bubble is laid out. Under Reduce
/// Motion it stays at `rest`: its content fades and its glass closes in place.
private struct PieceBud: ViewModifier {
var out: Bool
var rest: CGSize
var home: CGSize
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func body(content: Content) -> some View {
if reduceMotion {
// No travel. Inside a group glass ignores opacity, so the glass closes to nothing in place on the short
// Reduce Motion ease while its content fades; the opacity covers a bubble outside a group.
content
.pieceLiquidScale(out ? 1 : 0.001)
.opacity(out ? 1 : 0)
.offset(rest)
} else {
// The glass shrinks through `pieceLiquidScale`, never a plain scaleEffect (see there), then moves.
content
.pieceLiquidScale(out ? 1 : PieceLiquid.homeScale)
.offset(out ? rest : home)
}
}
}
/// A bubble's own content, on its own clock: gone the moment the bubble heads home, so it never rides over the
/// parent's content, and arriving just after the bubble leaves.
private struct PieceBudContent: ViewModifier {
var out: Bool
func body(content: Content) -> some View {
content
.blur(radius: out ? 0 : 6)
.opacity(out ? 1 : 0)
.animation(out ? .easeOut(duration: 0.3).delay(0.1) : .easeOut(duration: 0.14), value: out)
}
}
private extension View {
/// Places a bubble at `rest` while `out`, and at `home` (inside its parent, shrunk) while not.
func pieceBud(out: Bool, rest: CGSize = .zero, home: CGSize) -> some View {
modifier(PieceBud(out: out, rest: rest, home: home))
}
/// Hides a bubble's icon or label while it is home. Put it on the content, inside the glass.
func pieceBudContent(out: Bool) -> some View {
modifier(PieceBudContent(out: out))
}
}
/// Which bubbles exist and which are out. A bubble is added home with no animation, sent out on the next frame,
/// and called home before it is removed, so it always melts in rather than fading. Keep one in `@State`.
@MainActor @Observable
private final class PieceBuds {
private(set) var present: [String] = []
private(set) var out: Set<String> = []
/// The latest call for each bubble. A bloom or gather that has been overtaken (a bubble sent home while it was
/// still waiting to go out, or called out again while melting) leaves that bubble alone.
@ObservationIgnored private var turn: [String: Int] = [:]
func contains(_ id: String) -> Bool { present.contains(id) }
func isOut(_ id: String) -> Bool { out.contains(id) }
private func claim(_ ids: [String]) -> [String: Int] {
var mine: [String: Int] = [:]
for id in ids {
let next = (turn[id] ?? 0) + 1
turn[id] = next
mine[id] = next
}
return mine
}
/// Puts bubbles straight out at rest with no motion: a view's first frame, or a state restored.
func place(_ ids: [String]) {
_ = claim(ids)
var quiet = Transaction()
quiet.disablesAnimations = true
withTransaction(quiet) {
for id in ids where !present.contains(id) { present.append(id) }
out.formUnion(ids)
}
}
/// Adds bubbles home, then sends each out, `stagger` seconds apart, after an optional `delay`.
func bloom(_ ids: [String], reduceMotion: Bool, stagger: Double = 0.05, delay: Double = 0) async {
let mine = claim(ids)
var quiet = Transaction()
quiet.disablesAnimations = true
withTransaction(quiet) {
for id in ids where !present.contains(id) { present.append(id) }
}
try? await Task.sleep(for: .milliseconds(24 + Int(max(delay, 0) * 1000)))
let split = PieceLiquid.split(reduceMotion: reduceMotion)
for (i, id) in ids.enumerated() where turn[id] == mine[id] {
withAnimation(split.delay(reduceMotion ? 0 : Double(i) * stagger)) { _ = out.insert(id) }
}
}
/// Calls bubbles home, last first, then removes them once they have melted in.
func gather(_ ids: [String], reduceMotion: Bool, stagger: Double = 0.04) async {
let mine = claim(ids)
let home = PieceLiquid.home(reduceMotion: reduceMotion)
for (i, id) in ids.reversed().enumerated() {
withAnimation(home.delay(reduceMotion ? 0 : Double(i) * stagger)) { _ = out.remove(id) }
}
try? await Task.sleep(for: .milliseconds(Int((0.52 + Double(ids.count) * stagger) * 1000)))
var quiet = Transaction()
quiet.disablesAnimations = true
withTransaction(quiet) { present.removeAll { ids.contains($0) && !out.contains($0) && turn[$0] == mine[$0] } }
}
}
/// 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 {
// The morphing label at its full size when it fits. When it doesn't (a long label at an accessibility text
// size, a narrow screen), the same words wrap onto a second line and shrink a little instead of running past
// the screen's edge; that one changes as a whole rather than letter by letter. A single word never splits
// across lines: it stays on one and shrinks further.
let oneWord = !text.contains(where: \.isWhitespace)
ViewThatFits(in: .horizontal) {
morphing.fixedSize()
Text(text)
.lineLimit(oneWord ? 1 : 2)
.multilineTextAlignment(.center)
.minimumScaleFactor(oneWord ? 0.5 : 0.75)
.fixedSize(horizontal: false, vertical: true)
.id(text)
.transition(transition(0, scale: 0.9))
}
.font(font)
.accessibilityElement(children: .ignore)
.accessibilityLabel(text)
}
@ViewBuilder private var morphing: some View {
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))
}
}
}
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- Type
- OutcomeScreen
- Files
- OutcomeScreen.swift
- Depends on
- Nothing (Apple frameworks only)
- Version
- 2.2.0
Success, failure or empty outcome view with one shared choreography: a ring, a solid block, an ink mark and one liquid glass action. The action is a signal-tinted glass capsule. While a retry runs, its label morphs to "Retrying" and it gives up a little of its width to a progress bubble that buds out of its trailing end and rests joined to it by a liquid neck; when the retry returns, the bubble melts back in and the capsule takes its width back.
Notes
- Three outcomes share one choreography, a stamp landing: a ring is drawn in ink, color pools inward from it once it closes and the ring takes the block's color (sage for
.success, butter for.failure, sky for.empty), then the mark is pressed in. The outcome lands on one beat: its haptic, a small thud of the block and one ripple. The headline and message rise together, and the actions follow a beat behind, landing with a little give. Change the view's.idto replay it from the start. - Passing a new
outcometo the same view restamps in place instead of swapping: the old mark lifts as the block recolors, the new mark is pressed in, and the new outcome lands with its own haptic. The copy and actions crossfade and reflow together, and the action's label morphs letter by letter to its new title. A view that reappears keeps its landed outcome and plays nothing again. .emptydraws an open tray and plays a light impact, for first-run and no-results states.- The headline is set large and tight at
.largeTitle;eyebrowadds a small uppercase line above it. Every string is semibold, so size and color carry the hierarchy. - The primary action is a liquid glass capsule tinted with
signal(the brand red,#FF0000, by default) under dark ink. It sinks a few points while pressed and springs back with a little give.secondaryis a quiet text button. - On failure,
retryturns the primary action into "Try again". Tapping it morphs the label to "Retrying" while a round glass bubble with a spinner buds out of the button's trailing end: born inside the end, it springs out on a liquid neck as the button gives up its width, and rests joined to it. When the work returns, the bubble melts back in on a spring with no bounce and the button takes its width back. detailsadds a glass panel, apart from the button, that opens to selectable monospaced text and closes a little quicker than it opens.Styleholds the three blocks, the ink, the signal tint and its ink, the text colors and the details panel's glass tint (raised, clear by default), with house palette defaults that adapt to light and dark.accentstill overrides the block.- Before iOS 26 the button, bubble and panel draw on a frosted material with a light rim, and the bubble sits beside the button without a neck. Under Reduce Transparency they are solid.
- Under Reduce Motion the stamp fades in whole instead of being drawn, then lands with its haptic, and the copy and actions fade in a beat later without rising. A restamp recolors the block and swaps the mark briefly, with no thud or ripple. The label crossfades, the progress bubble fades in and out beside the button, the pressed button shifts a shade instead of sinking (darker in light mode, lighter in dark), and the details fade open.
- For a complete purchase screen, see the Pro Purchase screen.
Usage
OutcomeScreenExample()Parameters
| Parameter | Description |
|---|---|
outcome | .success, .failure or .empty. Sets the block, mark, and haptic. |
title | Headline, set large and tight. |
message | Supporting copy. |
primaryTitle | Primary button title when retry is not used. |
primaryAction | Primary button handler when retry is not used. |
retry | Optional async handler for failures; the primary button becomes "Try again", and until it returns the label reads "Retrying" with a progress bubble budded out of the button's end. |
secondary | Optional quiet second action. |
details | Optional technical text behind a "Details" disclosure on failures, on a glass panel. |
accent | Overrides the block color behind the mark. Defaults to the outcome's color in style. |
eyebrow | Optional small uppercase line above the title, such as "Synced 10:42". |
style | Block, signal and text colors. .standard is sage for success, butter for failure and sky for empty, with the brand red tinting the primary button's glass. |
Install
Pick one. Run it from the folder that contains your .xcodeproj and the files land inside your app.
Terminal
Or ask your coding agent · Claude Code, Cursor or Xcode, once the MCP server is connected
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 →
More SwiftUI feedback
All feedback →Build it yourself: SwiftUI loading states guide · SwiftUI haptics guide