Odometer
A number whose digits roll on their own slots like a mechanical counter, with a glass delta chip that buds out after each change.
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
/// Slot-machine number with a single spring-driven `Animatable` value behind every digit.
///
/// The digits are content, set semibold at whatever size you give them. After a change the delta is a tinted glass
/// chip that buds out from behind the figure's trailing edge, rests a neck's width beside it, and melts back behind
/// it when it goes.
///
/// - Parameters:
/// - value: The target value. Changing it rolls the digits from the current value.
/// - format: A decimal `FormatStyle` from `Double` to `String`, for example `.number.precision(.fractionLength(0))` or `.currency(code: "USD")`. Defaults to whole numbers.
/// - isLoading: Shows a dash in every digit slot instead of a value; the first real value rolls up from zero.
/// - showsDelta: Shows a signed delta chip of tinted glass beside the number for a moment after each change.
/// - ticksOnSettle: Plays a `.selection` haptic when a roll finishes.
/// - style: Delta chip tints, decimal dimming, and roll tinting. Defaults to `.standard`: sage for up, red for down, dimmed decimals, no tint while rolling.
public struct Odometer<Format: FormatStyle>: View where Format.FormatInput == Double, Format.FormatOutput == String {
/// Delta and decimal styling. See `OdometerStyle`.
public typealias Style = OdometerStyle
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var animated: Double = 0
@State private var template: [Glyph] = []
@State private var fractionDigits = 0
@State private var slotSize = CGSize(width: 12, height: 20)
@State private var rollingTint: Color?
@State private var delta: Double = 0
@State private var deltaShown = false
@State private var deltaTask: Task<Void, Never>?
/// The delta chip: out beside the figure while it shows, melted back behind it otherwise.
@State private var buds = PieceBuds()
@State private var chipWidth: CGFloat = 0
@State private var settles = 0
/// True while the dashes show, from the moment loading starts.
@State private var wasLoading = true
/// Counts rolls, so only the latest one's landing clears the tint, narrows the layout and ticks.
@State private var rollTick = 0
/// The largest magnitude the tape has headed for since it last stopped. The layout keeps its slots.
@State private var span: Double = 0
private let value: Double
private let format: Format
private let isLoading: Bool
private let showsDelta: Bool
private let ticksOnSettle: Bool
private let style: Style
public init(value: Double, format: Format, isLoading: Bool = false, showsDelta: Bool = true, ticksOnSettle: Bool = false, style: Style = .standard) {
self.value = value
self.format = format
self.isLoading = isLoading
self.showsDelta = showsDelta
self.ticksOnSettle = ticksOnSettle
self.style = style
}
public init(value: Double, isLoading: Bool = false, showsDelta: Bool = true, ticksOnSettle: Bool = false, style: Style = .standard) where Format == FloatingPointFormatStyle<Double> {
self.init(value: value, format: .number.precision(.fractionLength(0)), isLoading: isLoading, showsDelta: showsDelta, ticksOnSettle: ticksOnSettle, style: style)
}
public var body: some View {
let motion = PieceMotion(reduceMotion: reduceMotion)
let gap = PieceLiquid.joined
HStack(alignment: .center, spacing: 0) {
Tape(scaled: animated, template: template, slot: slotSize, loading: isLoading, tint: rollingTint, dim: style.dimsFraction ? style.muted : nil, reduceMotion: reduceMotion)
// The number never gives up width to the chip; separators and symbols would truncate first.
.layoutPriority(1)
// Behind the figure, so the chip comes out from behind its last digit and melts back there.
.background(alignment: .trailing) {
// Its leading edge rests a neck's width past the figure. The guide sits outside the condition: on a
// view inside an `if` it would be ignored.
ZStack {
if buds.contains("delta") { PieceLiquidGroup { deltaChip } }
}
.alignmentGuide(.trailing) { d in d[.leading] - gap }
}
// The room the chip rests in. The roll leads and the number makes way a beat later on the snap, the tier for
// something its size, while the chip buds out with its own give; it closes up quickly, on the dismiss, as
// the chip melts home.
Color.clear
.frame(height: 1)
.animation(chipShown ? motion.cascade(motion.snap, index: 3) : motion.dismiss) { room in
room.frame(width: chipShown ? gap + chipWidth : 0)
}
}
.monospacedDigit()
.fontWeight(.semibold)
.background {
// One measurement for every slot: digits are monospaced.
Text("0").hidden().fixedSize()
.background { GeometryReader { proxy in Color.clear.onAppear { slotSize = proxy.size }.onChange(of: proxy.size) { _, new in slotSize = new } } }
}
.sensoryFeedback(.selection, trigger: settles) { _, _ in ticksOnSettle }
.accessibilityElement(children: .ignore)
.accessibilityLabel(isLoading ? "Loading" : format.format(value))
.onAppear { retarget(from: nil) }
.onChange(of: value) { old, _ in retarget(from: old) }
// A roll still turning when the dashes come up lands silently behind them.
.onChange(of: isLoading) { _, loading in if loading { wasLoading = true } else { retarget(from: nil) } }
.onChange(of: chipShown) { _, shown in
Task {
if shown { await buds.bloom(["delta"], reduceMotion: reduceMotion) } else { await buds.gather(["delta"], reduceMotion: reduceMotion) }
}
}
}
/// Whether the delta chip is showing: for a moment after each change, never over the loading dashes.
private var chipShown: Bool { showsDelta && deltaShown && !isLoading }
/// The signed change on tinted glass: sage for up, red for down, with ink. It is born behind the figure's last
/// digit, shrunk, with its tint and amount hidden; it springs out with visible give and its amount arrives just
/// after. Going away the amount blurs off at once, the tint drains, and it slides home behind the digits on a
/// spring with no bounce, fading as it gets there, since the figure has no glass for it to melt into. A change
/// while it is out rolls its amount without overshoot, while the glass resizes and recolors in place with a
/// little give.
private var deltaChip: some View {
let up = delta >= 0
let out = buds.isOut("delta")
let motion = PieceMotion(reduceMotion: reduceMotion)
let home = -(PieceLiquid.joined + chipWidth * (1 + PieceLiquid.homeScale) / 2)
return HStack(spacing: 3) {
Image(systemName: up ? "arrow.up.right" : "arrow.down.right")
.font(.system(size: 11, weight: .semibold))
.id(up)
.transition(motion.swap)
Text((up ? "+" : "−") + format.format(abs(delta)))
.contentTransition(reduceMotion ? .opacity : .numericText(value: delta))
// The amount is a figure, so it lands without overshoot.
.animation(motion.value, value: delta)
}
.font(.system(size: 14, weight: .semibold))
.monospacedDigit()
.foregroundStyle(style.ink)
.fixedSize()
.pieceBudContent(out: out)
.padding(.horizontal, 10)
.frame(minHeight: 28)
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { chipWidth = $0 }
.pieceLiquid(Capsule(), tint: out ? (up ? style.up : style.down) : nil, interactive: false)
.animation(motion.snap, value: delta)
.pieceBud(out: out, home: CGSize(width: home, height: 0))
// With no glass to melt into, it fades as it slips behind the digits, gone by the time it is home.
.animation(out ? .easeOut(duration: 0.15) : .easeIn(duration: 0.22).delay(0.12)) { $0.opacity(out ? 1 : 0) }
}
// MARK: Rolling
private func retarget(from old: Double?) {
guard !isLoading else {
let layout = Self.layout(for: abs(value), format: format)
template = layout.glyphs
fractionDigits = layout.fractionDigits
wasLoading = true
return
}
let motion = PieceMotion(reduceMotion: reduceMotion)
let start = wasLoading ? 0 : (old ?? value)
wasLoading = false
// Lay out for the largest magnitude since the tape last stopped, so a shrinking number, or one retargeted
// mid-roll, keeps its high slots until it settles and never shows a digit cut off.
span = max(span, abs(start), abs(value))
let layout = Self.layout(for: span, format: format)
fractionDigits = layout.fractionDigits
let target = (value * pow(10, Double(layout.fractionDigits))).rounded()
let change = target - animated
withAnimation(motion.value) { template = layout.glyphs }
guard change != 0 else { return }
if let old, old != value {
delta = value - old
deltaShown = true
deltaTask?.cancel()
deltaTask = Task {
try? await Task.sleep(for: .seconds(1.6))
if !Task.isCancelled { deltaShown = false }
}
}
// The tint switches on and off without a fade: a color fade on text that the delta block is sliding garbled the
// digits, and blanked them when the block left. A fresh transaction, so a caller's `withAnimation` around the
// value change cannot fade it either. Under Reduce Motion there is no roll for it to cover.
var instant = Transaction()
instant.disablesAnimations = true
withTransaction(instant) {
rollingTint = style.tintsWhileRolling && !reduceMotion ? (change > 0 ? style.up : style.down) : nil
}
rollTick += 1
let tick = rollTick
// The drum's own pacing: no bounce, since an overshoot would show digits the value never had, and a duration
// that grows gently with the size of the change. A retarget mid-roll merges into this spring. Under Reduce
// Motion the tape does not roll at all (see `Tape`): changed digits switch in place.
let roll: Animation = reduceMotion ? motion.value : .smooth(duration: min(1.2, 0.5 + Double(abs(change)).squareRoot() * 0.01))
var landing = Transaction(animation: roll)
// Only the latest roll lands: when a newer one took over, its own landing does all of this, once.
// The landing the eye sees: the tint lets go and the tick plays. Behind the loading dashes there is nothing to feel.
landing.addAnimationCompletion(criteria: .logicallyComplete) {
guard tick == rollTick else { return }
withTransaction(\.disablesAnimations, true) { rollingTint = nil }
if !wasLoading { settles += 1 }
}
// Spare slots fold only once the drum has stopped: its tail can still cross a high digit. Behind the dashes
// the placeholder keeps its slots, and the next real value lays out from `span`.
landing.addAnimationCompletion(criteria: .removed) {
guard tick == rollTick else { return }
span = abs(value)
guard !wasLoading else { return }
let final = Self.layout(for: abs(value), format: format)
withAnimation(motion.value) { template = final.glyphs }
}
withTransaction(landing) { animated = target }
}
/// Reads the formatted string once to learn where digits, separators, and symbols sit.
private static func layout(for magnitude: Double, format: Format) -> (glyphs: [Glyph], fractionDigits: Int) {
let chars = Array(format.format(magnitude))
let separator = Array(Locale.current.decimalSeparator ?? ".")
// The decimal separator is the last one followed by at least one digit.
var fraction: Range<Int>?
var scan = chars.count - separator.count
while scan >= 0, fraction == nil {
if Array(chars[scan..<scan + separator.count]) == separator, scan + separator.count < chars.count, chars[scan + separator.count].isNumber {
var end = scan + separator.count
while end < chars.count, chars[end].isNumber { end += 1 }
fraction = (scan + separator.count)..<end
}
scan -= 1
}
let fractionDigits = fraction?.count ?? 0
let integerEnd = fraction.map { $0.lowerBound - separator.count } ?? chars.count
let integerIndices = chars.indices.filter { $0 < integerEnd && chars[$0].isNumber }
let firstDigit = integerIndices.first ?? chars.count
let lastDigit = fraction?.upperBound ?? ((integerIndices.last ?? -1) + 1)
var glyphs: [Glyph] = []
for (index, char) in chars.enumerated() {
if let place = integerIndices.firstIndex(of: index) {
let position = fractionDigits + (integerIndices.count - 1 - place)
glyphs.append(Glyph(id: position, kind: .digit(position: position, integer: true)))
} else if let fraction, fraction.contains(index) {
let position = fractionDigits - 1 - (index - fraction.lowerBound)
glyphs.append(Glyph(id: position, kind: .digit(position: position, integer: false)))
} else if index < firstDigit {
glyphs.append(Glyph(id: 3000 + index, kind: .literal(String(char))))
} else if index >= lastDigit {
glyphs.append(Glyph(id: 4000 + (chars.count - index), kind: .literal(String(char))))
} else {
let digitsToRight = integerIndices.filter { $0 > index }.count
let id = fraction.map { index >= $0.lowerBound - separator.count } == true ? 1000 : 2000 + digitsToRight
glyphs.append(Glyph(id: id, kind: .literal(String(char))))
}
}
return (glyphs, fractionDigits)
}
private struct Glyph: Identifiable, Equatable {
enum Kind: Equatable { case digit(position: Int, integer: Bool), literal(String) }
let id: Int
let kind: Kind
}
/// Every digit derives its roll from one animatable number, carrying upward only while the digit below passes 9.
private struct Tape: View, Animatable {
var scaled: Double
let template: [Glyph]
let slot: CGSize
let loading: Bool
let tint: Color?
let dim: Color?
let reduceMotion: Bool
/// Under Reduce Motion nothing is interpolated: the tape lands on the new value at once and each changed digit
/// switches in place instead of turning through the faces between.
nonisolated var animatableData: Double {
get { reduceMotion ? 0 : scaled }
set { if !reduceMotion { scaled = newValue } }
}
var body: some View {
let motion = PieceMotion(reduceMotion: reduceMotion)
let magnitude = abs(scaled)
let rolls = Dictionary(uniqueKeysWithValues: template.compactMap { glyph -> (Int, Double)? in
if case .digit(let position, _) = glyph.kind { return (position, roll(position, magnitude)) }
return nil
})
let visibility = leadingVisibility(rolls: rolls)
HStack(spacing: 0) {
if scaled < -0.5, !loading {
Text("−").transition(motion.transition(.move(edge: .leading).combined(with: .opacity)))
}
ForEach(Array(template.enumerated()), id: \.element.id) { index, glyph in
let reveal = visibility[index]
switch glyph.kind {
case .digit(let position, let integer):
DigitSlot(roll: rolls[position] ?? 0, slot: slot, loading: loading)
.frame(width: slot.width * reveal, height: slot.height, alignment: .trailing)
.foregroundStyle(dimmed(!integer))
.opacity(reveal)
.transition(.opacity)
case .literal(let text):
// A separator between hidden slots narrows with them, so it never holds a gap open before the
// number, and folding the spare slots away once the drum stops moves nothing.
RevealWidth(fraction: reveal) {
Text(text).fixedSize()
}
.foregroundStyle(dimmed(glyph.id == 1000))
.opacity(reveal)
.transition(.opacity)
}
}
}
// The number changes form without overshoot, on the value spring: the sign at the zero crossing, slots added
// or folded away. The dashes give way to digits at once: a crossfade into a drum already spinning doubled and
// cut the glyphs.
.animation(motion.value, value: scaled < -0.5)
.animation(motion.value, value: template)
}
/// Decimals and their separator sit back in the muted color unless the roll is tinted.
private func dimmed(_ isFraction: Bool) -> AnyShapeStyle {
if loading { return AnyShapeStyle(.tertiary) }
if let tint { return AnyShapeStyle(tint) }
if isFraction, let dim { return AnyShapeStyle(dim) }
return AnyShapeStyle(.primary)
}
private func roll(_ position: Int, _ magnitude: Double) -> Double {
let digit = (magnitude / pow(10, Double(position))).rounded(.down).truncatingRemainder(dividingBy: 10)
if position == 0 { return magnitude.truncatingRemainder(dividingBy: 10) }
let lower = roll(position - 1, magnitude)
return digit + (lower >= 9 ? lower - 9 : 0)
}
/// High integer slots that still read zero collapse to nothing and grow in as the carry reaches them.
private func leadingVisibility(rolls: [Int: Double]) -> [Double] {
var result: [Double] = []
var hidden = true
var carry = 0.0
let lastInteger = template.compactMap { glyph -> Int? in
if case .digit(let position, true) = glyph.kind { return position }
return nil
}.min()
for glyph in template {
switch glyph.kind {
case .digit(let position, let integer):
if hidden, integer, position != lastInteger, let roll = rolls[position], roll < 1 {
carry = roll
result.append(loading ? 1 : roll)
} else {
hidden = false
result.append(1)
}
case .literal:
// Leading symbols such as a currency sign always show; separators between hidden slots fade with the carry.
let leadingSymbol = glyph.id >= 3000 && glyph.id < 4000
result.append(hidden && !loading && !leadingSymbol ? carry : 1)
}
}
return result
}
}
/// Gives a glyph a fraction of its natural width, trailing aligned, the way a hidden digit slot narrows.
private struct RevealWidth: Layout {
let fraction: Double
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let size = subviews.first?.sizeThatFits(.unspecified) ?? .zero
return CGSize(width: size.width * fraction, height: size.height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
subviews.first?.place(at: CGPoint(x: bounds.maxX, y: bounds.midY), anchor: .trailing, proposal: .unspecified)
}
}
private struct DigitSlot: View {
let roll: Double
let slot: CGSize
let loading: Bool
var body: some View {
let current = Int(roll.rounded(.down)) % 10
let fraction = roll - roll.rounded(.down)
// The softening eases in over the first 8% of a turn and out over the last 8%, so a digit's edges sharpen
// as it seats instead of switching from soft to hard in one frame after the motion has visibly stopped.
let edge = 0.22 * min(1, min(fraction, 1 - fraction) / 0.08)
ZStack {
if loading {
Text("–")
} else {
Text(String(current)).offset(y: -fraction * slot.height)
Text(String((current + 1) % 10)).offset(y: (1 - fraction) * slot.height)
}
}
.frame(width: slot.width, height: slot.height)
.clipped()
// A digit never crossfades: the fading copies stay where the number was while the delta block slides it, which
// blanked or doubled the digits. Under Reduce Motion, where the tape lands without rolling, a changed digit
// switches in place.
.contentTransition(.identity)
// While a digit is between faces its slot edges soften, like a drum turning behind a window. One gradient at
// every fraction (hard edges at a face): a mask swapped in mid-roll lands where the number is going while the
// digit is still sliding there with the delta block, and hid it until the slide ended.
.mask {
LinearGradient(stops: [.init(color: .clear, location: 0), .init(color: .black, location: edge), .init(color: .black, location: 1 - edge), .init(color: .clear, location: 1)], startPoint: .top, endPoint: .bottom)
}
}
}
}
/// Delta and decimal styling for `Odometer`, built from the Free house palette.
public struct OdometerStyle: Sendable {
/// Glass tint of the delta chip for a rise, and the roll tint when `tintsWhileRolling` is on.
public var up: Color
/// Glass tint of the delta chip for a fall, and the roll tint when `tintsWhileRolling` is on.
public var down: Color
/// Text on the delta chip.
public var ink: Color
/// Color of dimmed decimals.
public var muted: Color
/// Dims the decimal separator and fraction digits.
public var dimsFraction: Bool
/// Tints the digits with `up` or `down` until a roll settles; best on dark grounds.
public var tintsWhileRolling: Bool
public init(up: Color, down: Color, ink: Color, muted: Color, dimsFraction: Bool = true, tintsWhileRolling: Bool = false) {
self.up = up
self.down = down
self.ink = ink
self.muted = muted
self.dimsFraction = dimsFraction
self.tintsWhileRolling = tintsWhileRolling
}
/// Sage and red delta chips with ink, dimmed decimals, untinted digits. Copy it and change one property to customize.
public static let standard = OdometerStyle(
up: adaptive(0xA9DCB7, 0xA9DCB7),
down: adaptive(0xFF0000, 0xFF0000),
ink: adaptive(0x141414, 0x141414),
muted: adaptive(0x8B8984, 0x6F6D69)
)
fileprivate static func adaptive(_ 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
private struct OdometerExample: View {
@State private var balance: Double = 12_480.55
@State private var loading = true
var body: some View {
Odometer(value: balance, format: .currency(code: "USD"), isLoading: loading, ticksOnSettle: true)
.font(.system(size: 48, weight: .semibold))
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(OdometerStyle.adaptive(0xF3F2EE, 0x121212))
// Drives the piece's own states: dashes while loading, then a deposit and a payment roll the digits.
.task {
try? await Task.sleep(for: .seconds(1))
loading = false
try? await Task.sleep(for: .seconds(2.4))
balance += 517
try? await Task.sleep(for: .seconds(2.4))
balance -= 1_450
}
}
}
#Preview("Light") {
OdometerExample().preferredColorScheme(.light)
}
#Preview("Dark") {
OdometerExample().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)
/// 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)))
}
}
// 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, bud)
/// 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 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] } }
}
}
// swiftpieces-liquid: end- Type
- Odometer
- Files
- Odometer.swift
- Depends on
- Nothing (Apple frameworks only)
- Version
- 2.2.0
Slot-machine number with a single spring-driven Animatable value behind every digit. The digits are content, set semibold at whatever size you give them. After a change the delta is a tinted glass chip that buds out from behind the figure's trailing edge, rests a neck's width beside it, and melts back behind it when it goes.
Notes
- One
Animatablenumber drives every slot. Each digit's roll is derived from that number and carries upward only while the digit below passes 9, so higher digits stay perfectly still until a carry reaches them. While a digit is between faces its slot edges soften, like a drum turning behind a window: the softening eases in as a digit leaves its face and out as it seats, so the edges sharpen with the landing. The roll runs on a spring with no bounce, so it never shows a digit the value never had, and its duration grows gently with the size of the change, up to 1.2 s; there is no per-frame loop. - A new value mid-roll merges into the turning drum instead of restarting it, and the layout keeps every slot the in-flight number still needs: a high slot that reads zero narrows to nothing along with its separator, and spare slots fold away only once the drum has stopped. Only the latest roll lands, clearing the tint and playing the settle tick, so
ticksOnSettlegives one.selectiontick per landing. A roll that lands behind the loading dashes clears its tint without a tick. - The layout (grouping separators, decimal separator, currency symbol) is read from the formatted target once per change. Use plain decimal or currency styles; compact notation such as
1.2Kwill not match the rolled digits. Leading symbols always show, and the number never gives up width to the delta chip. - Decimals and their separator are dimmed by default. The sign is its own glyph and slides in or out at the zero crossing. After each change the delta is a liquid glass chip, tinted sage for up and red for down with ink text, that shows the signed amount for 1.6 s. It is born behind the figure's last digit, shrunk and clear, and buds out with visible give to rest a neck's width beside the number, its amount arriving just after, while the number makes room a beat behind the roll. Going away, its amount blurs off at once, its tint drains and it slides back behind the digits on a spring with no bounce, fading as it gets there, and the number closes up. A change while it is showing rolls its amount without overshoot, blurs its arrow across when the direction turns, and resizes and recolors the glass in place with a little give.
Stylesetsupanddown(the chip's glass tints),ink,muted,dimsFraction, andtintsWhileRolling(tints the digits from the start of a roll until it lands, switching without a fade; best on dark grounds).isLoadingswaps every digit for a dash. When it ends, the dashes give way to digits at once and the first real value rolls up from zero.- Under Reduce Motion nothing rolls: the number lands on its new value at once, each changed digit switching in place, and with no roll there is no roll tint. The sign fades instead of sliding, and the delta chip fades in and out in place beside the number instead of budding; the dimmed decimals and the settle tick are unchanged. Under Reduce Transparency the chip's glass draws solid.
- VoiceOver reads the final formatted value only, never the intermediate frames. Apply
.fontto theOdometerto set its size; the piece sets every string semibold, as the whole library does, so size alone sets the hierarchy. Slot sizes are measured from that font. - A full dashboard screen ships in SwiftPieces Pro (
dashboard-screen).
Usage
OdometerExample().preferredColorScheme(.light)Parameters
| Parameter | Description |
|---|---|
value | The target value. Changing it rolls the digits from the current value. |
format | A decimal FormatStyle from Double to String, for example .number.precision(.fractionLength(0)) or .currency(code: "USD"). Defaults to whole numbers. |
isLoading | Shows a dash in every digit slot instead of a value; the first real value rolls up from zero. |
showsDelta | Shows a signed delta chip of tinted glass beside the number for a moment after each change. |
ticksOnSettle | Plays a .selection haptic when a roll finishes. |
style | Delta chip tints, decimal dimming, and roll tinting. Defaults to .standard: sage for up, red for down, dimmed decimals, no tint while rolling. |
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 data and charts
All data and charts →Build it yourself: SwiftUI animations guide