# SwiftPieces: full text > 77 free SwiftUI components for iOS, one Swift file each, MIT + Commons Clause. Index: https://swiftpieces.com/llms.txt. Source for any piece: https://swiftpieces.com/r/.json or its docs page. # Components ## Assistant Orb (`AssistantOrb`) URL: https://swiftpieces.com/docs/components/ai/assistant-orb Category: AI. iOS 17.0+. Metal shader. Files: AssistantOrb.swift, AssistantOrb.metal Install: npx swiftpieces add AssistantOrb A dark glass ball with a Siri-style voice wave inside, locked to the thinking state; four thin sine membranes travel through the equator of a refracting shell, drawn by a Metal color shader. The sphere never moves, only the wave, and a palette change crossfades its colours. The assistant's mark while it thinks. Copy `AssistantOrb.metal` alongside this file; its `assistantOrbSiriWave` kernel compiles into your app's default Metal library. ### Notes - Requires `AssistantOrb.metal` in the same target. The `[[ stitchable ]] assistantOrbSiriWave` function is looked up through `ShaderLibrary` at runtime. Its helpers live in the `assistantOrbSiri` namespace, so it can share a target with other orb kernels. - The orb has one look: thinking. Four thin sine membranes in blue, pink, orange and red travel left to right through the equator over a violet ground, seen through a refracting glass shell with a cool and warm rim. There is no idle or speaking phase, no audio level and no touch interaction. Colour is the only input: `palette` defaults to `.siri`, and `AssistantOrb.Palette(bands:ground:crest:rimCool:rimWarm:)` takes hex values or `Color`s for your brand. A palette change crossfades all eight colours on a calm spring, so a second change mid-fade turns from the colours on screen; under Reduce Motion it is a short colour fade. The motion never changes. - The sphere never scales, floats or pulses; only the wave inside it moves. It has no entrance of its own, so it arrives with whatever presents it. Put the orb on a dark surface and pair it with your own status copy if the step changes. - `size` sets a square frame. The ball fills 90% of it, so leave the frame square. - The clock restarts each time the orb appears, so the wave always enters on the same first pose and `Float` precision holds over long sessions. - Under Reduce Motion the wave rests on that first pose: a still frame that nothing moves or dims, and a palette change only recolours it in place. Your status copy carries the sense of progress there. Turning Reduce Motion off lets the wave flow on from that pose without a jump. - VoiceOver reads "Assistant" with "Thinking" as its value. ### Parameters - `size`: Diameter of the ball in points. 160 by default; the view is always a square of this side. - `palette`: The eight colours of the wave, ground, crest and rim. `.siri` by default; build your own with `Palette(bands:ground:crest:rimCool:rimWarm:)` from hex or `Color`. A change crossfades on a calm spring. ## Prompt Chips (`PromptChips`) URL: https://swiftpieces.com/docs/components/ai/prompt-chips Category: AI. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: PromptChips.swift Install: npx swiftpieces add PromptChips Liquid glass prompt chips above a composer, in a snapping horizontal row or a grid of columns, each keyed by a tinted glyph disc. They cascade in one after another as they appear. A tap presses the chip and swells it with a pop of its disc, and the other chips flow into it, nearest first, melting into its glass; then the gathered chip condenses into a drop that falls into the composer as the chips close, handing it the text. Prompt suggestions as liquid glass chips, sized for thumb reach directly above a composer. ### Notes - Each chip is a liquid glass capsule keyed by a 32pt tinted glyph disc concentric with its leading end; `style.blocks` cycle across chips (signal, sky, butter, sage, lilac) and `symbols:` gives each chip its own glyph. Pass `tint` for a single color. Chips are separate suggestions, so each is its own glass shape, 12pt apart, all in one glass group that only joins shapes inside 10pt: at rest they stay apart, and they melt together only while closing. - Two layouts. `.row`, the default, is a horizontal `ScrollView` with `viewAligned` snapping and 16pt content margins, sized for thumb reach directly above a composer. `.grid(columns:)` lays the chips out in equal columns that fill the width, wrapping onto as many rows as the suggestions need, for a starter screen with several prompts in view at once; a long title wraps to a second line instead of truncating. - Arriving, the group fades in on the calm reveal spring while the chips rise in reading order, 45ms apart, each lifting 12pt and growing from 94% (glass ignores opacity inside its group, so the group fades as a whole). A new set of suggestions arrives the same way. - Press: the chip's glass and words sink together about 2.5pt per edge, then spring back with a little give. The press is centered, so it never fights the row's scrolling. - Choosing gathers everything into the chosen chip: it swells to 104% on the success spring as its disc pops, and every other chip flows home into it, nearest first (40ms a place, counted along the row or across rows and columns in a grid), on the bounceless home spring. Each one's title and disc blur away the moment it leaves, and its glass shrinks to 72% as it travels, so it melts into the chosen chip through a liquid neck and disappears inside it. Chips scrolled out of a row travel in too. - Then the hand-off, once the farthest chip has arrived (440ms plus 40ms a place after the tap): the melted chips close out of sight, the chosen chip's title blurs away, and its glass condenses into a drop that falls 24pt toward the composer as it closes, while the chips close from their measured height so the composer below rises into their place. Nothing is clipped. `onSelect` fires on that beat, and `selection` is written back. - One selection haptic per choice, on the tap; there is no press haptic, so a touch that turns into a scroll stays silent. - One choice per set: once a chip is chosen, further taps, VoiceOver activations and `selection` changes are ignored until the suggestions change or the view is rebuilt. The chips that melted in leave the accessibility tree. - `Style` (`PromptChips.Style`): `blocks`, `text`, `ink`, `cornerRadius` (26 by default, a capsule at the chip's 52pt height). `surface` is kept for source compatibility and no longer used. Every string is semibold. - Under Reduce Motion nothing travels: the chips fade in together, the others close in place when one is chosen (their titles fade as their glass closes on a short ease), the chosen one closes the same way as it hands off (0.2s to `onSelect`), and a press shades instead of sinking. Reduce Transparency makes the glass solid. - **v3:** the motion was rebuilt around a cascade in and a hand-off toward the composer. - **v3.1:** a new close. The ripple away is replaced by a gather: the others flow into the chosen chip where it sits and melt into its glass, so nothing pools anywhere else, and the gathered chip falls into the composer as one drop. The public API is unchanged. - Need a full composer with attachments, recording and model picker? That is the Prompt Composer screen in SwiftPieces Pro. ### Parameters - `suggestions`: Chip titles, in order. - `systemImage`: Glyph on every chip's colour disc. Pass `nil` for text-only chips. - `symbols`: Optional per-chip glyphs, matched to `suggestions` by index. Falls back to `systemImage`. - `tint`: One disc colour for every chip. `nil` cycles `style.blocks`. - `style`: Text, disc colours and radius. Defaults to the house palette. - `layout`: `.row` (the default) is one horizontally snapping row; `.grid(columns:)` wraps the chips into equal - `selection`: Optional two-way selection. Set it to a suggestion to choose it programmatically; the chips write the chosen title back when it hands off. - `onSelect`: Called with the chosen title as it hands off to the composer, once the other chips have melted into it ## Source Stack (`SourceStack`) URL: https://swiftpieces.com/docs/components/ai/source-stack Category: AI. iOS 17.0+. Files: SourceStack.swift Install: npx swiftpieces add SourceStack Citations for an AI answer: a compact pill of overlapping site badges and a source count that opens into a list of numbered source cards. The badges fly out of the stack into their cards as the list cascades in under them, each card opens its link with a press, and closing gathers the badges back into the pill. The sources behind an AI answer, folded into one pill under the reply and opened into numbered cards on tap. The pill shows the first few sources as overlapping badges (a colour disc with the site's initial) and the count. Tapping it sends those badges out of the stack into their cards while the list cascades in, numbered so they match the `[1]` style markers in your answer text. Tapping a card opens its link, or calls `onOpen` if you pass one. ```swift SourceStack([ .init(title: "Tram 28, stop by stop", site: "Tram Notes", detail: "Updated May", url: URL(string: "https://example.com/28")), .init(title: "How the funiculars climb", site: "Hill & Harbour", url: URL(string: "https://example.com/funiculars")) ]) ``` ### Notes - Closed, the stack is one pill: the first `stacked` sources (3 by default) as overlapping badges, each a colour disc with the site's initial ringed in the surface so they stay apart, then the count ("4 sources", inflected for one) and a chevron. Place it directly under the answer it supports. `alignment` puts the pill at the leading edge (the default, under an answer) or centred when the stack stands on its own; the list always spans the width. - Tapping the pill opens the list. The pill's badges fly out of the stack into their own cards on the morph spring with a little give, while the cards cascade in under them 40ms apart, rising 10pt as they fade in. Sources past `stacked` arrive with their cards. The pill drops to the count alone and its chevron turns over. Closing reverses it on the quicker dismiss spring: the cards fade together and the badges gather back into the pill. - Each card shows its badge, the title (up to two lines), the site and optional `detail` (a date, say), and its citation number, matching the `[1]` markers in your answer text. A tap opens `url` through `openURL`, or calls `onOpen` when you pass one, so you can show the page in-app. - Pass `isExpanded` to open the list from your own code, for example when someone taps a `[2]` marker in the answer. A change from outside runs the same flight as a tap. - The pill and every card press in with the house press: a size-aware sink that springs back with a little give. One selection haptic per open or close, one light impact per source opened. - VoiceOver reads the pill as "4 sources, collapsed" with a hint, and each card as one link: "Source 2: Tram 28, stop by stop, Tram Notes, Updated May". The badges and numbers are decoration and stay out of the tree. - Reduce Motion: the badges move without overshoot on a short spring, the cards fade in without rising, and presses shade instead of sinking. Haptics are unchanged. - Dynamic Type: titles and site lines scale freely; badges stop growing at xxxLarge so the stack stays a stack. - `Style` sets the surface, text, secondary and ink colours, the badge `blocks` (sky, butter, sage, lilac, sand and signal by default) and the card corner radius. ### Parameters - `sources`: The sources, in citation order. Card numbers follow this order, starting at 1. - `isExpanded`: Optional two-way open state, for opening the list from your own marker taps. Without it the pill keeps its own. - `stacked`: How many badges the closed pill shows, 1...5. Defaults to 3. - `alignment`: Where the pill sits across the width: `.leading` under an answer (the default), `.center` on its own. - `style`: Surfaces, text and badge colours. Defaults to the SwiftPieces house palette, adapting to light and dark. - `onOpen`: Called with the tapped source. Without it a card opens the source's `url` through `openURL`. ## Streaming Reply (`StreamingReply`) URL: https://swiftpieces.com/docs/components/ai/streaming-reply Category: AI. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: StreamingReply.swift Install: npx swiftpieces add StreamingReply The AI reply surface in liquid glass. A glass header pill carries the assistant mark and a phase label that morphs letter by letter, text arrives token by token with a per-word fade and a tinted cursor bubble, inline code sits on neutral chips, and user prompts are signal-tinted glass bubbles. A long press lifts the message: the header pill swells into a glass card around the reply and Copy and Regenerate bud out of its bottom edge, and a stopped reply buds a signal Retry out of its header pill. A chat message that streams. Assistant replies sit on the ground under a glass header pill; user messages are a signal-tinted glass bubble. ### Notes - Assistant replies sit on the ground under a liquid glass header pill: a 24pt ink mark, the name (`style.assistantName`) and a small uppercase phase label (THINKING, WRITING, STOPPED) that morphs letter by letter, the letters two phases share holding still, while the pill reshapes around it. User prompts are signal-tinted glass bubbles (`style.userBlock`) with dark house ink and a tucked bottom-trailing corner. Set `style.showsHeader = false` for a bare reply. - Text is split into word tokens (each word keeps its trailing whitespace), and each new word settles like ink over `fadeDuration`: its opacity eases out, so it reads almost at once, and on iOS 18 a 2pt rise trails it and comes to rest just after the word is fully inked. On iOS 18 a private `TextRenderer` draws each token this way; on iOS 17 the same opacities are written into the `AttributedString` runs, without the rise. Words that arrive together in one update ink in left to right, 24ms apart, never more than 0.12s (or 40% of `fadeDuration`) behind the stream. Only words that arrive after the view appears animate, so loaded history renders instantly. - The reply text is not selectable: selectable text is drawn without the renderer, so words would land without their ink and the cursor would never breathe. Copy in the lifted actions takes the whole message. - Inline markdown renders in one weight: every string is semibold, strong emphasis included. Inline `code` sits on a small rounded chip, a faint wash of the text colour, so it reads on the ground and on a tinted bubble alike; set `style.code` to tint it, with dark ink on top. On iOS 17 the chip is the run's background. - The cursor is a small tinted bubble sized from the body font's cap height, so it flows with the text and scales with Dynamic Type. On iOS 18 it holds solid while words land, like a caret while typing, and breathes slowly once the stream pauses; on iOS 17 it is solid. Streaming with no words yet shows the cursor alone on the line the thinking dots used, so the reply never collapses and regrows. - `phase` is a value, not internal state: drive `.thinking` (block-colored dots rising in sequence, each easing out of rest and back, and a pulsing mark), `.streaming`, `.done`, `.error(message)` from your stream. New phases come in calmly and `.done` clears the label and cursor firmly. Entering `.error` turns the mark signal, shakes it once by 3pt with an error haptic, shows the message under the reply and buds a signal Retry out of the end of the header pill, joined to it by a liquid neck; it calls `onAction(.regenerate)` and melts back into the pill when the phase moves on. Without a header, Retry sits beside the message on its own glass. - Press and hold: after a short beat the message sinks a touch toward its tail, without bounce, and springs back if you let go early; at 0.35s it springs up with a rigid impact as the header pill swells into a glass card around the whole reply, and Copy and Regenerate bud out of the card's bottom edge, hanging a neck's width under it and resting apart from each other. A user's bubble lifts the same way and buds Copy alone. The room they take opens on a calm spring with only a slight give, so the messages around it move without bobbing. Copy writes to `UIPasteboard`, its glass tints sage, its glyph swaps to a check and its label morphs to Copied, and it pops with a success haptic; the message is set down 0.9s later unless you picked it up again. Tap the message to put it down: it lands with a little give while the actions melt back and the card shrinks into the pill, with no bounce. The lift's shadow is cast from the card's outline. Copy, Regenerate and Retry sink under the finger, glass and label together, and spring back. - `Style` (`StreamingReply.Style`): `userBlock`, `cursor`, `code`, `dots`, `signal`, `ink`, `text`, `muted`, `mark`, `markInk`, `actionInk`, `success`, `assistantName`, `showsHeader`. `surface` and `actionFill` are kept for source compatibility and no longer used. `tint:` still overrides the user bubble and cursor. - Under Reduce Motion new words still fade in, together and without the rise. The cursor holds solid, the mark neither pulses nor shakes (its signal color shows the error), and the thinking dots stay in place while a ripple runs through their opacity, so thinking still shows a sign of life. The message neither sinks nor scales: the card swells and the shadow fades in for the lift, the bubbles fade in place, labels crossfade, and pressed buttons shade instead of sinking. Reduce Transparency makes every glass shape solid. - Need full incremental markdown with lists, tables, source cards and a follow-up bar? That is the Streaming Markdown screen in SwiftPieces Pro. ### Parameters - `text`: The message body. Append to it as tokens arrive; each new word fades in. Inline markdown is rendered. - `role`: `.assistant` (leading, header and open text) or `.user` (trailing, tinted glass bubble). - `phase`: `.thinking` shows dots, `.streaming` shows the cursor, `.done` settles, `.error` shows the message and buds a Retry out of the header pill. - `tint`: Glass tint of user messages and the streaming cursor. `nil` uses `style.userBlock` and `style.cursor`. - `fadeDuration`: Seconds each new word takes to fade in. - `style`: Colors, the assistant name and whether the header shows. Defaults to the house palette. - `onAction`: Called with `.copy` or `.regenerate` from the long-press actions or the error retry. ## Thinking State (`ThinkingState`) URL: https://swiftpieces.com/docs/components/ai/thinking-state Category: AI. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: ThinkingState.swift Install: npx swiftpieces add ThinkingState The "assistant is working" placeholder, laid out like the reply it becomes: a liquid glass header pill carrying a breathing mark and an elapsed-seconds label that morphs as it counts, over three presentations on one clock: block-colored rising dots, a sheen over reply-shaped bars, or a label whose glyphs carry the sheen. Plus a modifier that sweeps any view. Placeholder for a reply that has not started. Every presentation mirrors the `StreamingReply` layout (mark and label, then content) so it resolves into the real message without a layout jump. ### Notes - Every presentation shares the `StreamingReply` layout: a liquid glass header pill (breathing ink mark, name and a THINKING · 3S elapsed label that counts up) and then content sized to a body line, so swapping `ThinkingState` for a `StreamingReply` lands on the same pill and does not shift anything. Set `style.showsHeader = false` for content only. - `.dots` rises three block-colored dots (tangerine, sky, lilac by default), `.sheen` sweeps a band across reply-shaped bars, and `.text(label)` sweeps the band through the label's glyphs. - One `TimelineView` drives the mark's breath, the dots and the sheen, so they stay phase-locked. It starts when the placeholder appears: the first pass enters from the leading edge and the dots rise from rest. `duration` sets the period of one pass. - Each pass eases the band across over the first 80% of `duration`, then rests just clear of the view before the next. The band slides whole, so it keeps its width as it enters and leaves. The mark draws in and turns on the same beat and is back to full size and upright between passes, which is the `StreamingReply` mark's pose. Each dot eases out of rest and back, as in `StreamingReply`. - Each second the count morphs: the digits it shares with the last one hold still and the new ones blur in, in tabular figures, so the pill only widens (with a little give) when the count gains a digit. THINKING · holds still. - Pass `tint` to use one color for the dots, mark and sheen. - `.thinkingState(_:tint:)` masks a bright sweep to the view's own alpha, so it follows capsules, glyphs and text with no clipping setup. It runs the same paced 2.2-second pass, starting from the leading edge each time `isActive` turns true, and fades in and out with `isActive`. On a glass pill, put it on the label inside the glass, as the example does, so the sweep runs through the words and the glass stays clear. - `Style` (`ThinkingState.Style`): `dots`, `bar`, `sheen`, `mark`, `markInk`, `ink`, `text`, `muted`, `assistantName`, `showsHeader`. - Every string is semibold. Under Reduce Motion nothing moves, but the placeholder still shows it is working: the band becomes an even glow that brightens and fades in place on the same beat (over the bars, through the label's glyphs, and in the modifier), the dots hold still and ripple their opacity, the mark rests full size and upright, and the elapsed seconds crossfade. Reduce Transparency makes the pill solid. - Need a live timeline of tool calls with durations and results? That is the Tool Execution Card screen in SwiftPieces Pro. ### Parameters - `presentation`: `.dots`, `.sheen` (reply-shaped bars), or `.text(label)`. - `tint`: Optional single color for the dots, mark and sheen. `nil` uses the house blocks for the dots and a neutral sheen. - `lineCount`: Number of placeholder bars in the `.sheen` presentation. - `duration`: Seconds per sheen sweep; the dots and mark breathe on the same clock. - `style`: Colors, the assistant name and whether the header with elapsed seconds shows. Defaults to the house palette. ## Thought Orb (`ThoughtOrb`) URL: https://swiftpieces.com/docs/components/ai/thought-orb Category: AI. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Metal shader. Files: ThoughtOrb.swift, ThoughtOrb.metal Install: npx swiftpieces add ThoughtOrb A small dark glass ball with a Siri-style voice wave inside, locked to the thinking state and drawn by a Metal color shader; palettes for searching, reading and writing crossfade the colour with the kind of work. Its Pill is the status control in liquid glass: the mark rides a round glass bubble joined by a liquid neck to a capsule whose label morphs letter by letter into each new verb while the capsule reshapes around it, with indeterminate dots at its end. The mark next to the verb an agent is doing. Copy `ThoughtOrb.metal` alongside this file; its `thoughtOrbSiriWave` kernel compiles into your app's default Metal library. ### Notes - Requires `ThoughtOrb.metal` in the same target. The `[[ stitchable ]] thoughtOrbSiriWave` function is looked up through `ShaderLibrary` at runtime. Its helpers live in the `thoughtOrbSiri` namespace, so it can share a target with other orb kernels. - The mark has one look: thinking. Four thin sine membranes travel left to right through the equator of a dark glass ball. There are no motion states and no drag; the sphere never scales or pulses, only the wave inside it moves. The wave's clock starts as the mark appears. - Colour says what kind of work is happening. Presets: `.siri` for thinking (blue, pink, orange, red over violet), `.searching` (electric blue, aqua and mint), `.reading` (indigo, lavender and a teal glint) and `.writing` (red, amber, rose and violet). Build your own with `ThoughtOrb.Palette(bands:ground:crest:rimCool:rimWarm:)`. A change crossfades on a calm spring, so steps that change faster than the fade glide from colour to colour instead of stacking. - `ThoughtOrb.Pill` is liquid glass: the mark rides its own round glass bubble, joined by a liquid neck to a glass capsule holding the label and the dots. They are two parts of one control, so they rest a neck's width apart and the neck holds. The shader ball sits in its bubble rather than on glass, so it is never glass on glass. - Drive it from the current step: `ThoughtOrb.Pill(step.title, palette: step.palette)` morphs the label into the new verb letter by letter, the letters both verbs share holding still while the rest blur through, as the capsule's width and the dots at its end reshape around it in the same quick beat, a 0.3s spring with a little give at the end, quick so it stays light in a toolbar. The mark's bubble stays put; only its colour follows on its slower fade, and the motion keeps thinking. `ThoughtOrb(size: 64)` works at avatar size. - `Pill` derives type, the glass around the mark and the dots from `markSize` (0.47x label, a bubble 1.4x the mark) and scales the whole thing with Dynamic Type through `@ScaledMetric`. Every string is semibold. Each dot rises and falls on an eased curve, then rests, in a ripple from left to right that starts from rest as the dots appear. They run on the clock rather than a repeating animation so they stay in phase however long the pill is up; VoiceOver hears the whole label with "Thinking" as its value and skips the dots. On iOS 26 the glass is Liquid Glass; earlier it is a frosted material with a light rim, and Reduce Transparency makes both shapes solid. - With an `action`, the pill is a button: its glass and what it carries sink together a touch while pressed, centered so it is safe in toolbars and scrolling content, and it plays one soft tap when the action fires. - Under Reduce Motion the wave rests on the pose it starts from, a fixed frame that holds through label and colour changes, and the light in it breathes instead: the four bands dim by a quarter and come back over 2.4s, then rest at full for 0.8s, redrawn at 30 frames a second. The glass, rim and crest stay lit and still, so even a bare `ThoughtOrb(size: 64)` reads as thinking with nothing travelling. In the pill the dots keep cycling too, since they only change opacity. The label crossfades instead of morphing, the capsule resizes on a short spring, a palette change is a short fade, and a press shades the pill instead of sinking it (darker in light mode, lighter in dark). Turning Reduce Motion off resumes the wave from its rest pose without a jump. ### Parameters - `size`: Diameter of the ball in points. 18–30 inside a pill, 64 at avatar size. - `palette`: The kind of work the colour portrays: `.siri` (thinking, default), `.searching`, `.reading` or `.writing`, or your own `Palette(bands:ground:crest:rimCool:rimWarm:)`. A change crossfades calmly, and a quick run of changes glides from colour to colour; the motion never changes. ## Silk (`Silk`) URL: https://swiftpieces.com/docs/components/backgrounds/silk Category: Backgrounds. iOS 17.0+. Metal shader. Files: Silk.swift, Silk.metal Install: npx swiftpieces add Silk A slowly folding silk surface lit by a movable light and shaded from a real gradient normal in a Metal shader, in house block fabrics (the brand red among them) and an adaptive standard style, with optional device-tilt lighting. Content only, so it carries no glass. Flowing silk background. Copy `Silk.metal` alongside this file. The shader samples the fold height field three times to build a normal, then shades it with Blinn-Phong so the sheen sits on the folds facing the light. It is a surface for content and glass controls to sit on, so it draws no glass and no text of its own. ### Notes - Requires `Silk.metal` in the same target. The shader samples the fold height field three times per pixel (centre, +x, +y) to build a real gradient normal, then shades it with Blinn-Phong, so the sheen lands on the folds facing `light` instead of being painted by height. - `light` is a `UnitPoint` in the view's coordinates; move it to change where the highlight sits. `Style.depth` is the fold contrast and `Style.shine` the highlight tightness. - The folds start from their rest pose when the view appears and ease up to `speed` over about a second; every new `speed` eases in the same way instead of jumping, and `speed: 0` eases the folds to a stop. Time accumulates per frame from appearance and wraps on the shader's exact period (about seven minutes at speed 1), so the fabric never pops. It renders at up to 60 fps, and only while something moves: with the folds stopped and `tiltLight` off it stops drawing new frames once the fabric and the light have come to rest. - `tiltLight` starts a private `CMMotionManager` source at 30 Hz, relative to the attitude at start, and only while the view is on screen and the scene is active. The light glides toward each sample every frame (a 0.2 s low-pass) and eases toward ±0.6 of the view instead of stopping dead at a limit. It stops on disappear, whenever `scenePhase` leaves `.active` and when `tiltLight` turns off, and the light glides back to its resting point rather than jumping. It is a no-op in the simulator. - Reduce Motion holds the folds still: on the rest pose when it is on at appearance, or stopped at once where they are when it turns on, so the fabric never jumps. With `tiltLight` the light still follows the device, which is user-driven, not decorative. - `Style.standard` (default) is stone with a white sheen in light and charcoal with a sand sheen in dark. `.tangerine` (the brand red, `#FF0000`), `.sky`, `.butter`, `.sage`, `.lilac` and `.sand` are solid house-block fabrics for panels and cards; put `#141414` ink on them. `.ink`, `.graphite`, `.paper` and `.indigo` still work. - The radial fold term is centred off-screen, so no pinch point appears in the middle of the view. - Silk is a surface, not a control, so it draws no glass and no text of its own. Liquid glass controls sit on it well: the folds move behind their rims. - The `#Preview` is the brand red block fabric alone, full bleed, with nothing on top of it. ### Parameters - `style`: Fabric color, sheen color, fold contrast and highlight tightness. `.standard` (default) follows light and dark; `.tangerine` (the brand red, `#FF0000`), `.sky`, `.butter`, `.sage`, `.lilac` and `.sand` are solid house-block fabrics for panels and cards. `.ink`, `.graphite`, `.paper` and `.indigo` remain available. - `light`: Where the light comes from, in unit coordinates of the view. Top-left by default. - `tiltLight`: When true, the device's attitude moves the light. Uses Core Motion and stops whenever the scene is not active. - `scale`: Pattern zoom; larger is finer folds. - `speed`: Time multiplier. ## Touch Grid (`TouchGrid`) URL: https://swiftpieces.com/docs/components/backgrounds/touch-grid Category: Backgrounds. iOS 17.0+. Files: TouchGrid.swift Install: npx swiftpieces add TouchGrid A Canvas dot grid that swells and warms into the brand red or another house block under the finger, springs back on release, and sends one dissipating ripple out from a tap. Content only, so it carries no glass. Interactive dot grid. Resting dots are a tonal ramp on `Style.dot` (`.primary` by default, so both color schemes work); under the finger they swell and take on `Style.highlight`, a solid house block. It is a surface for content and glass controls to sit on, so it draws no glass and no text of its own. ### Notes - Everything is drawn in one `Canvas` inside a `TimelineView` that pauses when nothing is pressed, springing or rippling, so an idle grid costs nothing. A short task clears expired springs and ripples so the timeline can pause again. - While dragging, the field sits on the finger at every frame. At touch-down its strength swells in on the library's press spring (about 90% in 90 ms, no overshoot), so the dots rise under the finger instead of appearing at full size. - On release the field springs back on the library's expressive spring, dipping below resting size before it settles in about 0.8 s (the dots under a full press shrink to about 0.75x for a moment), and coasts on along the finger's last direction like a scroll view decelerating, eased toward half the `radius`, so a flick leaves a short wake. A quick tap lifted mid-swell carries on for a moment before it springs back. A new touch never cuts a lifted field short: it plays out where it was let go while the new one swells under the finger. - A tap (moved under 8 pt, held under 0.3 s) emits one ripple ring that eases outward and widens as it fades; ripples are capped at three. - Lifted dots swell up to 3.2x and crossfade from the resting ink into the `style` highlight, so a fully lifted dot reads as solid block color. - The `.selection` haptic fires at most every 120 ms and only when the finger crosses into a new grid cell, so a quick drag ticks lightly instead of buzzing. - A touch the system cancels (a scroll taking over, an alert) lets go like a lift, so the grid never stays pressed. - Reduce Motion keeps the swell, the color and the haptic under the finger. The release settles on a short spring without dipping below rest or coasting, and a tap blooms in place, warming the dots around it for about half a second, instead of sending a ring out. `ripples: false` turns the bloom off too. - `probe` drives the grid without a finger, for previews, hints, or onboarding. Its releases spring back in place, with no coast. - `style:` sets the resting dot color (`.primary` by default) and the block the dots take on as they lift. `.standard` warms into the brand red, `#FF0000`; `.sky`, `.lilac`, `.sage` and `.butter` use the other blocks, and `.mono` keeps the single-tone ramp. - The grid is a surface, not a control, so it draws no glass and no text of its own. - The `#Preview` is the grid alone on the house ground, full bleed; touch it to swell, release to spring back, flick to see the field coast, tap for a ripple. ### Parameters - `spacing`: Distance between dots. - `dotSize`: Resting dot diameter. - `radius`: Influence radius of the touch. - `baseOpacity`: Opacity of resting dots on `.primary`. - `ripples`: Whether a tap emits a ripple ring. Under Reduce Motion a tap blooms in place instead of sending a ring out. - `probe`: Optional programmatic touch point for previews and onboarding hints. Setting it back to `nil` triggers the release spring; a probe held under 0.3 s emits a ripple like a tap. - `style`: Resting dot color and the block color dots take on under the finger. `.standard` (default) warms into the brand red; `.sky`, `.lilac`, `.sage` and `.butter` use the other blocks; `.mono` keeps the original single-tone ramp. ## Flip Card (`FlipCard`) URL: https://swiftpieces.com/docs/components/cards/flip-card Category: Cards. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: FlipCard.swift Install: npx swiftpieces add FlipCard A two-sided card you flip by tap or by dragging sideways. The drag scrubs the rotation, release commits or snaps back by velocity and carries the flick into the landing, the card lifts toward you mid-turn with a layered shadow that slides off the raised edge, and each face shades as it turns edge-on. An optional liquid glass flip hint rides the card's axis, its signal dot smearing from the front slot to the back slot as the card turns, and the faces take semibold type. Two-sided card with tap-to-flip and drag-to-flip. ### Notes - One `DragGesture(minimumDistance: 0)` handles everything: touch-down tilts the card toward the finger and sinks it to 0.97, an 8pt sideways move turns into a scrub where the full card width equals 180°, and a lift without movement is a tap. The press arrives on a tight spring with no overshoot and lets go with a little give. Only the press is animated, so the scrub is directly under the finger from its first frame. - Release commits when `predictedEndTranslation` projects past 90° in either direction, so a flick finishes the flip and a slow drag that stops short snaps back. Either way the card carries the flick's speed into the landing, so it turns straight through instead of stalling at lift-off, then swings past the face it lands on and settles back: about 8° after a turn from rest, and 13° to 20° after a hard flick. The release speed is capped, so even the hardest flick settles within about 20° instead of slinging further past. Past a half turn the scrub meets rubber-band resistance that grows the further you pull, easing toward 30° past it without ever hitting a wall. - While a finger turns it, the card lifts off the page even face-on: the shadow eases up as you pick it up and settles back down with the landing. Catch the card while it is still landing and it finishes that landing under your finger instead of jumping. A touch the system cancels turns the card back to the face it started on without committing. - Each face darkens with `brightness` as it turns edge-on and hides past 90°; the back is pre-rotated by -180° so it lands unmirrored. The shadow slides opposite the lifted edge and widens mid-flip. - The angle accumulates (0, 180, 360...) so repeated flips keep rotating the same way; `isFlipped:` stays in sync in both directions. A tap, the VoiceOver action and the binding turn the card a little slower than a release, since they start from rest, and a second tap mid-turn carries straight on into the next half turn. - A light impact fires on touch-down and a soft impact as the visible face changes. Reduce Motion replaces the rotation with a crossfade that the drag still scrubs, keeps the card flat, lands without overshoot, and shades the faces on press (darker in light mode, lighter in dark) instead of tilting and sinking them. The haptics stay. - **v2:** the card lifts 6% toward you at the edge-on point, sits on a layered shadow (a tight contact shadow plus a soft key shadow that slides off the raised edge), a soft-light band crosses each face as it turns, and the scrub rubber-bands past a half turn. Touch-down gives a light impact. - `style:` (`FlipCard.Style`) tunes `cornerRadius`, `shadow`/`shadowOpacity` (tripled in dark mode), `lift`, `pressScale`, `pressTilt`, `shading`, and the flip hint (`hint`, `hintTint`). `onFlip:` reports each committed flip. A disabled card dims and ignores touches. - **v2.2:** liquid glass. The faces stay yours (they are content, not chrome), and everything on them takes semibold type: the piece sets `.fontWeight(.semibold)` over both faces. `Style(hint: true)` adds the card's glass: a flip hint, a small liquid glass pill at the top centre of the card, on the axis it turns about, with a faint slot for each face and a signal dot (`hintTint`) over the face showing. The dot follows the drawn angle, so it slides under a scrub and lands on the turn's own spring, and mid-turn it smears along the pill like a drop on the move. The pill never turns with the faces: Liquid Glass can't follow a half turn in 3D (it flattens mid-flip), so it stays flat over both faces, lifts toward you with the card mid-turn and tips with a press, and the card's shadow never falls on it. On the axis it stays over the card the whole way round. Under Reduce Motion the dot slides without smearing. Off by default; keep the top centre of your faces clear for it. - Keep glass off the faces themselves, for the same reason: draw badges on a face as part of it. The example's "PORTUGUESE", "3 / 12" and "MEANING" badges are light chips 12pt in from the edges and 28pt tall, so their ends are concentric with the 26pt corners, level with the hint and in line with the face's text. - The example is a vocabulary flashcard: a butter front with a large word and a sky back with the meaning, each with its light badges, and the flip hint on. The preview shows the card alone, with no surrounding UI. - For a full card screen with number masking and controls, see the Pro Card Details screen (`payment-card`). ### Parameters - `isFlipped`: Optional binding to drive the flip externally. Leave nil to let the card manage its own state; taps and drags still update the binding when one is passed. - `style`: Depth, lift, press, and shading tuning. `.standard` uses the house values; the faces bring their own color. - `onFlip`: Called after a flip commits, with `true` when the back is now showing. - `front`: Front face. Give it its own shape and surface; the card only rotates, lifts, shades, and shadows it. Text - `back`: Back face, shown after the flip. It is pre-rotated so text is never mirrored. ## Motion Card (`MotionCard`) URL: https://swiftpieces.com/docs/components/cards/motion-card Category: Cards. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: MotionCard.swift Install: npx swiftpieces add MotionCard A solid color card that tilts with the device's attitude through an exponential filter, easing into its limit and leveling out when the grip changes, with a fixed-light sheen and a layered shadow that slide as it turns. Its content takes semibold type, and liquid glass chips laid on its face tilt and catch the same light. Press settles it toward flat, tips it down under the finger and sinks it with a light impact, and Core Motion stops whenever the scene is not active. Attitude-driven card with a fixed-light sheen, tracking shadow, and press feedback. ### Notes - Core Motion roll and pitch are read at 60Hz relative to a neutral pose, so the card sits flat however the phone is held, then run through an exponential filter (15% per sample) that removes hand jitter without perceptible lag. The neutral pose starts where the phone is when updates begin and drifts toward the current pose over about ten seconds, so the card levels out again after the hand settles into a new grip. - The tilt follows the phone linearly up to 70% of `maxAngle` (about 25° of rotation), then meets rubber-band resistance that eases it toward `maxAngle` without ever reaching it, so the card slows into its limit instead of stopping dead. Nothing on the sensor path is animated, so the card stays with the hand. - Motion starts on appear and whenever `scenePhase` becomes `.active` while the card is on screen, and stops on disappear and in any other phase, so the sensors never run in the background or behind a pushed screen or another tab. They also stop when Reduce Motion turns on and start again, from flat, when it turns off. In the simulator the card is static with a fixed light. - Pass `attitude:` (-1...1 per axis) to drive the tilt yourself for previews, tests, or the simulator; it replaces device motion entirely. - Touch-down sinks the card to `pressScale` toward the finger, settles the tilt halfway to flat, tips the card down under the finger by up to 0.3 of `maxAngle` at an edge (3° by default) and tightens the shadow. The sheen and the shadow follow that lean like any other tilt. The press arrives on a tight spring with no overshoot and one light impact per touch-down, and lets go with a little give from the same lean. - A lift within 10pt runs `action` if one is set. Slide further and the card lets go on the quicker snap spring, with far less give than a lift, so it never lingers in a press that would not count; come back within 10pt and it presses again, without a second haptic. A touch the system cancels (a scroll or an alert taking over) lets go the same way. - Reduce Motion keeps the card flat: no device tilt, sink or lean. A press darkens the card slightly (lightens it in dark mode) and tightens its shadow instead, and lets go without overshoot. The haptic stays. - The motion source is a nested `@Observable` class marked `fileprivate`, because the macro's generated extension cannot see a `private` nested type. - **v2:** the card draws a solid block (`style.fill`, red by default) with dark ink, a soft-light sheen plus a faint far-side shade whose center moves opposite the tilt, as a reflection of a fixed light would, and a two-layer shadow that slides the same way and widens with the tilt. Pressing settles the tilt halfway to flat, tightens the shadow, and scales to `pressScale`. Disabled cards desaturate. - `style:` (`MotionCard.Style`) sets `fill`, `foreground`, `sheen`, `shadowOpacity`, and `pressScale`; `cornerRadius` now defaults to 26. - **v2.2:** `notchRadius` and `notchPosition` cut half-circle notches from both side edges, as on a ticket's tear line. They are real holes in the card: the page shows through, the sheen stops at their edge, the shadow follows them and they take no touches. `notchRadius: 0`, the default, cuts none. - **v2.3:** liquid glass. The card itself stays a solid block (it is content, not chrome), and everything you lay on it takes semibold type: the piece sets `.fontWeight(.semibold)` on its content. Caption chips on the card's face read as light liquid glass, as the example's "ADMIT ONE" and ticket number do: small capsules 12pt in from the edges and 28pt tall, so their ends are concentric with the 26pt corners. They tilt with the card (glass follows its small tilt cleanly), catch the same fixed light as its sheen, and keep the ticket's dark ink in either appearance. The card's shadow is cast from its outline alone, so glass on its face never takes that shadow as a dark halo. On iOS 26 they are Liquid Glass; before that a frosted material with a light rim, and a solid fill under Reduce Transparency. - The example is a film ticket with glass caption chips, semibold seat numerals and a dashed tear line between two 11pt notches at 64% of its height. The simulator has no attitude, so it drifts through a few poses on its own. - For a full card screen, see the Pro Card Details screen (`payment-card`). ### Parameters - `maxAngle`: Maximum tilt in degrees on each axis. - `cornerRadius`: Corner radius of the card surface (continuous). - `attitude`: Optional fixed tilt, -1...1 on each axis, that replaces device motion. Useful for previews, tests, and the simulator. - `style`: The card's fill, ink, sheen, shadow, and press. `.standard` is a red block with dark ink. - `action`: Optional tap handler. The card always scales on press; this runs on a clean tap. - `content`: Card content, laid over the card's own surface. It inherits `style.foreground` and semibold type. ## Parallax Card (`ParallaxCard`) URL: https://swiftpieces.com/docs/components/cards/parallax-card Category: Cards. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: ParallaxCard.swift Install: npx swiftpieces add ParallaxCard An image card whose picture drifts slower than the scroll while a liquid glass caption panel drifts slower still, so the card reads in layers. The trailing glyph is a glass bubble joined to the panel by a liquid neck; a press lifts the card with a deeper shadow and the bubble leans toward its arrow, thinning the neck. The caption is composed from eyebrow, title, metadata, and trailing glyph slots. Image card with two-layer scroll parallax, press-to-lift, and a slotted liquid glass caption. The caption is a glass panel inset over the bottom of the picture, and the trailing glyph a round glass bubble resting joined to it, so a liquid neck holds the two together as one surface. ### Notes - The drift is computed with `visualEffect` from `proxy.frame(in: .scrollView)`, so the card works inside any `ScrollView` or `List` without a named coordinate space. Outside a scroll view it renders static. - Three layers move at different rates: the image at `parallax`, the caption at a third of that, and the scrim scales from 1.3x tall at the viewport center to 0.9x at the edges, so the bottom reads tighter as the card leaves. The scrim follows the square of the card's position, so it eases through the center instead of turning there. All three are pure functions of the scroll position, with no animation, so they never lag the scroll. - The image is oversized by `height * parallax` on each side and clipped, so no background shows at the extremes. - The caption is liquid glass: an inset panel with corners concentric to the card's, and the trailing glyph on a round glass bubble that rests joined to the panel, so a liquid neck holds the two together as one surface. On iOS 26 it is Liquid Glass; before that a frosted material with a light rim, and a solid fill under Reduce Transparency. Text and glyph on the neutral glass follow the appearance: dark ink in light mode, near white in dark. The card's shadow is cast from its outline alone, so the glass never takes it as a dark halo over the picture. - Pass `action:` to make the card a `Button`; the press lifts it to 1.02 rather than sinking it and deepens the shadow, arriving on a tight spring with no overshoot under the finger and settling back with a little give. The trailing bubble leans 2pt toward its arrow a beat after the card lifts, on a slightly looser spring, so the neck to the panel thins as if the arrow were pulling it out, and it returns a beat behind the card. It is a plain `Button`, so the standard scroll delay and cancellation apply, and a scroll that takes over the touch settles the card the same way. No haptics. Without an action it keeps a resting shadow. - The caption is slots: `eyebrow` (uppercased, tracked), `title`, `metadata` (dot-separated row), and `trailingSymbol` (on the glass bubble). All text is semibold; size and opacity set the hierarchy. - Reduce Motion disables the drift, the scrim scaling and the bubble's lean; the composition is unchanged. A press keeps the card its size: the shadow still deepens, and the card darkens slightly (lightens in dark mode, where a shadow barely shows), then lets go without overshoot. - `ParallaxCard.Style.scrim` puts white text straight over the tightening scrim, for busy photos that should stay edge to edge; its trailing bubble is dark glass so the white arrow reads. - `style:` (`ParallaxCard.Style`) sets `caption`, `captionFill`, `captionInk`, and `shadowOpacity`; `cornerRadius` defaults to 26. `captionFill` is now the panel's glass tint: `.clear`, the default, keeps it neutral. Tint it only when the colour means something, and set `captionInk` to an ink that reads on it. The press state reaches the caption through the environment, so VoiceOver still reads the button label. - **v2.2:** the solid caption block became a liquid glass panel, and the trailing glyph's ink circle a glass bubble joined to it. `captionFill` now defaults to neutral glass and `captionInk` to ink that follows the appearance. - The example renders each picture as a flat composition (a color ground, a disc, a cropped giant word) with `ImageRenderer`; no photos ship with the piece. - For an expanding card on a full discover screen, see the Pro Discover screen (`hero-expand`). ### Parameters - `image`: The picture to show, scaled to fill. - `eyebrow`: Optional small uppercase line above the title, for a category or date. - `title`: Title drawn in the caption. - `metadata`: Optional short facts shown in a dot-separated row under the title. - `trailingSymbol`: Optional SF Symbol drawn in a glass bubble joined to the caption's trailing end, for example "arrow.up.right". - `height`: Card height. - `parallax`: How far the image drifts, as a fraction of `height` on each side. The caption drifts at a third of that. 0 disables the effect. - `cornerRadius`: Corner radius of the card. - `style`: Caption treatment and colors. `.standard` is a neutral glass panel whose ink follows the appearance; `.scrim` puts white text over a darkened bottom edge. - `action`: Optional tap handler. When set, the card lifts on press and reads as a button. ## Swipe Deck (`SwipeDeck`) URL: https://swiftpieces.com/docs/components/cards/swipe-deck Category: Cards. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: SwipeDeck.swift Install: npx swiftpieces add SwipeDeck A gesture-driven card stack: the top card follows the finger and swings from where you grabbed it, a tinted liquid glass outcome badge buds in through the card's edge, leaning in from the side you head toward, and stamps at the threshold, melting back out if you return, the cards beneath rise one after another as it leaves, and release past the threshold or a flick throws it left, right, or up at the speed of your finger. Swipeable card stack over Identifiable data. The deck removes the top item from `items` after each throw. ### Notes - The deck is the source of truth for nothing: it removes the top item from the bound `items` array after each throw and reports the direction through `onSwipe`. Append items to keep the stack going, or watch the array run empty. - Directions use SwiftUI's `Edge` so the file stays self-contained: `.trailing` (swiped right), `.leading` (left), `.top` (up). Down is never committed: a downward drag meets rubber-band resistance and springs back. - Commit is decided from `predictedEndTranslation`, so a flick throws even from a short drag. The horizontal threshold is 40% of the width, the upward one 30% of the height. A `.selection` tick fires the moment the drag crosses either, and a solid impact fires on the throw. - The card flies along its actual vector (drag plus velocity) for 1.6x the deck's larger dimension, leaving at its own speed on each axis, then is removed without a transition. A release short of the threshold returns home at that speed on the elastic settle spring, swinging a little past center before it rests, and a drag the system cancels (a parent scroll, a call) brings it home on the quicker snap spring. The card's speed is the finger's, except while it is pulled down against the rubber band: there it lets go at the band's slower pace, so a release still pulling down never lurches further down before it turns home. - The cards beneath rise with the drag, the second card first and each deeper one a little later along it, from 0.95 scale and 16pt lower per step. When a card is thrown, the cards beneath finish rising on follow-through springs, each a beat after the one above, so the next card settles into place with a little give. One more card than `visibleCount` waits invisibly at the back and fades up as the top card leaves, so nothing pops in. - Rotation is 14° per card width, pivoting on the edge opposite your finger: grab the top half and it swings from its base, grab the bottom half and it swings from its top. Catch the card on its way home and it keeps its pivot until the swing has landed, so it never jumps sideways. Reduce Motion drops the rotation, returns home without overshoot and fades the card out in place instead of throwing it, then steps the stack up on a short spring without overshoot. Badges fade in and out where they rest instead of budding, and keep their size instead of swelling and stamping. - `swipe:` is a command binding: set it to a direction to throw programmatically (from buttons, a keyboard shortcut, or a preview loop); the deck resets it to nil. VoiceOver gets "Swipe right", "Swipe left", and "Swipe up" actions on the top card. - Give the deck an explicit frame; cards are sized to fill it. - Outcome badges are tinted liquid glass. Once the top card is a fifth of the way to a threshold, the badge for that direction (Skip, Keep, Save by default) buds in through the card's edge on the liquid split spring: the card's edge hides it until it emerges, and its glyph and title blur in just after it leaves. Badges rest where they stay in view however far the card travels, even in a full-width deck: Skip and Keep at the top centre, budding in through the top edge from the side the card is heading for, and Save at the bottom centre, rising through the bottom edge the way the card is going, since the top goes off screen first. A badge swells a little toward the threshold under the finger, and stamps on with the success spring's give as the drag crosses it. Turn the card another way and it melts back out the way it came, with no bounce, its tint draining to clear glass, while the new direction's badge buds in past it; let the card come home and it melts away once the card is nearly there. A thrown card takes its badge with it, and the card that rises starts with none. The threshold tick never doubles up with a throw's impact. Cards further down sit in shade and brighten as they rise, and the top card's shadow deepens with the drag. - `style:` (`SwipeDeck.Style`) sets the three `SwipeDeck.Badge` values (title, symbol, `fill` as the glass tint, `ink`; nil hides one), `cornerRadius` (which also clips the badges to your card's outline), `shadowOpacity`, and `emptyMessage`, a dashed slot drawn when the deck runs out. `.plain` turns badges and the slot off. VoiceOver actions use the badge titles. All text is semibold. - **v2.2:** the solid badges that faded in over the card became tinted liquid glass that buds in through the card's edge and melts back out. Skip and Keep moved from the top corners to the top centre. ### Parameters - `items`: Bound array of cards, top first. Swiped items are removed from the front. - `swipe`: Optional command binding. Set it to `.leading`, `.trailing`, or `.top` to throw the top card programmatically; the deck resets it to nil. - `visibleCount`: How many cards are drawn in the stack. - `style`: Outcome badges, stack depth, and the empty slot. `.standard` shows Skip, Keep, and Save badges on tinted glass. - `onSwipe`: Called after a card is thrown, with the direction (`.leading`, `.trailing`, or `.top`) and the item. - `onTap`: Optional tap handler for the top card. - `card`: Builds one card. Give it its own shape and surface; the deck sizes it to fill the deck's frame. ## Wallet Stack (`WalletStack`) URL: https://swiftpieces.com/docs/components/cards/wallet-stack Category: Cards. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: WalletStack.swift Install: npx swiftpieces add WalletStack A wallet of tickets, boarding passes, memberships and loyalty cards as solid colour passes stacked so every header strip shows: tap one and it rises to the top and opens to its whole face with a real QR, Aztec, PDF417 or Code 128 code drawn by Core Image while the others slide down into a pile of narrowing slivers and a clear glass close bubble buds out of its corner, then drag it down with the finger's velocity, tap the pile, tap the bubble or press Escape to put it back; the strips draw closer when space runs short, the stack scrolls when there are many, and VoiceOver reads every pass, field and code. A wallet of passes and tickets: header strips stacked one over the next, and a tap brings one pass forward with its whole face and a scannable code while the rest tuck into a pile. Passes are content, solid blocks with dark ink, listed back to front: each header strip shows above the next pass and the last pass shows whole. Tap a pass and it rises to the top of the space it's given and opens to its code, drawn from `code` with Core Image, while the others slide down into a pile of slivers. A clear glass close bubble buds out of the pass's top corner. Drag the pass down (a flick is enough), tap the pile, tap the bubble or press Escape to put it back. When the stack is taller than its space the strips draw closer, then it scrolls. Give it the space the passes may use, such as a whole screen under a title or a sheet. It scrolls its own passes, so don't put it inside another vertical scroll view. ```swift @State private var presented: String? WalletStack(passes: passes, selection: $presented) .padding(.horizontal, 20) ``` ### Notes - Passes are content: solid house blocks with dark ink, listed back to front. The only glass is the close bubble, clear Liquid Glass in its own `PieceLiquidGroup`. On iOS 17 to 25 it is a frosted material; under Reduce Transparency, a solid fill. - Give it the space the passes may use, such as a screen under a title or a sheet, with your own horizontal padding. It scrolls its own passes, so don't put it inside another vertical `ScrollView`. The pile and the front pass carry on under the home indicator, the way a wallet runs off the bottom of the screen. - In the stack each pass shows exactly its own header strip (symbol tile, title, subtitle and the header field) and the last pass shows whole. When the passes don't fit, the strips draw closer by giving up their bottom padding, never their text; past that the stack scrolls. - Tap a pass, or set `selection`, and it rises to the top of the visible area and opens to its code while the others slide down into a pile of slivers, `Style.sliver` apart and a little narrower each, at most `pileDepth` deep. Passes keep their stacking order throughout: the ones in front slide off the chosen pass and the ones behind slip under it, so nothing ever jumps in front. Then the close bubble buds up out of the pass's top trailing corner and rests above it. - Put it back by dragging it down past 110 points or flicking it down: it follows the finger, the pile rises to meet it with a tick as the pull crosses the threshold, and it leaves at the finger's speed and sinks into its place as the passes in front rise over it. A shorter drag springs back. Tapping the pile, tapping the bubble, Escape on a hardware keyboard and the VoiceOver escape gesture all put it back too. The drag is a UIKit pan on the presented pass only, so it never fights the scroll view or a sheet, and sideways drags pass through. - Codes are generated on the device by Core Image, off the main actor: `.qr` (correction level M), `.aztec`, `.pdf417` and `.code128` (ASCII only; other text shows without bars). They sit on a white tile in both appearances, drawn pixel-crisp, with the code's text printed under them. A pass with `code: nil` opens without a code area. - `selection` is the presented pass's id. Set it from a deep link or a notification to present that pass; an id that isn't in `passes` shows the stack, and removing the presented pass puts the stack back. Changing it to another id swaps passes in one move. - Tints: `.sky`, `.butter`, `.sage`, `.lilac`, `.sand` and `.red`, the house signal, which is best kept for the one pass that matters most right now, such as tonight's ticket. Your own colours take `Tint(color, ink:)` with an ink that reads on them. - Long titles and subtitles truncate to one line in the stack and wrap to two once the pass is open. Fields share one row, spread across the pass like a boarding pass, and wrap onto more rows when they don't fit. - Haptics: a light impact when a pass is presented and when it goes back, and a selection tick each time a drag crosses the return threshold. - VoiceOver: each stacked pass is a button that reads its title, subtitle and header field ("Rail pass, Lisbon to Porto, Coach, 4") with the hint "Shows the whole pass". Presented, focus moves to the pass, which reads its title as a heading, then every field and the code; the other passes are hidden, the pile is a button "Other passes" and the bubble is "Done". On the way back focus returns to the pass. - Reduce Motion: passes move on a short ease with no overshoot, the code fades in place and the bubble appears and leaves where it rests. Increase Contrast draws the subtitles and field labels in full ink. - Dynamic Type: strips and fields grow with the text, and at accessibility sizes the header stacks its tile, titles and field. When an open pass is taller than the space, the stack scrolls so its code can be reached. Right to left mirrors the header, the fields and the bubble; codes are never mirrored. - `Style` sets the passes' corner radius, the code tile and its ink, the bubble's glyph, the sliver height and the pile's depth. `Messages` replaces the copy VoiceOver reads, such as `done: "Close"`. - Wire it up with your passes in state and the presented id beside them: ```swift @State private var passes: [WalletStack.Pass] = Trip.passes @State private var presented: String? WalletStack(passes: passes, selection: $presented) .padding(.horizontal, 20) .navigationTitle("Passes") .onOpenURL { url in presented = url.lastPathComponent } ``` ### Parameters - `passes`: The passes, back to front: the first sits at the top of the stack and the last shows whole. Ids must be unique. Make each one with ``WalletStack/Pass``, picking a house colour such as `.sky` or `.red` for its `tint`. - `selection`: The id of the presented pass, or `nil` for the stack. It changes as people present and put back passes; set it yourself to present one from a deep link or a notification. An id that isn't in `passes` shows the stack. - `messages`: The built-in copy: the close bubble, the pile and the hints VoiceOver reads. Defaults are localizable through your String Catalog; replace any line, for example `done` with "Close". - `style`: The passes' corner radius, the code tile's colours, the close bubble's glyph and the pile's slivers. Defaults to the SwiftPieces house palette. ## Commit Button (`CommitButton`) URL: https://swiftpieces.com/docs/components/controls/commit-button Category: Controls. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: CommitButton.swift Install: npx swiftpieces add CommitButton A liquid glass async action button driven by one idle/loading/success/error/disabled phase: the signal glass capsule draws in around its label as a glass bubble buds out of its end carrying a spinning ring; on success the ring closes on a check and both turn sage, then the bubble melts home as the label morphs to its success title; an error morphs the label to the message on butter glass, turns the bubble into a retry and shakes. Liquid glass async action button whose whole lifecycle is one bound `phase`. The capsule is glass, tinted for what it says: signal to act, sage once the work is done, butter on an error. While the work runs a glass bubble buds out of the capsule's end carrying the progress ring, joined to it by a liquid neck, and melts back in once the outcome has landed; on an error it stays out as the retry. The label morphs letter by letter from one message to the next. ### Notes - `phase` is a `Binding` so the button can return itself to `.idle` after the success hold (`successHold`, default 900ms); everything else is set by you: `.loading` in the action, `.success` or `.error("message")` when the work finishes. - The button is liquid glass. Idle, it is a signal-tinted glass capsule with dark ink; pressed it sinks and the glass darkens a touch. Loading draws the capsule in around its label and buds a round glass bubble out of its end, joined to it by a liquid neck, carrying a signal arc that winds up from rest. Success lets the arc coast to rest as it closes into a ring while the capsule and the bubble turn sage, draws the check and pops it on the success haptic; then, if you pass `successTitle` ("Saved"), the bubble melts back into the capsule's end as the capsule opens to full width and the label morphs letter by letter into the title, a check beside it. Without a title the check stays in its bubble until the hold ends. An error opens the capsule back out on butter glass, morphs the label into the message, turns the bubble into a butter retry and shakes with the error haptic on the same beat. Disabled is clear glass with muted ink. - A new `title` or `successTitle` reaches the label as soon as it changes, morphing letter by letter, so a checkout's "Pay $64.00" can follow its total. It never restarts the work, the outcome or the hold, and an error keeps showing its message until the next try. - The label gets the capsule's width and no more: the room you give the button, less the bubble while it is out. A label too long for one line there (a long error, an accessibility text size, a narrow screen) wraps onto a second line, shrinking a little if it must, and the capsule grows taller to hold it instead of wider. It never runs past the screen's edge, with `collapses` on or off. - Tapping in the error state, on the label or on the retry bubble, fires the action again (VoiceOver hears "Double tap to try again"). The button ignores taps while loading or succeeding without dimming. - `collapses: false` keeps the capsule full width while it works and makes room for the bubble at its end instead of drawing in around the label. - `tint` and `errorTint` still work: they override the style's glass tints and switch that state's ink to white. For full control pass a `Style` with your own tints, ink, height and font. `disabledFill` no longer does anything: a disabled button is clear glass. - Motion uses the shared SwiftPieces springs: the capsule's width, tint and label change together and land with a small give, the bubble leaves on the bud's split spring and goes home with no bounce, its tint draining as it melts in, and only the mark pops. Under Reduce Motion the press darkens instead of sinking, the bubble fades in and out in place, the arc holds still and breathes in opacity while it works, labels cross-fade, and there is no pop or shake; colors, the check, the message and every haptic stay. Reduce Transparency draws the glass solid. - Need a slide-to-act confirmation with an amount and recipient? SwiftPieces Pro has the Send Money screen (`swipe-to-confirm`). ### Parameters - `title`: Idle label. - `phase`: Bound `.idle`, `.loading`, `.success`, `.error(message)` or `.disabled`. The button returns itself to `.idle` after a success hold. - `tint`: Overrides the style's glass tint while idle and loading, with white ink. `nil` uses `style.fill`. - `tintInk`: The ink on `tint`, for a tint white doesn't read on (an app's accent and its ink). `nil` is white. - `errorTint`: Overrides the style's error tint, with white ink. `nil` uses `style.errorFill`. - `successHold`: How long the success state stays before the button returns to idle. - `successTitle`: Text the label morphs to on success, such as "Saved", with a check beside it. `nil` keeps the check in its bubble until the hold ends. - `collapses`: Draws the capsule in around its label while it works (the default), so the ring bubble buds out beside it. `false` keeps the capsule full width and makes room for the bubble at its end. - `style`: Tints, ink, height and type. `.standard` is the house palette: signal, sage for success, butter for errors. - `action`: Tap handler. Set `phase` to `.loading` inside it and to `.success` or `.error` when the work finishes. ## Elastic Button (`ElasticButton`) URL: https://swiftpieces.com/docs/components/controls/elastic-button Category: Controls. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: ElasticButton.swift Install: npx swiftpieces add ElasticButton A ButtonStyle that squashes toward the touch point, stretches along the drag with rubber-band resistance, deepens after a hold and springs back with as much give as the pull put in. Optional liquid glass surfaces (signal, block and clear raised glass) float on a soft lift that flattens under the finger, and the glass darkens as it is pressed. Squash-and-stretch press for any `Button`. Apply with `.buttonStyle(.elastic)`, or `.buttonStyle(.elastic(.signal))` to also draw the surface. ### Notes - It is a `ButtonStyle`, so it works on any `Button`: `.buttonStyle(.elastic)` adds only the motion, `.buttonStyle(.elastic(.signal))` also draws the surface, and `.elastic(squash:bounce:haptics:style:)` tunes both. - Drawn surfaces are liquid glass. `Style` presets come from the house palette: `.standard` (motion only, your label keeps its look), `.signal` (signal-tinted glass) for the one primary action, `.block(.lilac)` and the other blocks for glass tinted with that block and dark ink, `.raised` (clear glass, ink that follows the appearance) for quiet ones. Pass `fill: .clear` for clear glass of your own. `radius`, `height` (never below 44) and `padding` are adjustable. - States: at rest a drawn surface floats on a soft lift; pressed it squashes toward the finger, the glass darkens and the lift flattens; after a 350ms hold it presses 0.03 deeper and darkens further with a soft impact; a drag past 44pt cancels the press with a soft impact; disabled surfaces turn to clear glass with muted ink instead of fading. The glass doesn't swell under the press on its own: the squash is the press. - The touch point comes from a simultaneous zero-distance drag. The squash leans 60% of the way toward the finger and keeps that lean through the release, so the button re-expands from where it was pressed. Dragging stretches the shape along the pull and thins it across, keeping its area, with the same rubber-band resistance a scroll view has at its edge; the pull tracks the finger directly, never through an animation. - The release springs back past rest with more give the further the press was pulled: a tap gets half of `bounce`, a press pulled to the edge all of it. With the default 0.2, a tap lands on the house elastic spring (passing rest by about 8% of the squash) and a full pull on the expressive one (about 15%), so the button is never firmer than a plain chip's release. Dragging past 44pt cancels the press, and it lets go without overshoot, so tearing off reads differently from a tap. - Reduce Motion removes the squash, lean, stretch and offset, and every release settles without overshoot. A label with no surface dims while pressed instead; drawn surfaces keep their darken and the lift flattening. Reduce Transparency draws the glass solid. Disabled buttons play no haptics. ### Parameters - `squash`: Scale while pressed, anchored toward the touch point. Holding 350ms eases 0.03 deeper. - `bounce`: The release's extra give. A tap gets half of it and a press pulled to the edge all of it, so with the default 0.2 a tap lands on the house elastic spring and a full pull on the expressive one. - `haptics`: Plays a solid impact on press, a soft one when the hold deepens, and a soft one when a drag cancels. - `style`: The surface drawn behind the label. `.standard` draws nothing and only adds the motion; `.signal`, `.block(_:)` and `.raised` draw a liquid glass capsule (tinted signal, tinted with the block, or clear), a soft lift that flattens on press, and a darken under the finger. ## Fan Stack (`FanStack`) URL: https://swiftpieces.com/docs/components/controls/fan-stack Category: Controls. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: FanStack.swift Install: npx swiftpieces add FanStack Overlapping avatars in liquid glass bubbles that melt into one shape where they overlap, then fan apart on tap or hold through necks that stretch and snap, the hidden faces budding out of the +N bubble as it melts into them. A drag scrubs across them: the avatar under the finger lifts with its neighbours rolling up after it, a glass name tag buds out of it and a haptic ticks, and releasing selects it. Avatar group that expands into a selectable fan of liquid glass bubbles. Collapsed, the avatars overlap and their glass melts into one shape. Fanned, they rest apart, each its own bubble, so opening pulls them apart through necks that thin and snap. Avatars past `max` wait inside the "+N" bubble and bud out of it as it melts into them. ### Notes - Each avatar's face sits inset in a liquid glass bubble. Collapsed, the avatars overlap and their glass melts into one shape; the avatar above cuts a rim's width out of the face below it, so a ring of glass shows between them. - Tap toggles the fan; a 300ms hold fans it and keeps the touch live so you can scrub. The closed stack sinks under the finger on touch-down, so a hold answers before the fan opens. - Fanned, the avatars are separate choices and rest apart, each its own bubble, so opening pulls them apart through necks that thin and snap. Avatars past `max` wait inside the "+N" bubble, shrunk with their faces hidden; as the fan opens they bud out of it while the "+N" bubble travels out with the first of them and melts into it, its count blurring away. Folding reverses: the hidden avatars melt home into the "+N" bubble as it buds back out. - While fanned, the avatar under the finger lifts with a little give (14% larger, a 4pt rise) and the avatars either side rise a little after it on looser springs, so a scrub rolls along the hand instead of hopping. Its full name buds out of it in a clear glass tag that rests a neck's width above it, joined to it, and melts back in when the finger moves on; its first name below turns to the stronger ink, and `.selection` ticks per crossing. The lift is height only, never sideways, so the avatar that looks lifted is the one a release picks. - Releasing on an avatar calls `onSelect` at once with a solid impact; the avatar drops back into the hand and the fold follows 100ms later, so the choice reads before the stack closes. Releasing a hold on empty space leaves the fan open; a tap on empty space closes it. A touch the system cancels (a scroll takes over) lets go without picking. - Initials sit on solid house blocks with dark ink, picked by a stable FNV-1a hash of the name. `Style` sets the block list, ink, the "+N" text, the captions and the tag's text. `ring`, `overflowFill` and `tagFill` no longer do anything: the cut, the "+N" bubble and the tag are glass. - Every name is in the hierarchy at all times. Opening staggers 35ms per avatar: the shown avatars spring apart on an elastic spring, the fan's signature give, firmed up so none swings into its neighbour's merge distance; the hidden ones bud out on the bud's split spring. Closing reverses, last avatar first, the shown ones on a quick spring with no overshoot and the hidden ones melting home with no bounce. Larger groups stagger tighter so the whole cascade stays within 200ms, and the frame moves halfway through it (with a small give opening, firm closing), so the layout around the stack moves with the avatars rather than ahead of them. First names arrive once their avatar has nearly landed. - Fanned, the avatars sit 26pt apart, so six at the default 44pt take about 394pt: give a big group the room, or a smaller `size`. The tag draws about 40pt above the avatars and first names 24pt below while fanned (the frame grows for the names only), so leave room above the stack. The tag is centred over its avatar, so on an end avatar a long name reaches past the stack's end by up to half its width: leave room beside the stack too. - `images` is a parallel array; it may be shorter than `names`, and `nil` entries fall back to initials. - Haptics: a soft impact when a touch or VoiceOver fans or closes the stack, a solid impact on its own for a pick, and nothing when the `isFanned` binding moves it. - Reduce Motion removes the stagger, the pause before a pick's fold, the scaling and the lift: avatars move to their places on short eases, the hidden ones and the "+N" bubble fade in place, the pressed stack shades instead of sinking (darker in light mode, lighter in dark), the hovered avatar shows a ring, and the name tag fades in. Haptics are unchanged. Reduce Transparency draws the glass solid. VoiceOver reads the collapsed stack as one element ("Priya Raman, Jonas Weber, and 3 more", double tap to expand) and each avatar as its own button once fanned. ### Parameters - `names`: Full names, in display order. First name is drawn on top while collapsed. - `images`: Optional parallel array of images; `nil` entries fall back to initials on a block color. - `size`: Diameter of each avatar in points. - `max`: Avatars shown while collapsed before the rest fold into "+N". All fan open. - `overlap`: Fraction of `size` each avatar overlaps the previous one while collapsed, 0–1. - `onSelect`: Called with the name released on while fanned. Also used by VoiceOver's per-avatar action. - `isFanned`: Optional binding to fan or collapse programmatically; written back when the user toggles it. - `style`: Block colors, ink and the name tag's text. `.standard` is the house palette. ## Hold To Confirm (`HoldToConfirm`) URL: https://swiftpieces.com/docs/components/controls/hold-to-confirm Category: Controls. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: HoldToConfirm.swift Install: npx swiftpieces add HoldToConfirm A press-and-hold liquid glass capsule for destructive or important actions: the signal tint wells out of the leading puck while held and sweeps through the glass, passing three milestone dots with rising haptics and inverting the label under it, drains back into the puck on an early release, and on completion turns sage, morphs its label and buds a sage check bubble out of its end. Hold to confirm, in liquid glass. The tint sweeps through the glass from the leading puck and the label inverts as it passes; letting go or dragging off drains it back. Completing turns the capsule sage and buds a check bubble out of its end, joined to it by a liquid neck. ### Notes - The control is a clear liquid glass capsule with a tinted puck at its leading end holding the symbol. At touch-down the tint wells out of the puck to the capsule's height, then sweeps through the glass at a steady speed while held, driven by a `TimelineView`, so the edge always shows the real time left. An ink copy of the label, puck and milestone dots is cut to the fill, so all of them invert exactly under its edge. - The title sits centred on the capsule, balanced against the puck, when it fits there. A longer one centres in all the room after the puck, and one too long for that (an accessibility text size, a narrow screen) wraps onto a second line, shrinking a little if it must, while the capsule grows taller to hold it. It never runs past the capsule, and the fill, the puck and the dots follow the taller capsule. - Three milestone dots sit at 25, 50 and 75%. Each swells like a bubble with a little elastic give as the fill passes it, in step with a soft impact of rising intensity. - At 100% the channel clicks shut: `.success`, a 2% swell and the action land on the same beat, and the action never waits for the motion. The fill turns `committedFill` (sage), and a beat later the capsule draws its end in as a sage glass check bubble buds out of it, joined by a liquid neck, while the label morphs letter by letter into `committedTitle` if you pass one ("Deleted"), or lets go if you don't. After 1.4s the check melts back into the capsule's end, the confirmed block fades out over the glass in its own color and the label morphs back. - Releasing early, dragging more than 16pt off the capsule or a touch the system cancels (a scroll taking over, an alert) cancels the hold, and a finger dragged off has to lift before it can start another. The fill carries on for an instant at the speed it was moving, then drains back to the puck without overshoot, each dot losing its ink as the edge passes back over it, and the last of the fill sinks back into the puck. Holding again mid-drain starts a fresh hold: the fill keeps sinking until the new sweep overtakes it, so it never snaps back, and the dots it still covers keep their swell. - `Style.standard` sweeps signal for destructive actions; `Style.butter` is a calmer confirm. `tint` still works and overrides the fill with white ink. `track` no longer does anything: the capsule is clear glass. Disabled (`.disabled(true)`) desaturates the control and blocks the gesture. - Phases: `idle`, `holding`, `committed`, `cancelled`, `disabled`. `cancelled` lasts until the fill has drained, about half a second. VoiceOver reads a button with the hint "Touch and hold to confirm"; its activate action performs the action directly, and the fill sweeps full to show it. - Reduce Motion halves `duration`. It drops the press, the click swell and the dot swell, fades the fill in and out over the puck instead of welling it out, drains on a short spring, fades the check bubble in place and cross-fades the label. The fill still sweeps, the colors still change and every haptic still plays. Reduce Transparency draws the glass solid. - Want slide to act instead of hold? SwiftPieces Pro has the Send Money screen (`swipe-to-confirm`). ### Parameters - `title`: Label while idle and holding. - `systemImage`: Optional SF Symbol drawn in the leading puck. - `duration`: Seconds the hold must last. Halved under Reduce Motion. - `tint`: Overrides the style's fill, with white ink on it. `nil` uses `style`. - `phase`: Optional binding that mirrors `idle`, `holding`, `committed` and `cancelled`; set it to `.holding` to start a sweep programmatically, `.idle` to cancel one. - `committedTitle`: Text the label morphs to once the hold completes, such as "Deleted". `nil` lets the label go and leaves the check to speak. - `style`: Label, fill, ink and committed colors plus height. `.standard` is the house palette with a signal fill. - `action`: Called once when the hold completes. VoiceOver's activate action calls it directly. ## Timer Dial (`TimerDial`) URL: https://swiftpieces.com/docs/components/controls/timer-dial Category: Controls. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: TimerDial.swift Install: npx swiftpieces add TimerDial A countdown ring you set by dragging a liquid glass knob around it like the Timer dial, with a tick per step and a heavier detent every 5s. A solid ring over a minute-tick scale, semibold display numerals, a breathing signal warning zone, a sage finish, optional glass start, pause and reset bubbles that bud out of each other, and a determinate progress mode. Settable countdown ring, or a determinate progress ring with the same look. The ring and its ticks are content; the knob riding the ring is a liquid glass bubble, and `style.showsControls` adds start, pause and reset as glass bubbles under the dial: reset buds out of the start bubble while there is something to reset. ### Notes - While idle or paused, dragging around the ring sets `seconds` like a kitchen timer: the angle maps to `0...maxSeconds` in `step` increments. The knob rides under your finger and the ring follows it, with no lag; the value, the digits and the lit ticks move in whole steps. Let go and the knob clicks into its detent on a short spring from rest, so it never swings past the value shown, and seats with a small pop. Touching anywhere else on the ring moves the knob straight there. Touching the dial while paused starts the countdown over from the dialed length. - The knob is a clear liquid glass bubble riding the ring, so the ring's color shows through it. It is larger while the dial can be set, and lifts while held: a little larger again, with a deeper shadow. It drops back with an elastic give and a small pop when you let go, while the ring and the value land firm. It stays on the ring throughout, at 12 o'clock once a countdown is done or a progress ring is empty. - The value stops at one `step` and at a full turn. Past either end the knob gives with rubber-band resistance, never more than about its own width, while the value stays at the end; let go and it springs back at the speed it was moving, on the snap spring rather than an elastic one, since the ring is the time: it passes the end back into the range by under a point when let go at rest, and by a few points at most when flicked back. Drag on more than half a turn past an end and the knob comes round to your finger, taking the value there. Crossing 12 o'clock never wraps the value from one end to the other, and touching the knob at the top of a full dial grabs it there rather than emptying the dial. - Haptics: `.selection` per step, throttled so a fast spin over 1s steps clicks instead of buzzing, and a rigid impact on every multiple of 5 and at either end. VoiceOver adjusts it with swipe up/down; those changes, a new `seconds` from your code and a new `progress` value all land on the same short spring, with no overshoot to misread, unless you animate the change yourself. Under your finger the ring and knob never animate, even inside an animated transaction of yours. - One face throughout: the ring shows the time left against `maxSeconds`, so it winds to the set time and drains back to 12 o'clock from there. A `seconds` longer than `maxSeconds`, set from your code, is measured against itself instead, so that countdown still drains from a full ring; dialing clamps to `maxSeconds`. The look comes from `Style.standard`: a solid signal ring over a raised track, a clear glass knob, a 60-tick scale inside that brightens under the value, and semibold display numerals reading `m:ss` with the leading `0:` dimmed. `Style.sky` swaps the ring; `colors` still overrides it (several colors draw an angular gradient). `knob` no longer does anything: the knob is glass. - Timing is anchored to a start date and rendered by `TimelineView`, so the ring never drifts; a single `.task` sleeps until the finish. Toggling `isRunning` off banks the elapsed time; on resumes. After finishing, `true` restarts from `seconds`. Changing `seconds` resets. - Digits roll toward the new value, up when time is added and down as it counts, without rolling past it, and change at once under your finger. The caption (Ready, Remaining, Paused, Done) morphs letter by letter from one phase to the next. - `Style(showsControls: true)` adds start, pause and reset under the countdown dial as liquid glass bubbles. Start is glass tinted like the ring, its glyph swapping between play and pause as it toggles `isRunning`. Reset buds out of it to rest beside it, apart, while the dial is running, paused or done, the pair keeping centred, and melts back into it once the dial is ready again; it stops the countdown and sets the dial back to `seconds`. Off by default, so a dial with your own controls looks as it did. - At or below `warningAt` the signal ring fades in over the base ring and the digits turn signal. The dial beats once a second, up to 1.2%: each beat peaks as the digits change, with a soft impact, and the beat fades in and out instead of popping, including when you pause. Finishing springs a full sage ring in under the knob and plays `.success`. - `TimerDial(progress:)` is the determinate mode: the same ring and knob with a percent readout (dimmed %) and no clock or dial. Turn `showsTicks` off and lower `digitSize` for small rings. - Digits scale with Dynamic Type relative to `.largeTitle` and never outgrow the ring. Reduce Motion keeps the knob tracking your finger but stops it firm at the ends, drops the lift scale and the landing pop (the shadow still deepens), lands without overshoot, fades the reset bubble in and out in place, crossfades the digits and the caption, ticks the ring once per second and skips the finish scale. In the warning zone the beat becomes a brief brightening of the ring each second, and the soft impact still plays. Reduce Transparency draws the glass solid. - Need a whole focus session with task binding and breaks? SwiftPieces Pro has the Focus screen (`focus-timer`). ### Parameters - `seconds`: Bound countdown length. Dragging around the ring while idle or paused rewrites it. - `isRunning`: Set `true` to run, `false` to pause. Flips to `false` when the countdown finishes; set it back to `true` to restart. - `step`: Seconds per dial detent. Every multiple of 5 plays a heavier tick. - `maxSeconds`: Value at a full turn of the dial. - `warningAt`: Remaining seconds at or below which the ring turns to `style.warning` and breathes. - `lineWidth`: Ring thickness in points. - `colors`: Overrides `style.fill`: one color for a flat ring, or several for an angular gradient. `nil` uses the style. - `caption`: Text under the digits. Defaults to the phase (Ready, Remaining, Paused, Done). - `style`: Ring, track, knob, tick, warning and finish colors plus the numeral size. `.standard` is the house palette with a tangerine ring. - `onFinish`: Called once when the ring reaches zero. - `progress`: Determinate mode: a 0–1 value with a percent readout and no clock. ## Activity Heatmap (`ActivityHeatmap`) URL: https://swiftpieces.com/docs/components/data/activity-heatmap Category: Data. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: ActivityHeatmap.swift Install: npx swiftpieces add ActivityHeatmap A liquid glass streak calendar for habit, fitness, study and journaling apps: weeks of days drawn in one Canvas as rounded cells in solid steps from a pale red mix up to pure red, laid out by the locale's first weekday with month labels on their week columns, a semibold streak numeral under a glass streak chip that stays alive until midnight beside the longest run and active days, a scrub that lifts the day under your finger as a tinted glass tile with a glass callout budding out of it on a liquid neck and a haptic tick per day, a glass legend chip, sideways scrolling that opens on today when the weeks outgrow the width, and VoiceOver that steps day by day or plays the weeks as an audio graph. A calendar heatmap of daily activity with the current streak, a scrub callout and full VoiceOver support. Columns are weeks, oldest leading and newest trailing, and rows are weekdays in the calendar's order, so Monday-first regions get Monday on top. Each day is one rounded cell: level 0 is an empty field and levels 1 to 4 are solid steps of the accent mixed into the ground, up to the pure accent. Drag sideways across the grid, or press and move, to read any day, and tap to select one; vertical swipes still scroll the screen. When the weeks outgrow the width the grid scrolls sideways and a short hold starts the reading instead. The cells are drawn in one `Canvas` and hit-tested by geometry, so a year of days stays light. The cells are content; the chrome around them is liquid glass. The streak label and the legend sit on glass chips, and a selected day lifts out of the grid as a glass tile tinted with its shade, with its callout budding out of it and resting joined to it by a liquid neck. The callout melts back into the tile when the day is let go. ### Notes - Pass activity keyed by day as `[Date: Double]` or `[Date: Int]`. Each key lands on the day it falls on in the calendar's time zone, so exact timestamps work and several entries on one day add up. Zero, negative and non-finite values count as no activity. If you store days as midnight UTC, pass a calendar whose time zone is UTC. - Columns are weeks, oldest leading and newest trailing, and rows are weekdays in the calendar's order: the person's own first weekday by default, so Monday-first regions get Monday on top. Days are stepped from noon, so daylight saving changes and leap days never skip or double a cell. Month labels sit above the first week of each month, and the weekday labels mark Monday, Wednesday and Friday on whichever rows they fall. - Level 0 is the empty field. Levels 1 to 4 are solid steps of the accent mixed into the ground (pale on light, deep on dark, further apart with Increase Contrast), up to the pure accent, never transparency. `.automatic` splits the non-zero days in view into quartiles, so one huge day does not wash out the rest and the busiest day always takes the top shade. `.thresholds([1, 3, 6, 10])` sets the minimum for each level; fewer than four spread over the shades, so `.thresholds([1])` draws a done or not done habit in the full accent. - The current streak counts back from today and stays alive until midnight while today has nothing yet. It looks back through all of your data, so a streak that began before the first visible week is counted in full; the longest streak and the active days cover the visible weeks. With `.thresholds`, the first value is what counts as active. `ActivityHeatmap.summary(of:)` returns the same figures for a widget or a reminder. - Drag sideways across the grid, or press and move, and the day under the finger is plucked up out of the still grid as a glass tile tinted with its shade, with a selection tick for each new day. It springs up with visible give, and its callout (date, value and its shade, on glass) buds out of it: born inside the day, it springs out to rest just above it (or below, near the top), joined to the day by a liquid neck that points it at its day. Both glide from day to day on a spring that never bounces or trails the finger, while the callout's text changes at once, so a fast scrub never ghosts. Let go, the callout's text blurs off and it melts back into the day, which settles onto the grid and leaves. Vertical swipes that start on the grid still scroll the screen or move a sheet. Tap a day to select it and tap it again to clear it. - With a `selection` binding, a scrub moves the selection live and release keeps it; setting it from outside selects that day and scrolls it into view. Without a binding, a scrub shows the callout only while the finger is down, and a tap keeps it. - Cells grow to fill the width up to `Style.maximumCellSize`. When the weeks would make them smaller than `Style.minimumCellSize`, the grid scrolls sideways, opens on today and comes to rest on whole weeks; sideways swipes scroll it and a quarter-second hold starts reading instead, with a soft tap in place of the first day's tick. - Today wears a ring in the text color, and days after the end date are left out. `endDate` shows an earlier range; `nil` (the default) follows today and moves on by itself at midnight or when the time zone changes. - The current streak's label sits on a glass chip, its flame lit in the accent while the streak is alive (lit and unlit blur across), and the legend sits on a glass chip under the grid. Every string and symbol is semibold; size sets the hierarchy. - On first appearance the empty tiles are there at once and the shades grow into them in a wave, oldest week to newest, stopping at their tile's size so none ever reads larger; today's ring is there from the start. A whole new dataset (more than 21 days changed) grows in the same way. A day whose value changes is kicked up off its tile and settles with a little give while its neighbors stay still, and the streak numeral and figures roll to their new values. Under Reduce Motion the shades are simply there, a changed day is marked by a faint ring just inside its tile that fades instead of the pop (so it shows on today too), the lifted day sits at a smaller lift without springing and moves from day to day at once, the callout fades in and out in place instead of budding, the numerals crossfade, and a selection set from outside jumps into view instead of scrolling. - VoiceOver reads the heatmap as one adjustable element: "Activity, last 20 weeks. Current streak 12 days. Longest 31 days. Active 86 days." Swipe up or down to move a day (the value reads "Tuesday, September 30, 45 min"), use the Previous week and Next week actions, and the escape gesture clears the selection. It also offers the days as an audio graph. With a hardware keyboard, the up and down arrows move a day and left and right move a week. - While your data loads, apply `.redacted(reason: .placeholder)`: the grid keeps its shape in empty cells and the figures redact. With no activity in view the summary reads "No activity yet". - Dynamic Type scales the streak, the figures and the labels; the summary stacks at large sizes and the grid labels stop growing before they crowd the rows, while cells keep their size. Right to left, the weeks run from the right, newest on the left. - `Style` sets the accent, the ground the shades are mixed into (set it to the surface the heatmap sits on), the empty, text, secondary and callout colors, the cell radius and spacing, the smallest and largest cell, and the numeral size. `callout` tints the callout's glass (`.clear`, the default, leaves it neutral) and `calloutText` colors its text (the text color by default). Under Reduce Transparency the glass draws solid. `Style.fill(level:)` gives the same shades for your own day details. `Messages` replaces any line of built-in copy, and `valueLabel` names your unit, for example minutes formatted as a `Measurement`. ### Parameters - `counts`: Activity per day, such as sessions, minutes or entries. Each key is placed on the day it falls on in `calendar` (its time zone), so exact timestamps work and several on one day add up. Zero, negative and non-finite values count as no activity. Days outside the visible weeks are ignored, except that a current streak which began earlier is counted in full. `[Date: Int]` works too. - `weeks`: How many week columns to show. Defaults to 20. Cells grow to fill the width up to `Style.maximumCellSize`; when they would fall below `Style.minimumCellSize` the grid scrolls sideways and opens on the newest week. - `endDate`: The last day shown; later days in its week are left out. `nil` (the default) is today, and the grid moves on by itself at midnight. - `calendar`: The calendar for days, weeks and the first weekday. `nil` (the default) uses the environment's calendar, which is the person's own. - `levels`: How values map to the four shades. `.automatic` splits the non-zero days in view into quartiles. `.thresholds([1, 3, 6, 10])` sets the minimum for each level, and its first value is also what counts as an active day for streaks. - `showsSummary`: Shows the current streak, the longest streak and the number of active days above the grid. - `selection`: The selected day, as the start of that day. Tapping a day selects it and tapping it again clears it; with a binding, a scrub moves it live and release keeps it. Without a binding, a scrub shows the callout only while the finger is down. Setting it from outside selects that day and scrolls it into view. - `messages`: Built-in copy. Defaults are localizable through your String Catalog; replace any line, for example `title` with "Workouts". - `style`: Colors and cell metrics. Defaults to the SwiftPieces house palette with a red accent, adapting to light and dark. - `valueLabel`: How a day's value reads in the callout and to VoiceOver, for example minutes formatted as a `Measurement`. Defaults to the plain number. Days with no activity read `messages.noActivity`. ## Live Stat (`LiveStat`) URL: https://swiftpieces.com/docs/components/data/live-stat Category: Data. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: LiveStat.swift Install: npx swiftpieces add LiveStat A liquid glass metric tile: a semibold display value that rolls with a spring and dims its decimals, a tinted delta chip, a sparkline you hold and scrub to retarget the value live while a glass flag buds out of the tile's top edge above your finger with the change from that point to the latest, a press that expands the line into a taller chart with low and high, and a tinted block variant. KPI tile with a rolling value, delta chip, and a scrubbable sparkline that expands on press. The tile is a liquid glass panel. While you scrub, a glass flag buds out of the panel's top edge above the point under your finger, joined to the panel by a neck, and shows the change from that point to the latest one. It rides along the edge with the finger and melts back into the panel on release. ### Notes - The tile is a liquid glass panel: neutral glass by default, tinted with the block's color for `.block(_:)`. The value is an `Animatable` text in semibold 40 pt display type with dimmed decimals. A spring with no bounce interpolates real numbers through your `format`, so a roll from rest never shows a value past the one it is heading to. In-flight frames round to the last place the target shows, measured in the format's own terms, so a whole-number format never flashes a long fraction mid-roll and a `.percent` heading for 3.2% passes 1.6% on the way; a `.scale` or compact notation rolls the same way. At rest the figure is exactly what your format prints. - On first appearance the value counts up as the line draws on, and the dot drops onto the tip once the line has reached it. - The tile is a weighted card: it dips about 2.5 pt per edge toward your thumb and its shadow flattens. Held for 280 ms anywhere on a tile with a line, it keeps sinking a point deeper while the hold arms, then springs back up as the dot catches the line at the point nearest your finger with a pop and a soft tap. While you scrub, a rule and the lifted dot sit on the nearest point at every frame, the value retargets with a short roll, and `.selection` ticks at each point after the first. A glass flag buds out of the panel's top edge right above the point, out of the finger's way, and rests a neck's width above it, joined by a liquid neck: it shows the change from that point to the latest one, tinted sage for a rise and red for a fall, and rides along the edge with the finger without easing after it. Release rolls back to the live value, the flag's tint drains as it melts back into the panel, and the dot grows back on the latest point with visible give. The flag reaches about 32 pt above the tile, over whatever sits there, for as long as you scrub. - A plain tap opens the tile like a drawer: the same line grows into a 120 pt chart, with its dot and a dashed baseline at the first value riding the new scale, the baseline fading in a beat behind. The card's edge uncovers a low and high row as it opens, and closing is a little quicker. Tiles around it make room on the same spring. Moving more than 10 pt before the hold completes cancels both, so the tile can live in a scrolling grid, and a touch the system cancels lets go without expanding. - The delta is a tinted chip with ink on the panel: sage for up, red for down. It is a fill rather than a glass shape of its own, so the tile reads as one surface. It dims while you scrub, while the flag speaks for the point. Under Increase Contrast it gains a 1 pt outline. - The tile holds up at half a phone's width and at large text sizes. When the label and the chip don't fit side by side, the chip moves under the label and a long label wraps instead of truncating; the chip always stays on one line. A figure too wide for the tile shrinks to fit rather than losing digits, to the same smallest size at every Dynamic Type size, and in a narrow tile the expanded low and high row stacks into a column. - `Style`: `.standard` is a neutral glass tile (its `background` is `.clear`); `.block(_:)` tints the glass with a color block and turns the value, line, and dot to ink, with the delta and a moving flag as ink pills. `tint` overrides the sparkline color. Every string and symbol is semibold; size sets the hierarchy. - Under Reduce Motion the press dims the tile and flattens its shadow without scaling or sinking, the value and line appear without the count-up and draw-on, each roll shortens to a quarter second, the dot and rule fade instead of scaling, the flag fades in and out in place above the tile instead of budding, and the drawer opens and closes in about 0.2 s with fades. Under Reduce Transparency the glass draws solid. Haptics are unchanged. VoiceOver reads label and value, double-tap expands, and swipe up or down scrubs history; swiping up past the latest point returns to the live value, which then follows new values again. - A full dashboard screen ships in SwiftPieces Pro (`dashboard-screen`). ### Parameters - `label`: Metric name shown above the value. - `value`: Current value. Changing it rolls the number from the previous one. - `format`: Number format for the value, for example `.number`, `.percent` or `.currency(code: "USD")`. Defaults to `.number`. - `delta`: Optional fractional change (0.12 is +12%) shown as a tinted chip on the panel: sage for up, red for down. - `series`: History drawn as the sparkline, oldest first. Hold and drag it to scrub. - `tint`: Sparkline color. Defaults to the style's `line`. - `expanded`: Optional binding to the expanded state, for driving it from outside. - `style`: Tile, text, and chip colors. Defaults to `.standard`, a neutral glass tile; `.block(_:)` tints the glass with a color block and dark ink. ## Odometer (`Odometer`) URL: https://swiftpieces.com/docs/components/data/odometer Category: Data. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: Odometer.swift Install: npx swiftpieces add Odometer A number whose digits roll on individual slots like a mechanical counter, carrying from the low digits up so unchanged digits never move, with softened slot edges while rolling, dimmed decimals, a separate sign, a dashed loading state, and a tinted liquid glass delta chip that buds out from behind the figure after each change, rests joined beside it and melts back in. 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 `Animatable` number 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 `ticksOnSettle` gives one `.selection` tick 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.2K` will 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. - `Style` sets `up` and `down` (the chip's glass tints), `ink`, `muted`, `dimsFraction`, and `tintsWhileRolling` (tints the digits from the start of a roll until it lands, switching without a fade; best on dark grounds). - `isLoading` swaps 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 `.font` to the `Odometer` to 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`). ### 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. ## Range Lens (`RangeLens`) URL: https://swiftpieces.com/docs/components/data/range-lens Category: Data. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: RangeLens.swift Install: npx swiftpieces add RangeLens An overview and detail chart for long series like a year of daily steps, revenue, sleep or prices: a semibold readout of the window's average (or total, highest or lowest) with its dates, a detail chart of the window's bars that pans and rescales without overshooting under a red dashed average rule, and a strip of the whole series underneath with a liquid glass lens you drag to move and pull by its joined glass handles to resize, which swell and flood red while held, settle with the flick, rubber-band at the ends, tick at each week and never invert, plus tap to centre, double-tap to show everything, keyboard arrows and VoiceOver that steps the lens a week at a time. Overview and detail for a long series: a chart of the window you are reading, and a strip of the whole series under it with a glass lens you drag and resize to choose that window. The readout names the window's summary (the average by default) in a large numeral over its dates. The detail chart draws the window's samples as bars in the text color, with the window's average as a red dashed rule and an "avg" chip on the value axis; it pans with the lens and rescales without overshooting. The strip draws every sample in one `Canvas`, bright inside the window and dim outside, over the lens: a liquid glass slab with a glass handle joined to each end. Drag the slab to move the window, drag a handle (or within about 22 points of an edge) to resize it, tap the strip to centre the lens there, and double-tap to show everything and again to go back. Vertical swipes that start on the strip still scroll the screen or move a sheet. The bars are content; the lens is the control, so it is the only glass. ### Notes - Pass the whole series as `[RangeLens.Sample]` (a date and a value each) and bind the window as a `ClosedRange`, from the date of its first sample to the date of its last. Keep both in your own state; `RangeLens.window(last: 42, of: days)` gives a good opening window, the last six weeks of daily samples. Samples are sorted by date for you; negative and non-finite values draw as zero. - The readout names the window's `summary` (`.average`, the default, or `.total`, `.maximum`, `.minimum`) in a large numeral, with your `unit` beside it and the window's dates under it ("3 Mar – 14 Apr", with the year when the window crosses one or lies in an earlier year). `valueLabel` formats the numeral and what VoiceOver reads, for example a currency or minutes as a `Measurement`. - The detail chart draws the window's samples as bars in the text color. Its ceiling follows the window's highest sample with a little headroom, so the bars always fill it; when the window changes, the ceiling moves on the value role and never overshoots, and the gridlines, on round values at most four, slide with it. The window's average is a red dashed rule with an "avg" chip on the value axis (`showsAverage: false` hides it); the rule follows the bars while they move. - The strip draws every sample on one scale in one `Canvas`, on a quiet track: dim outside the window, bright inside it. The lens is the only glass: a slab over the window with a narrow glass handle joined to each end by a liquid neck, all in one `PieceLiquidGroup`. Bars outside the window lie under the glass; the window's bars sit on the slab, crisp. Month marks run under the strip (years for long series, weeks or days for short ones), thinned evenly so they never crowd, and January names its year. - Drag the slab to move the window: it follows the finger, swells a little while held, and past either end of the series gives with rubber-band resistance. Drag a handle, or within about 22 points of an edge, to resize: the handle swells and floods red while held, stops firmly at `minimumSpan` samples (a week by default) and never inverts. Let go and the lens coasts a little with the flick and lands on whole samples. A drag that starts off the lens brings the lens to the finger first. - Tap the strip beside the lens to centre the lens there. Double-tap to show the whole series, and double-tap again to go back to the window before it. A press held still for a moment also takes hold, for a careful move. - The selection is written live while the lens moves, whenever the whole samples it rests on change, and once more when it lands. Set the selection yourself and the lens glides there; a window wider than the series shows all of it. - Horizontal drags and held presses belong to the lens; vertical swipes that start on the strip still scroll the screen or move a sheet, since the strip's recognizers only begin for a mostly horizontal drag. - Haptics: a selection tick when the moving edge crosses into another week (another month for weekly samples, a year for monthly ones), a light tap when a handle is grabbed and on Show all, and a soft bump when the lens meets an end of the series or its minimum span. Never per frame. - States: an empty series shows `messages.empty` in place of the numeral, a baseline and the bare track, with no lens; one sample shows that sample; all zeros draw a flat strip and a zero axis. While your data loads, apply `.redacted(reason: .placeholder)`: the bars draw as quiet placeholders and the text redacts. Hundreds of samples stay smooth: both charts are single `Canvas` passes and the series is sorted and summed once per change, never on a drag step. - VoiceOver reads the readout ("Average 8,412 steps a day, 3 March to 14 April"), then the detail chart, which also plays the window's samples as an audio graph, then the lens: an adjustable element named "Range" whose value is the dates and the summary. Swipe up or down to move it a step (a week of daily samples); the Widen, Narrow and Show all (then Back to previous range) actions change its size and announce the result. - Hardware keyboard: with the strip focused, the left and right arrows move the window a step, up and down widen and narrow it, and Space toggles Show all. - Reduce Motion: the lens lands and resizes on a short spring with no overshoot and doesn't swell, the detail cross-fades to a new scale instead of growing, and the numeral and dates cross-fade instead of rolling. Under the finger the numeral and dates change in place in every mode, so they stay legible. - Dynamic Type scales the readout, the axis values and the marks (the axis and marks stop growing before they crowd the charts); the numeral and unit stack when they no longer fit on one line. Right to left, time runs from the right: the latest samples, the lens and the value axis sit on the left. All text is one weight, semibold. - `Style` sets the accent (the average rule, its chip and a held handle) and the ink on it, the text, secondary and axis colors, the dim color of the bars outside the window (a solid mix into the ground, never transparency), the gridlines, the track, the detail chart's height and the numeral size. Under Increase Contrast the bars outside the window take the secondary color. `Messages` replaces any line of built-in copy, such as `average: "Daily average"`. ### Parameters - `samples`: The series, one value per date: daily steps, revenue, hours slept, a price. Sorted by date for you. Values that are negative or not finite draw as zero. An empty series shows `messages.empty`; one sample shows that sample. - `selection`: The window, from the date of its first sample to the date of its last. The lens writes it live while it moves and lands it on whole samples. Set it from outside and the lens glides there; a window wider than the series shows all of it. `RangeLens.window(last:of:)` makes a good starting value. - `summary`: The figure the readout shows for the window: `.average` (the default), `.total`, `.maximum` or `.minimum`. - `unit`: What the numbers count, shown after the numeral and read by VoiceOver, such as "steps a day". `nil` shows the numeral alone. - `minimumSpan`: The fewest samples the window may cover. Resizing stops firmly there and the window never inverts. Defaults to 7, a week of daily samples. - `showsAverage`: Draws the window's average as a red dashed rule across the detail chart with an "avg" chip on its value axis. - `messages`: Built-in copy. Defaults are localizable through your String Catalog; replace any line, for example `average: "Daily average"`. - `style`: Colors and sizes. Defaults to the SwiftPieces house palette with the red accent, adapting to light and dark. - `valueLabel`: How a value reads in the readout and to VoiceOver, for example `{ $0.formatted(.currency(code: "EUR")) }`. Defaults to whole numbers from 100 up and one decimal below that. ## Ring Breakdown (`RingBreakdown`) URL: https://swiftpieces.com/docs/components/data/ring-breakdown Category: Data. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: RingBreakdown.swift Install: npx swiftpieces add RingBreakdown A composition ring of solid color blocks with rounded seams that you scrub by dragging around it, with a haptic at each slice boundary and a radial lift for the selected slice while the rest step back to the surface, around a semibold center figure under a liquid glass label pill whose name morphs letter by letter and out of which the selected slice's share buds as a tinted chip, above a legend of glass chips resting apart that take the slice's tint when picked. Ring chart with scrub-to-select, a lifted slice, and a built-in legend. The ring is content; its chrome is liquid glass. In the center a glass pill names the selection, morphing letter by letter, and the selected slice's share buds out of it as a chip tinted with the slice's color, resting joined to it. The legend rows are glass chips resting apart; the picked one takes its slice's tint. ### Notes - Slices are solid color blocks from the house palette (red, sky, butter, sage, lilac, sand), separated by a 4 pt gap with rounded ends. Each slice is filled and stroked with a round join, so the corners stay soft at any size. - Drag anywhere on the ring and the selection follows your finger around it, with a `.selection` tick at every slice boundary. Release keeps the selection; tap the hole or the selected legend chip to clear it. Only changes made on the ring, the legend or with VoiceOver tick: a parent setting `selection`, or new data clearing it, stays silent. - The selected slice pulls 6 pt outward along its mid-angle with visible give and lifts a soft shadow, and drops back firmly when it lets go. The others step back to a solid resting color a beat later on a calmer spring, never a faded wash, so a selection reads as one wedge pulling out; clearing brings everything back together. The center is liquid glass over a semibold figure with dimmed decimals: a glass pill names the selection ("Total" when nothing is picked), morphing letter by letter, and the selected slice's share buds out of the pill's end as a chip tinted with the slice's color, resting joined to the pill by a liquid neck while the pair slides over to stay centered. Moving to another slice keeps the chip out: its share rolls and its tint changes in place. Clearing the selection drains its tint and melts it back into the pill. The figure and the share roll without overshoot. The legend is a stack of glass chips resting apart; the picked chip takes its slice's tint with ink text on the slice's beat, the other chips' text steps back on the same follow-through while their glass stays whole, and chips sink slightly when pressed. - The ring sweeps in clockwise from the top on first appearance, and again only when data lands in an empty ring, while the center pill and figure are there from the start. New values after that slide the seams to their new angles with no overshoot, even when the selection changes with them, the center figure rolls to its new value, and the selection stays while its label is still there. A slice that arrives opens from the seam between its neighbors as they make room, and one that leaves folds back into it, so the ring never shows more than the whole. - `tint` overrides the first slice; `color` on a slice overrides any slice. `Style` sets `blocks`, `rest`, `ink`, `text`, `muted`, `gap`, and `cornerRadius`. The default thickness is 34 pt. Every string is semibold; size sets the hierarchy. - The ring is one VoiceOver element: it reads the selected slice or the total, and swiping up or down moves the selection. Legend chips are 44 pt buttons with their value. Reduce Motion drops the lift, the share chip's bud (it fades in and out in place beside the pill), the letter morph (the name crossfades) and the press scale (a pressed chip dims instead), fades the ring in instead of sweeping it, and crossfades digits instead of rolling them. New values still slide the seams, and slices still open and fold, on a shorter spring with no overshoot. Colors, the shadow and the haptics stay. Under Reduce Transparency the glass draws solid. - Labels are the identity, so keep them unique. `selection` lets a parent drive or observe the selected slice, and a selection set before the ring appears is kept. An id that names no slice draws as no selection. - A full budget screen built on a spending ring ships in SwiftPieces Pro (`spending-ring`). ### Parameters - `slices`: Parts of the whole, drawn clockwise from the top. Labels must be unique. - `thickness`: Ring thickness in points. - `tint`: Color of the first slice. Defaults to the style's first block; later slices take the next blocks unless a slice sets `color`. - `format`: Number format for the center value and legend, for example `.number` or `.currency(code: "USD")`. Defaults to `.number`. - `showsLegend`: Show the tappable legend rows below the ring. - `selection`: Optional binding to the selected slice id, for driving selection from outside. - `style`: Slice blocks, ink, and surface colors. Defaults to `.standard`: red, sky, butter, sage, lilac, and sand. ## Scrub Chart (`ScrubChart`) URL: https://swiftpieces.com/docs/components/data/scrub-chart Category: Data. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: ScrubChart.swift Install: npx swiftpieces add ScrubChart A liquid glass line or bar chart: a semibold readout with dimmed decimals beside a tinted glass delta chip, a glass flag that rides the scrub rule with the range's change budding out of it and resting joined while a range is held, hold-and-drag range selection, and a range picker of joined glass segments whose tint flows into the one you pick as it swells, morphing the line between datasets. Time-series chart with scrubbing, range selection, and a morphing range picker. The chart is content; its chrome is liquid glass. A glass flag rides the scrub rule, and while a range is held the range's change buds out of the flag and rests joined to it. The range picker is a row of glass segments joined by liquid necks: the one you pick swells and takes the accent tint, which flows to it through the necks. ### Notes - The readout is display type: an uppercase meta line (range, point time, or held span), a semibold 44 pt figure with its decimals dimmed, and a liquid glass delta chip beside it, tinted sage for up and red for down with ink text, and left clear with muted figures when nothing changed. - The figure is never cut short. Beside the chip it may shrink to 90% at most; when the widest figure and chip any range can show don't fit side by side at that size (a narrow card, a large figure, or a large Dynamic Type size), the chip sits under the figure instead, and only then does the figure scale down further, if it still can't fit on its own line. The choice is made once for the whole chart, across every range, so neither a scrub nor a range switch changes the readout's shape or moves the chart. - One finger drags to scrub: a rule and a butter dot sit on the nearest point, and a glass flag grows out of the top of the rule with its time. They move with the finger on the same frame and never ease after it, so the figure, the rule, the dot and the flag always agree, and `.selection` ticks at each point reached (not on release). Touching down brings the cursor in with the dot a beat ahead; letting go clears it and the flag sinks back into the rule. Holding still for 300 ms before moving pins the point under the finger as an anchor: the dot pops with a rigid tap and the drag becomes a range, a solid band fills the span and the figure shows the signed change with its percent. The range's change also buds out of the flag's side, on the side toward the middle of the plot, as a tinted glass chip that rests joined to the flag by a liquid neck; the flag keeps the pair inside the plot, and when the range ends the chip's tint drains and it melts back into the flag. - Figures roll straight to their new value when the mode changes (touch, pin, release, range switch), never counting through the values between and never overshooting, and change at once while you scrub. The delta chip's glass around them resizes with a little give, and its arrow blurs across when the direction turns. - Every series is normalized to 0...1 for drawing, so the y-scale never jumps between ranges. In `.line` mode datasets are resampled to 64 positions and switching ranges bends the line into the new one with no overshoot; the latest dot and the dashed baseline ride it, and the min and max labels leave at once and return once it lands. In `.bars` mode bars grow to their new heights and the active bar takes the accent block. New data in the same range morphs the same way without ending a scrub or a held range, and the min and max labels stay unless the min or max itself changed. When the chart goes from no data to data, on first appearance or when data loads later, the line unfolds from its baseline and bars rise from the floor, unhurried and without overshoot. A lazy list that rebuilds the row plays this again. - At rest: no y-axis, one dashed baseline at the first value, monospaced min and max markers that shift inward to stay inside the plot when the min or max is the first or last point (on the latest point they sit off its corner, clear of the dot), the latest point as a butter dot, and three uppercase date labels whose format follows the span of the range. The picker is a row of glass segments, 44 pt tall, joined by liquid necks: segments press in lightly, and the one you pick swells and takes the accent tint, which flows to it through the necks with a little give while the one it leaves narrows and drains back to clear glass. A range set through `range` from outside moves it the same way. - `tint` sets the line (the house text color by default). `Style` sets `accent` (also the picked segment's glass tint), `up` and `down` (the delta chips' glass tints), `ink`, `muted`, `band`, `ground` (the card the chart sits on), and `numeralSize`. `format` takes any `FormatStyle` from `Double` to `String`; deltas reuse it with a sign. `range` lets you own the picker index. An empty series shows "No data yet". - VoiceOver reads the readout and the plot as one element with the range, the latest value and change; swipe up or down steps the scrub point, and swiping up past the latest point lets it go. Each range segment is its own button, and the picked one reads as selected. - Under Reduce Motion the chart appears in place without unfolding, a range switch bends the line on a shorter spring, the picker's tint crossfades and its widths settle on a short spring with no give, the figures crossfade instead of rolling, the delta chip and the flag fade in without scaling, the range's change chip fades in beside the flag instead of budding, and a pinned anchor skips the pop but keeps its tap, band and readout change. Under Reduce Transparency the glass draws solid. Every string and symbol is semibold; size sets the hierarchy. - A full dashboard screen ships in SwiftPieces Pro (`dashboard-screen`). ### Parameters - `series`: One dataset per range button, in display order. A single entry hides the picker. - `mode`: `.line` draws a smoothed line with min and max markers; `.bars` draws one bar per point. - `tint`: Line and bar color. Defaults to the style's `line`, the house text color. - `format`: Number format for readouts, for example `.number` or `.currency(code: "USD")`. Defaults to `.number`. - `range`: Optional binding to the selected series index, for driving the picker from outside. - `style`: Accent, delta, band, and numeral settings. Defaults to `.standard`: a butter accent, sage for up, red for down. ## Outcome Screen (`OutcomeScreen`) URL: https://swiftpieces.com/docs/components/feedback/outcome-screen Category: Feedback. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: OutcomeScreen.swift Install: npx swiftpieces add OutcomeScreen A success, failure or empty outcome view sharing one choreography, where a ring draws, floods into a solid color block, the mark strokes in dark ink, one pulse lands with a haptic, and the headline and a signal-tinted liquid glass action rise. On failure an async retry morphs the action's label to "Retrying" while a progress bubble buds out of its end on a liquid neck, and a glass details block opens to the technical text. 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 `.id` to replay it from the start. - Passing a new `outcome` to 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. - `.empty` draws an open tray and plays a light impact, for first-run and no-results states. - The headline is set large and tight at `.largeTitle`; `eyebrow` adds 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. `secondary` is a quiet text button. - On failure, `retry` turns 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. - `details` adds a glass panel, apart from the button, that opens to selectable monospaced text and closes a little quicker than it opens. - `Style` holds 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. `accent` still 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. ### 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. ## Rating Scrub (`RatingScrub`) URL: https://swiftpieces.com/docs/components/feedback/rating-scrub Category: Feedback. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: RatingScrub.swift Install: npx swiftpieces add RatingScrub A star rating you tap or scrub, with soft rounded stars that fill as solid blocks colored by the score and rise under your finger like soft keys, falling back in a ripple when you let go, with a little give past either end. While you scrub, a glass readout tinted with the score buds up out of the touched star and rides from star to star, and the caption under the stars is a glass numeral pill joined to a tinted label chip whose word morphs letter by letter. Tap-or-scrub star rating with lift, staggered settle, a score-colored fill, a liquid glass readout and an adjustable accessibility element. The stars are content and stay solid. The chrome is glass: while a finger scrubs, a readout bubble tinted with the score buds up out of the touched star, rides with the finger from star to star, and melts back down into the star the finger leaves. A tap alone never raises it, so a quick rating stays quiet. ### Notes - Stars are soft rounded shapes that fill as solid blocks. They are the content, so they stay solid; the chrome around them is liquid glass. The block follows the score through `Style.levels` (the brand red, sand, butter, sage, sky by default), so a low rating reads differently from a high one at a glance. Pass `tint` to use one color for every score. - One `DragGesture` with zero minimum distance handles tap and scrub. The row works like soft keys: the star under the finger rises on a short spring with no overshoot, and the two stars either side of it rise a little after it, less with distance. Let go and the star you left drops first and each neighbor follows 30ms later, so the row falls back in a ripple. The lift is decoration only: the fill and numeral land on the value with no delay or overshoot, quickly under the finger and on a firm spring from VoiceOver or the binding, so a star never shows a score that isn't true. - While you scrub, a glass readout tinted with the score buds up out of the touched star: born behind it as a small round bubble, it rises above the lifted star while it widens to fit the rating and its label, then rides from star to star with your finger, changing tint and morphing its word as it goes. Let go and it narrows and sinks back into the star you left, its tint draining as it melts. A quick tap never raises it, so a single rating stays quiet. - With `labels`, a caption sits under the stars: a glass pill with the numeral, a dimmed decimal and "/ 5", joined by a liquid neck to a glass chip tinted with the score that carries the label for the rounded-up rating. The chip morphs its width, tint and word letter by letter as the rating changes. Unrated, the chip is clear glass reading "Tap or slide". - Pull past either end of the row and the end star gives a few points with rubber-band resistance, about the gap between two stars, then springs back at the speed you let go. The rating itself stays at 0 or the maximum. A scrub the system cancels, such as a scroll taking over, lets the stars and the readout go the same way. - Every string is semibold, so size and color carry the hierarchy. Before iOS 26 the readout, pill and chip draw on a frosted material with a light rim, and the pill and chip sit side by side without a neck. Under Reduce Transparency they are solid. - Reduce Motion: nothing rises, ripples or gives past the ends. The star under the finger shifts a shade in place instead (deeper in light mode, lighter in dark), the readout fades in and out above it at full width, the words crossfade, and the haptics and colors are unchanged. - Haptics: `.selection` on each step, a rigid impact when the value hits 0 or the maximum. - `isReadOnly` removes the gesture, the readout and the VoiceOver adjustable action but keeps the value readable; its chip reads "No rating" until there is one. `size` scales with Dynamic Type. ### Parameters - `rating`: Bound value from 0 to `count`, in whole or half steps. - `count`: Number of stars. - `allowsHalf`: Snap to half stars. - `labels`: Optional one label per star ("Poor" ... "Great"). Adds a caption under the stars: a glass pill with the numeral, joined to a glass chip tinted with the score that carries the label for the rounded-up rating. The scrub readout shows the label too. - `isReadOnly`: Shows the value without a gesture or adjustable action. - `size`: Star point size; scales with Dynamic Type. - `tint`: Overrides the fill color for every score. Defaults to `style.levels`, one block per score. - `style`: Fill colors per score, the empty star color and text colors. `.standard` runs the brand red, sand, butter, sage, sky. ## Reaction Toggle (`ReactionToggle`) URL: https://swiftpieces.com/docs/components/feedback/reaction-toggle Category: Feedback. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: ReactionToggle.swift Install: npx swiftpieces add ReactionToggle A liquid glass like or save toggle. It presses in, its glass capsule floods with its tint from the symbol outward while the symbol swaps to its filled form and squashes and springs like soft rubber, the count rolls, and a confirmation pill buds out of the capsule on a liquid neck and melts back into it. Liquid glass reaction toggle with press feel, a tint that floods the glass from the symbol, a rubbery symbol squash, and an optional rolling count and confirmation pill that buds out of the capsule. ### Notes - At rest the toggle is a neutral liquid glass capsule. Turning it on leads with two things on the tap: the tint floods out of the symbol to the rim, showing through the glass as it spreads, and the symbol swaps to its filled form in dark ink, blurring from one to the other, and squashes like soft rubber, flattening wide, springing up tall with a small tilt and wobbling to rest. Two beats (140 ms) later the glass takes the tint as its own, so at rest the capsule is tinted glass rather than a flat block. The count rolls with the toggle, never behind it and never past its number. Turning it off drains the tint back into the symbol with no bounce or haptic. - With `confirmation`, a glass pill with a check buds out of the top of the capsule once the glass has taken the tint: born inside the capsule, shrunk, it springs out on a liquid neck that thins as it goes and rests joined to the capsule above it. 1.3 s after the tap it reaches its neck back out and melts home on a spring with no bounce, its words gone the moment it heads in, so nothing ever sits over the symbol. Turning the toggle off calls it home at once. - `Style` holds the tint (`fill`), the `ink` on it, the glass tint while off (`restFill`, clear by default), the rest ink, and the pill's glass tint (`pillFill`, clear by default) and ink, with house palette defaults that adapt to light and dark. The default tint is the brand red, `#FF0000`. `tint` still works and overrides `fill`. - Set `isContained: false` for a bare symbol that fills like a sticker under an ink outline. With no glass to flood, it keeps its halo: one ring ripples off the symbol a beat after the tap. Its pill has nothing to bud from, so it rises in on its own above the symbol. - The press comes from a `ButtonStyle`, so it tracks the real touch: it sinks about 2.5 pt per edge with no overshoot and springs back with a little give on release. It stays centered and never reads the touch point, so it is safe in scrolling feeds. The squash and the pill are visual only, so the hit area and layout never move. - `count` is the base value from your model; the view adds one while `isOn` so the number rolls immediately without a round trip. `size` scales with Dynamic Type and the hit area never drops below 44 pt. Every string and symbol is semibold. - Before iOS 26 the capsule and pill draw on a frosted material with a light rim, and the pill joins the capsule without a neck. Under Reduce Transparency they are solid, and the tint arrives without the flood. - Under Reduce Motion the flood fades in and out at full size instead of spreading and draining, the symbol and the count crossfade, the pill fades in and out where it rests, the press shifts a shade instead of sinking (darker in light mode, lighter in dark), and there is no squash or halo. Nothing waits a beat. The soft haptic still fires on the off-to-on edge only. ### Parameters - `isOn`: Bound reaction state. - `systemImage`: Outline symbol name. - `filledImage`: Filled symbol name; defaults to `systemImage` with `.fill` appended. - `count`: Optional number shown beside the symbol; it rolls with `numericText` and offsets by one while `isOn`. - `confirmation`: Optional pill text, such as "Saved". Turning the toggle on buds a glass pill out of the top of the capsule, joined to it by a liquid neck, which melts back in a moment later. - `size`: Symbol point size; scales with Dynamic Type. The hit area is at least 44 pt. - `tint`: Overrides the tint the glass floods with when on. Defaults to `style.fill`. - `style`: Colors and shape. `.standard` is a neutral glass capsule that floods with the brand red under dark ink; set `isContained` to `false` for a bare symbol. ## Skeleton Loader (`SkeletonLoader`) URL: https://swiftpieces.com/docs/components/feedback/skeleton-loader Category: Feedback. iOS 17.0+. Files: SkeletonLoader.swift Install: npx swiftpieces add SkeletonLoader Solid placeholder bones and a self-masking modifier that turns any layout, color blocks included, into one quiet shape sweeping a soft diagonal highlight on a shared clock, then hands off as content unblurs and rises row by row. The bones stand in for content, so they stay solid rather than glass. Standalone placeholder bone. Use `.skeleton(isLoading:)` to redact real content instead. Bones stand in for content, not chrome, so they stay solid in the liquid glass language: glass is for the controls that load around them. ### Notes - `.skeleton(isLoading:)` keeps the real view in the tree, hidden, so layout never jumps. The placeholder is a silhouette of the redacted view drawn through an alpha threshold, so text bars, color blocks and symbols all become one solid bone color, whatever colors the content uses. - A soft diagonal light passes over the bones, easing in and out, then rests before the next pass. Every instance reads the same reference clock, so passes start on the same beat. Bones and rows are crossed edge to edge in the first three quarters of `period`, so they are lit in the same moments whatever their width. Larger surfaces, up to a whole screen, stretch their pass toward nine tenths of `period` to keep the light calm, so it runs only a little quicker than a row's and rests less. - On handoff the placeholder lifts away first on a quick, firm fade, and 50 ms later the content surfaces behind it, unblurring and rising 6 pt on a calm spring with no visible overshoot, so the two barely overlap out of register. `staggerIndex` delays each row's handoff by 60 ms per index so lists reveal top to bottom. Turning loading back on covers every row at once. - `isFailed` stops the sweep and dims the bones firmly so the slot reads as empty; flip `isLoading` back on to retry, and the bones brighten as the light returns. - `Style` sets the bone `fill`, the sweep `highlight`, the `period` and the failed opacity, with house palette defaults that adapt to light and dark. Pass the same style to `SkeletonLoader` bones and `.skeleton` so they match. - Under Reduce Motion the light holds still and every bone breathes slowly toward the highlight and back in unison, over two periods, so the slot still reads as loading. The handoff is a short crossfade with no blur, rise or stagger, so the slot never flashes empty. - Reduce Transparency switches to an opaque fill without the sweep or the breath. - Bones stand in for content, not chrome, so they stay solid rather than liquid glass: glass is for the controls that load around them. The example rows are semibold throughout, like every string in the library. - For a whole projects screen that starts as ghost rows and hands off to real content, see the Pro Projects screen. ### Parameters - `shape`: `.rounded(radius)`, `.circle`, `.capsule`, or `.text(lines:)` for a stack of text lines with a shorter last line. - `isFailed`: Stops the sweep and dims the bone so the slot reads as empty. - `style`: Bone and highlight colors and the sweep period. `.standard` is a solid warm grey that adapts to light and dark. ## Status Morph (`StatusMorph`) URL: https://swiftpieces.com/docs/components/feedback/status-morph Category: Feedback. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: StatusMorph.swift Install: npx swiftpieces add StatusMorph One continuous stroke that spins as a loading arc, closes into a ring, floods into a solid block and draws a check in dark ink on success, or a cross with a nudge on failure, with an optional caption on a liquid glass pill under the ring whose label morphs letter by letter. Idle, loading, success, and failure in one ring that lands as a solid block, with an optional caption on a glass pill. ### Notes - Loading is a 72% arc in ink turned by a `TimelineView` clock, so nothing runs while idle or settled. Each spin starts from rest wherever the stroke lies and reaches full speed within about half a second. - On success or failure the arc keeps its angle and coasts to rest at the speed it was turning, never swinging back, while it closes into a ring. A beat later the ring floods inward into a solid block (sage for success, the brand red for failure), and a beat after that the check or cross draws in dark ink. The stroke and the block are the content, so they stay solid. - The outcome lands as the block closes solid, about half a second after the change, while the mark finishes drawing: success plays the `.success` haptic and, with `pops`, a soft pop; failure plays the `.error` haptic and a short shake sized to the ring, on the same beat. - `captions` puts the caption on a liquid glass pill just under the ring. A changed caption morphs letter by letter: letters the two words share hold still and the rest blur through, so "Saving" becomes "Saved" in place, and the pill eases to its new width. An outcome's word changes two beats after the state, so it lands with the mark it names. With nothing to bud from, a caption that appears rises in on its own and one that goes sinks away; the slot keeps its height, so the ring never moves. The caption is quiet while working and full ink once settled, all in semibold. - Retrying from a settled state runs the morph in reverse: the mark un-draws, the arc turns back to ink and opens as it starts to turn, and the block drains outward into it in its own color. Going straight from success to failure recolors the ring, then the block a beat later, and swaps the check for the cross. Draining, the marks show only through what is left of the block, so ink never shows over the open center. Drawing in, they see the whole block, so the cross's first stroke never breaks as it crosses the closing center. - Each mark thins to nothing over the first few percent of its stroke, like a pen landing and lifting, so an un-drawn check never leaves its round cap behind. - A change that arrives mid-morph retargets from wherever the ring is, and a landing from an earlier state never plays late. An outcome left for idle before it lands still plays its haptic at once, without the pop or shake. A retry or the other outcome replaces one that hasn't landed, so only the newer outcome is felt. If the haptic matters, keep the view on screen until the outcome lands, for example before dismissing a sheet on success. - `Style` holds the two blocks, the ink, the arc and track colors and the caption colors, with house palette defaults that adapt to light and dark. `tint` still works and overrides the success block. - The check and cross are one `Shape` stroked to a progress, so the cross draws its two lines in order. - A view that appears already settled shows its final block and caption without replaying the morph or its haptic. - `size` is a `@ScaledMetric`, so the ring grows with Dynamic Type alongside its caption. - Before iOS 26 the caption pill draws on a frosted material with a light rim; under Reduce Transparency it is solid. - Under Reduce Motion the arc does not turn: while loading it holds still and breathes slowly in opacity, so it still reads as work in progress without a caption. Each change lands in one short beat with no rotation, pop or shake, and the mark shows through the block as it fills. The color, the mark, a crossfaded caption and the haptics still carry the outcome. - For a full agent task screen with a live tool timeline, see the Pro Agent Task screen. ### Parameters - `state`: `.idle`, `.loading`, `.success`, or `.failure`. - `captions`: Optional caption per state, on a glass pill under the ring. A changed caption morphs letter by letter, so "Saving" becomes "Saved" in place; a caption that appears rises in, and one that goes sinks away. Use `.saving` for "Saving / Saved / Failed". - `size`: Ring diameter in points; scales with Dynamic Type. - `lineWidth`: Stroke width for the ring, check, and cross. - `tint`: Overrides the success block color. Defaults to `style.success`. - `pops`: Soft scale pop when success lands. - `style`: Block, stroke and caption colors. `.standard` lands sage for success and the brand red for failure, with dark ink marks. ## Glass Action Menu (`GlassActionMenu`) URL: https://swiftpieces.com/docs/components/glass/glass-action-menu Category: Glass. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: GlassActionMenu.swift Install: npx swiftpieces add GlassActionMenu A liquid glass action menu. Hold the signal trigger and its actions bud out of it along a line or an arc, each born inside it and pulling a neck that thins and snaps, with its name budding from its side; slide across them and release to fire. The others melt home first and the fired one last, as the trigger lands a check; a tap toggles and a scrim dismisses. Floating liquid glass action menu with a hold-slide-release gesture, a dimming scrim and a confirming trigger. The trigger and its actions are glass bubbles in one liquid group. The actions rest apart, from the trigger and from each other. Opening, each one buds out of the trigger: born inside it, it pulls a liquid neck that thins and snaps as it springs to its place, and its name buds from its side and stays joined to it by a neck. Closing, the names melt into their actions and the actions melt home, farthest first; after a fire the chosen action melts home last. ### Notes - Two ways in: a tap toggles the menu, a 350ms hold expands it and keeps the same touch live, so you can slide across items (each crossing lifts the item and ticks `.selection`) and release to fire. Releasing a hold on empty space leaves the menu open. A touch the system cancels lets go without firing. - Liquid glass throughout: the trigger, the actions and their names are glass shapes in one liquid group (`GlassEffectContainer` on iOS 26). The actions rest apart, 26pt from the trigger and from each other, so each is its own bubble; a name rests joined to its action, 6pt off its side, so a liquid neck holds them together. - The trigger leads: it sinks under the finger without bouncing, springs back on release and turns to a close mark on a crisp snap. The actions then bud out of it, 50ms apart: each is born inside the trigger, shrunk, and springs out to its place on the split spring, pulling a neck that thins and snaps as it goes. Each name buds from its own action 160ms later, squeezed narrow inside the action and stretching out to full width. - Closing: the names melt into their actions first, then the actions melt home, farthest first, 40ms apart, on a spring with no bounce, so none pokes out through the far side of the trigger. A bubble's icon or name blurs away the moment it heads home, and a tinted action drains to clear glass as it melts in, so nothing sits on top of the trigger's symbol. - A hovered item lifts with a little elastic give and leans a few points toward the finger on its tether. The lean is visual only; hits always use the items' resting positions. - Firing: the rigid haptic lands on release, the item swells, and its action runs 140ms later. The other actions melt home first and the chosen one melts in last, still swollen; the trigger's close mark blurs into a checkmark as it arrives and holds it for 0.9s (`Style.confirmsAction`). No second haptic. - The scrim is an oversized background behind the trigger; it dims everything beneath the menu in its parent and dismisses on tap. It fades with the menu and lets taps through as soon as the menu starts to close. Place the menu last in a `ZStack` so nothing sits above it. - Phases: `collapsed`, `expanding`, `open`, `hovering`, `firing`, `collapsing`. Reduce Motion fades the actions and names in and out at their places with no stagger or neck, rings the hovered item instead of lifting and leaning it, shades the trigger on press instead of sinking it (darker in light mode, lighter in dark), and swaps the open trigger's symbol for a close mark instead of turning it. Haptics and the checkmark stay. Reduce Transparency draws every bubble as an opaque fill. - VoiceOver sees the trigger as a button (double tap toggles) and each item as its own button while the menu is open. - Items take an optional `tint` (`Item(symbol:label:tint:action:)`): tinted glass with dark ink, for an action that needs to say something. Untinted items are neutral glass with ink that follows the appearance. - The trigger is signal-tinted glass with the house ink by default (`tint` is optional; `nil` uses `Style.trigger`). All type and symbols are semibold. - `Style`: `trigger`, `ink`, `labelInk`, `triggerSize`, `itemSize`, `scrimOpacity`, `confirmsAction`; `labelFill` is kept for existing code but no longer used, since names are neutral glass. In an arc of three or more, the top item's name sits above it. - Before iOS 26 the same choreography runs on a frosted fallback, where shapes don't merge into a neck. - For a floating pill that morphs into a full toolbar and panel, see the Pro Reader screen (`morph-nav`). ### Parameters - `items`: Actions in emergence order (nearest to the trigger first). Give an item a `tint` to tint its glass. - `triggerSymbol`: SF Symbol on the collapsed trigger. Rotates 45° while open (under Reduce Motion it swaps to a close mark instead). - `arrangement`: `.linear` stacks items upward; `.arc` fans them from up to leading, for a bottom-trailing placement. - `tint`: Trigger glass tint. `nil` uses `style.trigger`, the house signal red. Items keep their own tints so the trigger reads as the anchor. - `isExpanded`: Optional binding to open or close the menu programmatically; it is written back when the user toggles it. - `style`: Trigger tint, inks, sizes, scrim strength and the confirmation check. ## Glass Mode Bar (`GlassModeBar`) URL: https://swiftpieces.com/docs/components/glass/glass-mode-bar Category: Glass. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: GlassModeBar.swift Install: npx swiftpieces add GlassModeBar One liquid glass body that turns into whatever mode you pick. At rest a capsule of mode icons spans the width; tap one and a signal anchor carrying that mode buds out of the capsule's leading end while the capsule draws back to make room and trades its icons for that mode's own controls, a waveform for voice, thumbnails for photos, chips for a timer. Tap the anchor and it melts home as the icons come back. The body keeps its footprint the whole time. One liquid glass body that becomes the controls of the mode you pick, without leaving its place. At rest a capsule spans the width offered, its mode icons spread evenly across it. Tapping a mode buds a signal anchor out of the capsule's leading end, carrying that mode's icon, while the capsule draws its leading edge back to make room and trades its icons for the mode's controls, which you build. The two rest joined, one body. Tapping the anchor melts it home into the capsule as the icons return. The outer edges never move. ```swift @State private var mode: String? GlassModeBar(modes: [ .init(id: "voice", systemImage: "mic", title: "Voice"), .init(id: "timer", systemImage: "timer", title: "Timer"), ], selection: $mode) { mode in switch mode.id { case "voice": RecordingControls() default: TimerChips() } } ``` ### Notes - One body that keeps its footprint. At rest a liquid glass capsule spans the width offered, its mode icons spread evenly across it. Open a mode and the capsule draws its leading edge back while a signal anchor carrying that mode's icon buds out of its leading end, born inside it, shrunk, and pulled out through a neck to rest joined beside it. Neither outer edge moves. - The capsule trades its icons for the open mode's controls, which you build in `panel`: they blur in where the icons blurred out, on the reveal and dismiss roles. The panel gets the capsule's height (56pt) and the full width beside the anchor, so give its contents `maxWidth: .infinity` to span it, as the example's waveform, photos, chips and format buttons do; keep it plain content, since the capsule is already the glass. The example turns Voice into a live waveform and a running time, Photo into recent shots, Timer into duration chips and Text into format controls. - Every change overlaps, so the body is never blank: opening, the panel blurs in as the icons blur out while the anchor buds; closing, the anchor melts home on the bounceless home spring and the icons start coming back 160ms in, while the capsule reaches back for it. The anchor keeps its glyph until it is inside. Setting `selection` from your code opens, closes or switches a mode the same way; switching swaps the panel and the anchor's glyph in place. - Haptics: a selection tick as a mode opens, a soft impact as it closes. - On iOS 26 the capsule and the anchor are Liquid Glass and the neck forms between them; before that they are frosted glass that simply sit side by side, and under Reduce Transparency they are solid. - Reduce Motion: the anchor fades in and out where it rests instead of budding, the capsule's edge moves on a short spring with no overshoot, and the icon and panel swaps cross-fade. - VoiceOver: each mode icon is a button with its title; the anchor reads "Close" and the mode's title. Panels are your views, so give their controls labels. - `Style` (`GlassModeBarStyle`) sets the icons' colour, the anchor's ink and the anchor's tint (signal by default). `Mode` is `GlassModeBarMode`: an `id`, an SF Symbol and a title. All text and symbols are semibold. ### Parameters - `modes`: The modes, in order across the capsule. - `selection`: The open mode's `id`, or `nil` at rest. Set it yourself to open or close a mode from your code. - `style`: Colors. Defaults to the SwiftPieces house palette, adapting to light and dark. - `panel`: The controls for a mode, laid out in the capsule beside the anchor. They fill the capsule's height (56pt) ## Glass Quick Add (`GlassQuickAdd`) URL: https://swiftpieces.com/docs/components/glass/glass-quick-add Category: Glass. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: GlassQuickAdd.swift Install: npx swiftpieces add GlassQuickAdd A liquid glass Quick add pill that folds up into a card. Tap it and the pill grows up and across into a menu of capture kinds; pick one and the same glass re-forms in place into that capture, a title field and its option chips. A signal Save bubble buds out from under the card's corner only once there is something to save, melts back if the field empties, and saving folds the whole thing down into the pill. A capture kind in a `GlassQuickAdd`: its row in the menu, and what the capture asks for. ### Notes - One glass card that changes what it is. At rest it is a Quick add pill. Tap it and it grows up and across into a card listing the capture kinds, each keyed by a tinted disc; pick one and the same glass re-forms in place into that kind's capture: a header with the kind and a close button, a title field (up to three lines) and the kind's option chips. The card's 26pt continuous corner makes the 52pt pill a capsule and the open card a rounded panel, so the shape never changes kind, only size, on the morph spring. Each stage's contents blur out as the next blur in. - It grows from its lower trailing corner, so place it at the bottom of its space (a screen's bottom trailing corner, above a tab bar) and it opens upward. - Save is a signal bubble joined under the card's trailing corner. It buds out of the card the moment the field has text and melts back into the corner if the field empties, so it is only ever there when there is something to save. Saving (or Return) calls `onSave` with the kind, the trimmed text and the options that are on, then plays its own beat before anything closes: Save shows a spinner and morphs letter by letter to Saving, then turns sage with a check and Saved, with a success tap, then melts home and the card folds down into the pill. Each stage's contents leave quickly and the next arrive a beat later, so they never smear together as the glass resizes. - The field takes focus as a capture opens, so the keyboard rises with it. Chips are plain content inside the card's glass: a soft fill, stronger when on; any number can be on. - Pass `stage` to open the menu or a capture from your code (`.closed`, `.menu`, `.capture(id)`), `text` to keep a draft or fill it in, and change `saveTrigger` to save from your code (a keyboard shortcut, a toolbar button) as if Save were tapped. Without them the piece keeps its own. - Haptics: a soft impact as the pill opens, a success tap as Saved lands. - On iOS 26 the card and Save are Liquid Glass and Save's neck forms against the card; before that they are frosted glass, and under Reduce Transparency they are solid. - Reduce Motion: the card resizes on a short spring with no overshoot, Save fades in and out where it rests instead of budding, and the stage swaps cross-fade. - VoiceOver: the pill, every kind, each chip (with its selected state), Close and Save are buttons; saving is announced. Text follows Dynamic Type. - `Style` (`GlassQuickAddStyle`) sets the label, secondary, ink, action (Save) and success (Saved) colours. `Kind` (`GlassQuickAddKind`) sets each kind's id, symbol, title, prompt, options and disc tint. All text and symbols are semibold. ## Glass Segments (`GlassSegments`) URL: https://swiftpieces.com/docs/components/glass/glass-segments Category: Glass. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: GlassSegments.swift Install: npx swiftpieces add GlassSegments A liquid glass segmented control: a signal-tinted glass indicator melted into a glass track as one surface. It moves like liquid, its leading edge reaching the new segment first and the trailing edge following, so it stretches across and gathers in. Grab it to scrub: it lifts under the finger, flattens against the ends and lands where the flick was heading, while the label ink flips exactly under its edge; tap still works. Visual tuning for `GlassSegments`. `standard` is a neutral glass track with a signal-tinted glass indicator melted into it, the house ink on the red; set `indicatorTint` for another tint. ### Notes - Liquid glass: the track and the indicator are glass shapes in one liquid group (`GlassEffectContainer` on iOS 26). The track is neutral glass; the indicator is signal-tinted glass resting inside it, joined to it, so the two melt into one surface and the red reads as a lens of colour moving through the glass. The selected label sits on the red in the house ink, which reads on signal, sage and butter alike. - The indicator moves edge by edge. On a tap or an external change of `selection`, the edge facing the new segment leads on a quick spring and lands crisply while the far edge follows on a slightly slower one, so the indicator reaches across (about one and a third segments wide on a move to the neighbour) and gathers in to one segment with a little liquid give: the far edge passes its mark by about 3% of its travel and settles back. The leading edge never overshoots, so the indicator never pokes past the track at either end. Longer jumps stretch further (about two segments wide on a jump of three) and still land in under 0.4s. - A touch that lands on the indicator grabs it: it lifts (scale 1.04, deeper shadow) on a spring with no bounce and follows the finger directly, with no catch-up. Its leading edge reaches a little ahead with speed and relaxes when the finger rests. A touch anywhere else is a plain tap on that segment. - The indicator can be grabbed on the segment it is headed for while it is still moving there. It finishes that move under the finger instead of jumping to it, and the finger's own movement still applies directly. - Past either end, the edge at the wall gives no more than the 3pt track inset while the far edge follows against rubber-band resistance, so the indicator flattens against the end instead of leaving the track. The lift grows toward the wall there, so the lifted indicator stays inside the track too. The selection stays clamped. - Release commits to the segment the flick would land on (`predictedEndLocation`), and both edges leave at the finger's speed into the same edge-by-edge hand-off. The lift comes down on the release spring as the indicator lands. Let go flattened against an end, it springs back to full width without the push. - One `.selection` haptic fires each time the indicator's center crosses into a new segment, once on tap, and once on release only if it lands somewhere new. Changes made through the binding are silent. - The binding changes on a short spring with no overshoot, so views that read the selection can animate with it, and a chart or a total switched by it never swings past its new value. A drag that a parent scroll view cancels puts the indicator back in its segment. - Reduce Motion removes the stretch, the reach, the flattening and the lift scale (the shadow still lifts): both edges move together on a short spring with no overshoot. Reduce Transparency draws the track and the indicator as opaque fills, the indicator still in its tint. Before iOS 26 both are frosted and simply don't merge. - Segments share the width equally; the control fills its container, so constrain it with `.frame(width:)` when you want it compact. - Labels are drawn twice: once in `Style.ink`, and once in `Style.selectedInk` masked to the indicator's live shape, so the color hands over exactly under the indicator edge while it moves, stretches or flattens. All type is semibold. - `indicatorTint` sets the indicator's glass tint (`nil` is the house signal red; `.clear` leaves it neutral glass), and `GlassSegmentsStyle.block(_:)` makes one in another tint with dark ink. - The example floats the control over a few color blocks, since glass over a flat page reads as grey. - `Style` (`GlassSegments.Style`, alias of `GlassSegmentsStyle`): `ink`, `selectedInk`, `indicatorTint`, `font`, `disabledOpacity`; `track` and `indicatorSurface` are kept for existing code but no longer used, since both shapes are glass. `.disabled(true)` fades the control and ignores touches. The row height scales with Dynamic Type (capped at 1.5x). - For a floating tab bar whose lens follows the finger, see the Pro Lessons screen (`lens-tab-bar`). ## Glass Surface (`GlassSurface`) URL: https://swiftpieces.com/docs/components/glass/glass-surface Category: Glass. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: GlassSurface.swift Install: npx swiftpieces add GlassSurface The public face of the SwiftPieces liquid glass: `.glassSurface` draws any view on a liquid glass capsule, rect, circle or concentric shape, and `GlassSurface.Group` melts neighbouring surfaces into one through a liquid neck. Liquid Glass on iOS 26; before it a frosted surface with its own press, top light and specular edge; a solid fill under Reduce Transparency, and a designed disabled state everywhere. The SwiftPieces liquid glass, for your own views: Liquid Glass on iOS 26, a layered frosted surface everywhere else, with the same press feel on both. `.glassSurface` draws the same glass every SwiftPieces piece is made of, and `GlassSurface.Group` is the same liquid group: surfaces inside one that come within its `spacing` of each other melt into one shape through a neck. Rest the parts of one control 6pt apart, so the neck holds, and separate actions 26pt apart, so each is its own bubble. Text and symbols on a surface take the house weight, semibold. ### Notes - This is the SwiftPieces liquid glass, for your own views. `.glassSurface` draws the same glass every piece is made of, and `GlassSurface.Group(spacing:)` is the same liquid group: on iOS 26 a `GlassEffectContainer`, so surfaces within `spacing` of each other melt into one shape through a neck that pinches as they separate and snaps when they pass it. - Rest the parts of one control 6pt apart, inside the merge distance, so the neck holds (a transport's previous, play and next, as in the example); rest separate actions 26pt apart, so each is its own bubble. Put every surface of one piece of UI in one group, never one group inside another, and never one surface on top of another. - On iOS 26 this is real Liquid Glass; `tint` tints it natively. Earlier it is a frosted fill with a light rim, a top light and a specular edge (a bright top highlight plus a faint `.primary` edge); a tint lies over it at `Style.tintAmount`. Under Reduce Transparency it is opaque: `Style.solidFill` (the secondary system background by default) with the tint on top. - Tint only to say something: the house signal red for the primary action, sage for success, butter for a warning. Ink on a tinted surface reads best in the house ink (#141414); on neutral glass, let it follow the appearance. - Text and symbols on a surface take the house weight, semibold. - `interactive: true` on iOS 26 is the system's interactive glass, which swells under the finger and owns the press; the surface adds no motion or haptic on top of it. - On the fallback, `interactive: true` gives the pane a press of its own. It sinks level to `Style.pressedScale` (0.96) on a tight spring with no overshoot, its far shadow draws in close under it, and light pools under the finger and follows it. On lift it springs back up on the shared release spring, passing rest by about 8% of the press depth before it settles, and the shadow swings back out with it. A finger that slides more than 44pt off the edge lets go without the give, and the press returns if the finger comes back. - One light impact plays as a tap lifts on the fallback surface, never on touch-down, so touches that turn into scrolls stay silent. A touch that travels more than 44pt before it lifts (a drag, a slider scrub, a slide from one button to another) ends without it. The press is tracked with a simultaneous gesture, so a `Button` inside still owns the tap, and a touch the system cancels (a scroll taking over) releases the surface instead of leaving it pressed. - Lift: on iOS 26 a lone surface floats on a wide, faint shadow in light mode (`Style.shadow`); inside a group the group floats its surfaces as one instead. In dark mode the glass carries its own depth. - Reduce Motion drops the fallback's scale and the release's give. The light pool, the closer shadow and the haptic stay, so the press still reads. - `.concentric(minimum:)` resolves to `ConcentricRectangle` on iOS 26 (set `.containerShape` on the parent) and to a continuous rounded rect with `minimum` as the radius earlier. - `Style` (`.standard`): `tintAmount` (0.72, so a tinted fallback reads as a color block), `specular`, `shadow`, `pressedScale` (0.96), `disabledOpacity` and `solidFill` for Reduce Transparency. - Disabled: `.disabled(true)` fades and desaturates the surface and silences the press response on both paths. - For a floating glass card over full-bleed art with detents, see the Pro Stay screen (`glass-sheet`). ### Parameters - `shape`: `.capsule`, `.rect(cornerRadius:)`, `.circle` or `.concentric(minimum:)` (iOS 26 concentric corners, continuous corners earlier). - `tint`: Optional glass tint. Glass tints natively; the fallback lays it over its frosted fill at `style.tintAmount`, so it reads as a color block. Tint only to say something: signal for the primary action, sage for success, butter for a warning. - `interactive`: Press response: interactive glass on iOS 26, which swells under the finger; on the fallback the surface sinks to `style.pressedScale` into a closer shadow, light pools under the finger, and a light impact plays as a tap lifts on the surface. - `style`: Fallback tint strength, specular edge, lift shadow, press scale, disabled opacity and the Reduce Transparency fill. ## Address Field (`AddressField`) URL: https://swiftpieces.com/docs/components/inputs/address-field Category: Inputs. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: AddressField.swift Install: npx swiftpieces add AddressField A liquid glass address field for checkout, delivery and sign-up. Type-ahead suggestions pour down out of the field as a glass list with the matched text picked out, a clear or searching bubble buds from the field's end, and picking a row with a tap or the hardware keyboard buds a spinner bubble beside it while it resolves into a structured address with street, city, state, postal code, country and coordinate; the list then melts back into the field as the address settles in with the locality on a second line and a sage check, keeping the typed text usable with a "Use as typed" row and a butter retry bubble when a lookup fails. Address entry with type-ahead suggestions that resolve to a structured, verified address. The field is a liquid glass shape, and what it shows buds out of it: the suggestion list pours down out of the field and rests apart from it, the clear (or searching) bubble comes out of its end, and the bubble that spins while a pick resolves comes out of the list beside the chosen row. Each melts back into where it came from. Typing searches after a short pause (three characters or more). Each new keystroke cancels the search in flight, so a slow answer for an older query never replaces a newer one. Picking a suggestion looks up the full address; when it arrives, `address` is set, `text` becomes the address's first line (usually the street) and the locality shows under it. Editing the text again clears `address`. Location permission is never requested: suggestions come from the query alone, optionally biased by `region`. ```swift @State private var query = "" @State private var address: AddressField.Address? AddressField("Delivery address", text: $query, address: $address, countries: ["US", "CA"]) ``` ### Notes - The field is a liquid glass shape (Liquid Glass on iOS 26, a frosted fallback before it, a solid fill under Reduce Transparency), and what it shows buds out of it and melts back in. The suggestion list pours down out of the field and rests apart from it, 26pt below. A round bubble as tall as the field buds from the field's end while you edit: it spins while suggestions load and is a clear button otherwise. While that bubble is out the field gives it its end, and the list follows the field's edge, so the two stay in line with the bubble's column beside them. `Style.field` tints the glass; `.clear`, the default, leaves it neutral. - Typing searches once the trimmed text is three characters or more, after a 250ms pause. The search runs in a task keyed on the text, so every keystroke cancels the one in flight: a slow answer for an older query can never replace a newer list. Earlier suggestions stay on screen while the next ones load, with the field's bubble spinning instead of a flicker. - Suggestions come from `MKLocalSearchCompleter` with `resultTypes = .address`, at most five, with the matched runs in the label colour, the rest of the title in the secondary colour, and the locality on a second line. Picking one runs an `MKLocalSearch` for that completion: the other rows dim and a spinner bubble buds out of the list's end beside the chosen row. Picking another row, editing the text or leaving the screen cancels the lookup, and a late answer is ignored. - Once resolved, `address` holds the street, city, state, postal code, country, ISO country code, coordinate and a single line formatted for the address's own country. `text` becomes the first line (usually the street), the rest shows under it in the field and a sage check lands inside the field. `mailingLines` gives the label lines in the country's postal order. - Editing the text clears `address`. Setting `address` from outside (a saved address) fills the field without searching, and its check lands on a short spring with no success pop or haptic; setting `text` and `address` together is not treated as an edit. - A failed lookup stays on its row: the row's pin turns into a butter warning mark, the message replaces the locality, and the spinner bubble beside it turns butter with a retry arrow; tapping the bubble or the row tries again. A failed suggestion request shows "Couldn't load suggestions" with the same butter retry bubble beside it. No matches is a quiet line. In each of these cases a "Use as typed" row sets an address with `isVerified == false` holding only the typed text. Turn it off with `allowsUnverified: false`. - `countries: ["US", "CA"]` leaves out suggestions whose last line names another country, and a picked address outside the list shows "Choose an address in United States" on its row instead of being set, with no retry bubble since trying again gives the same answer. `region` biases suggestions toward an area. Location permission is never requested. - iOS 26 moves map items to `MKAddress`, which has no separate street or postal code, and deprecates `MKPlacemark`. On iOS 17 to 25 the parts come from the placemark; on iOS 26 from the item's placemark read dynamically (so the file builds without deprecation warnings), with `MKAddressRepresentations` for the formatted line, city and country as the fallback. - `source:` swaps MapKit for your own backend, or a fixed list for previews and tests. Both calls are cancelled when their result is no longer wanted. - Hardware keyboard: up and down arrows move the highlight, Return picks the highlighted row (or the top one), Escape closes the list. A long street wraps up to three lines. A pasted multi-line address is joined with commas. - The list sits inline under the field and pushes content down, so it scrolls with a form and never clips inside a scroll view. Leaving the field hides it; coming back shows it again. - Motion: the list is born as a thin sliver tucked inside the field's bottom edge and springs down to its place, growing to full height as the neck between them stretches and snaps; its room in the layout opens with it, so what sits below glides down. Going home it reaches back up, melts into the field on a spring with no bounce, and only then leaves the layout, its rows gone the moment it heads back. As it refines while you type it resizes without overshoot, so nothing below it wobbles, and typing itself is never animated or delayed. Bubbles bud out of their parent's end on the same cycle, their glyphs blurring in just after they leave and out the moment they head home, and a glyph that changes inside a bubble (spinner to clear, spinner to retry) blurs from one to the other. A row's highlight wash is there on touch-down, the row sinks about a point, and the wash fades as the finger lifts; bubbles sink too and spring back. When a pick resolves, the spinner and the list melt home as the street and locality settle into the field, which grows for the second line with a little give, and the sage check lands on the expressive spring as the success haptic plays. - Type is one weight, semibold, for the text, rows and every glyph; size and colour set the hierarchy. - VoiceOver: the field's hint says whether the address is verified; the suggestion count ("5 suggestions", inflected), "Finding address", the result and any error are announced; rows are buttons read as title and locality with the selected trait when highlighted. The clear bubble is "Clear address"; a failed suggestion request's bubble is "Retry suggestions". - Haptics: a soft impact on picking a suggestion, success when the address resolves, error when a lookup fails, selection ticks for arrow-key moves. "Use as typed" sets the address at once, so it plays only the success. Nothing per keystroke. - Dynamic Type: rows and the field grow and wrap; at accessibility sizes the leading glyphs step aside so titles get the width. - `Style` sets the field tint, label, secondary label, focus ring, row highlight, success, error (the warning mark and the retry bubble) and ink colors, the pin, optional suggestion tiles, the corner radius and the minimum height (also the bubbles' size). - Reduce Motion: the list and bubbles fade in and out in place instead of budding, glyph changes fade, and pressed rows and buttons dim instead of sinking. Highlights, the dimmed rows during a lookup, the spinners, colors, announcements and haptics are unchanged. Reduce Transparency draws the field, list and bubbles as solid shapes. ### Parameters - `label`: The placeholder shown while the field is empty, and its accessibility label. - `text`: Bound text. Set by typing, and to the address's first line once a suggestion resolves. - `address`: The resolved address, or `nil` while there is none. Setting it from outside (a saved address) fills the field and skips searching. Editing the text sets it back to `nil`. - `region`: Biases suggestions toward an area, such as the city a store delivers in. `nil` searches everywhere, starting with the device's region. - `source`: Where suggestions and addresses come from. `nil` (the default) uses MapKit. Pass your own for a geocoding backend, previews or tests. - `countries`: ISO 3166 country codes that are accepted, such as `["US", "CA"]`. Suggestions clearly in another country are left out, and a picked address outside the list shows an inline error instead of being set. Empty accepts every country. - `allowsUnverified`: When `true`, a "Use as typed" row appears when there are no matches or a lookup fails, and picking it sets an address with `isVerified == false` holding only the typed text. - `style`: Colors and field metrics. Defaults to the SwiftPieces house palette, adapting to light and dark. ## Amount Field (`AmountField`) URL: https://swiftpieces.com/docs/components/inputs/amount-field Category: Inputs. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: AmountField.swift Install: npx swiftpieces add AmountField A currency amount entry that formats live in the user's locale as each digit lands (grouping, decimal separator, symbol before or after, the currency's own fraction digits), with a large auto-fitting semibold figure whose digits roll in, a blinking signal caret right after the digits, robust paste of amounts like "$1,234.56" or "1 234,56 €", and a limit that refuses the keystroke with a damped shake, an error haptic and a butter glass pill with the limit, budding out of the compact field's liquid glass block, all backed by a `Decimal` binding. Currency amount entry with live locale formatting, a `Decimal` value and a limit. The field keeps its own digit buffer and renders the formatted string itself, so the caret never jumps and locale separators never break parsing. Formatting follows `\.locale`; fraction digits come from the currency. The figure is content and stays on your ground. The compact block is a liquid glass shape, and the limit shows on a butter glass pill: it buds down out of the compact block and melts back into it, and rises in under the hero figure, which has no glass of its own to come from. ### Notes - The field keeps its own buffer of typed digits and renders the formatted string itself. A hidden `TextField` with `.decimalPad` captures keys, so the caret never jumps and grouping separators never reach a parser. The caret, in the signal red, is drawn right after the digits, before a trailing symbol ("1.234,56| €"), and blinks with a short fade like the system caret, turning solid whenever the amount changes. - The value is a `Decimal`. Fraction digits come from the currency through `Decimal.FormatStyle.Currency`: JPY takes none, USD two, KWD three. Formatting follows `\.locale`, so `.environment(\.locale, Locale(identifier: "de_DE"))` gives "1.234,56 €", en_IN groups in lakhs ("₹1,23,456") and ar_SA shows Arabic-Indic digits. - While typing, what you typed is what you see: "12." keeps its separator and "12.50" keeps its zero. Leading zeros are stripped, "." on an empty field starts "0.", a second separator is ignored and digits past the currency's precision are dropped. Leaving the field settles "12.5" to "12.50" and "12." to "12". - Both "." and "," (and the Arabic decimal separator) work as the decimal key, whatever the device region. Digits in any script are accepted and stored as ASCII. - Paste (long-press, or Command-V) parses "1,234.56", "$99", "1 234,56 €", "US$ 12" and Arabic-Indic text. A lone separator followed by exactly three digits reads as thousands unless the currency has three decimals. Words, two numbers or a minus sign are refused like a key past the limit, with a shake and an error haptic but no limit pill; one already showing stays. - `limit` refuses the keystroke or paste that would pass it with one firm knock: the figure (or the compact block and its ring) shakes once with an `.error` haptic on the same beat, VoiceOver announces the limit, and the `limitMessage` arrives a beat behind the knock on a butter glass pill with house ink, then goes after a moment. The same refusal repeated less than a second apart, like a key pressed again and again at the limit, gets a half-height shake and a lighter tap instead of escalating. Accepted keys stay quiet. An external value over the limit is shown as it is, not clamped, with the pill and (compact) a butter ring; the digits keep their colour so they stay readable on a light ground. - External writes to the binding (a quick-amount chip, a reset) redraw the display at the currency's precision. Negative or over-precise values are normalized and written back. - `.hero` is a large semibold figure that scales down to fit its width, with digits rolling in through `.numericText()` on a spring that never overshoots, so a digit never rolls past its place. The fit uses the same spring, so the figure shrinks while a digit rolls in and never swells past its width, and typing never waits on an animation. The figure is content and sits on your ground; the limit pill rises in under it. Both follow Dynamic Type. - `.compact` is a 56pt liquid glass block (Liquid Glass on iOS 26, a frosted fallback before it) with a focus ring. Its limit pill buds down out of the block and rests joined to it, 6pt below, so a liquid neck holds the two; when the limit has had its say the words go, the butter drains and the pill melts back into the block. The pill's room in the layout opens and closes with it, so what sits below glides rather than jumps. `Style.field` tints the block; `.clear`, the default, leaves it neutral. - Type is one weight, semibold, for the caption, the figure and the pill; size alone sets the hierarchy. - The example's quick-amount chips are neutral glass capsules resting joined, so they read as one liquid strip; they write the binding like any external value. - VoiceOver reads the label and the full formatted amount as the value ("$1,234.56"), and the limit as a hint. `.disabled(true)` dims the field and blocks focus. `isFocused` lets you raise or dismiss the keyboard. - `Style` sets the block tint, amount, secondary label, caret, error (the pill's tint and the over-limit ring) and ink (the pill's text) colors, plus the compact block's height and corner radius. - Reduce Motion: no shake; each refusal dims the digits for a beat with the same haptic, digit changes crossfade, and the limit pill fades in and out in place, still clearing itself after a moment. Reduce Transparency draws the block and the pill as solid shapes. ### Parameters - `label`: Caption above the amount, also the accessibility label. Pass an empty string to hide the caption. - `value`: Bound amount. Never negative; external values are shown at the currency's precision (a negative or over-precise value is normalized and written back). - `currencyCode`: ISO 4217 code. Sets the symbol and the fraction digits (USD 2, JPY 0, KWD 3). Defaults to the current locale's currency. - `limit`: Largest amount that can be typed or pasted. A keystroke past it is refused with a shake (the digits dim for a beat under Reduce Motion), an error haptic and the limit message. `nil` means no limit. - `limitMessage`: Text of the pill shown when the limit refuses input or the value is over it. `nil` shows "Up to" plus the formatted limit; an empty string shows no pill. - `allowsDecimal`: Allow the decimal key. When `false`, amounts are whole units and the number pad has no decimal key. - `size`: `.hero` is a large centered figure for a payments screen; `.compact` is a 56pt liquid glass form-row block. - `isFocused`: Optional binding to read or drive keyboard focus, for example to raise the keyboard on appear. - `style`: Colors and metrics. Defaults to the SwiftPieces house palette, adapting to light and dark. ## Date Range Picker (`DateRangePicker`) URL: https://swiftpieces.com/docs/components/inputs/date-range-picker Category: Inputs. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: DateRangePicker.swift Install: npx swiftpieces add DateRangePicker A start-to-end date range picker on a paged month grid built from the environment calendar and locale, with the selection in liquid glass over the grid. A touch presses a small glass bubble into the day and the tap swells it into a red glass start bubble; the second tap lands the end the same way and the band pours out of the start along each week and into the end like liquid, joined to both bubbles by necks. Sold-out days are struck through, a refused end day swells into a butter bubble that shakes and melts away, a maximum length dims unreachable days, months page by swipe or a joined pair of glass chevrons that melt into each other at the ends, the month and summary morph letter by letter, and a glass chip reads out the dates and nights. Day-granular start/end range picker with a paged month grid. The selection is always two start-of-day dates in the environment `Calendar` and `TimeZone` (`upperBound` is the start of the last day, e.g. the check-out day for a stay). The first tap sets a start, the second closes the range (a day before the start becomes the new start), and a third tap starts over. A range can never contain a disabled or out-of-bounds day: an end tap that would span one, or that exceeds `maximumLength`, is refused with an error haptic, a butter bubble that flares and shakes on the tapped day and a short notice, and the start stays in place. The grid stays as it is and the selection is liquid glass over it: the start and end are tinted bubbles, and the band between them is liquid glass that joins them through a neck at each end and wraps across week rows. ### Notes - The selection is a `ClosedRange` of start-of-day dates in the environment `Calendar` and `TimeZone`. `upperBound` is the start of the last day (the check-out day for a stay). Dates you pass in are normalized to their day. - First tap sets the start and clears any old range, second tap sets the end, third tap starts a new range. Tapping a day before the pending start moves the start there. In `.nights` mode tapping the start again cancels it; in `.days` mode it confirms a one-day range. - The grid stays as it is and the selection is liquid glass over it. Touching a day presses a small clear glass bubble into it. The tap that makes it the start or end swells that bubble out to full size on a spring that springs a little past it, its red flooding in, so the press and the landing are one bubble; with no finger behind it (a VoiceOver tap, a range set from outside) it swells and fades in where it lands. A bubble whose day stops being an endpoint drains clear and shrinks away where it is, without bounce. Pressing a day that already has a bubble sinks it and it springs back with a little bounce. The day's number turns to dark ink as the red comes in, so it reads on the red. The glass is drawn behind the day buttons, so a press never dims or dents it. - A range never contains an unavailable day. An end tap that would span a day marked by `isDateDisabled` or outside `bounds`, or that exceeds `maximumLength`, is refused: the start stays, an `.error` haptic plays, the bubble under the finger swells into butter glass (`Style.error`) as the day shakes once, holds for a beat and melts away, and the summary chip turns butter with the notice ("Includes unavailable dates", "Up to 14 nights") for two seconds, or until the next tap closes or restarts the range. The flare and shake also mark the refusal with `showsSummary: false`. While a start is pending, days that cannot close the range fade to a dim, and fade back when it closes. - The grid comes from the calendar: `firstWeekday`, days per week, days in the month and the weekday offset all come from `Calendar` APIs, so Hebrew, Islamic, Ethiopic and other calendars lay out correctly. Weekday symbols are the calendar's very short standalone symbols; day numbers, month titles and the summary use `Date.FormatStyle` and `Date.IntervalFormatStyle` in the environment locale, including native digits. - Day math uses `Calendar.date(byAdding:)` and counts days on civil dates, never 86,400 second steps, so 23 and 25 hour DST days and time zones whose midnight is skipped stay exact. - The band is liquid glass that joins the start and end bubbles: it rests a few points short of each, so a liquid neck holds it to both (a red bubble and the neutral band blend through the neck), and it wraps across week rows as one capsule per week, with rounded ends at row and month edges. Each week row is its own liquid group, so the band in one week never melts into the next. When a range completes, the band pours out of the start bubble like one pen stroke: a drop that lengthens into the band, filling each week in turn as the pen reaches it, quick off the start and easing into the end bubble, in at most half a second, where the neck snaps in. The end bubble lands at once and never waits for the band. Starting a new range drains the old band back along the same path into its start on a spring with no bounce, while its bubbles drain and shrink away and the new start swells in. Offsets follow the layout direction, so right to left the grid, the band and the bubbles all flow right to left. - Months page with the chevrons or a horizontal swipe (a paging `ScrollView` with its native fling, which runs out to the card's edges so a month slides in from the edge), and the month title morphs letter by letter without overshoot. The chevrons are a joined pair of glass bubbles; at the first or last month the one that can't page melts into the other, and buds back out as soon as it can, each keeping its place. They scroll to the month on a short spring that settles with a little give. Paging stops at the months of `bounds`; without bounds it spans five years either side of today and the selection's month. - The summary shows the range in the locale's interval pattern ("Mar 4 – 9") and one glass chip: "Select end date" while a start waits for its end, the notice after a refusal (tinted butter), and the length in `counting` units once the range closes, pluralized with automatic grammar agreement. The chip morphs between them, its label changing letter by letter and its glass widening or narrowing around it. It pops with a bounce when a tap closes the range, on the beat of the success haptic; a range set from outside, which plays no haptic, lands it on a short spring instead. The dates morph letter by letter without overshoot. - Haptics: `.selection` when a start is set, `.success` when a range completes, `.error` on a refused end. One per tap; nothing plays on press. - VoiceOver: each day is a button labeled with its full date ("Tuesday, March 4", plus the year outside the current one, and "Today" first for today), with the `.isSelected` trait inside the range and a value of Start date, End date, In range or Unavailable. Month changes, completed ranges and refusals are announced. - Dynamic Type: day cells grow with `@ScaledMetric` from 44pt up to 76pt tall, and the bubbles and band with them; day numbers scale to accessibility sizes and shrink to fit rather than clip. The summary stacks when it no longer fits on one line, and at accessibility sizes the titles and the chip wrap and blur across instead of morphing. - Reduce Motion: no pour, swelling, shake, overshoot or morph. Bubbles, the chevrons and the band fade in and out where they rest, a pressed bubble shades instead of sinking (darker in light mode, lighter in dark), a refused day still flares butter, titles cross-fade, and out-of-reach days still fade. The swipe no longer slides the grid; both the swipe and the chevrons crossfade to the next month, and a short quick flick pages as well as a drag past 50pt. Reduce Transparency draws the glass solid. - `Style` sets the ground, day text, muted text, the glass tints of the band (neutral by default), the start and end bubbles (the signal red) and the month buttons (neutral), ink on tinted glass, the today dot, disabled days, and the butter tint of the notice and the refusal flare. `cornerRadius` no longer does anything: the bubbles are round and the band a capsule. `.disabled(true)` dims the picker and stops taps and paging. - For booking a time slot inside a day, see the slot picker in SwiftPieces Pro. ### Parameters - `selection`: Bound range, or `nil` while nothing (or only a start) is chosen. In `.nights` mode it stays `nil` until the end is tapped; in `.days` mode a lone start already reads as a one-day range. - `bounds`: First and last selectable days. Days outside are muted and inert, and paging stops at their months. `nil` allows any day and pages five years either side of today. - `isDateDisabled`: Marks single days unavailable (for example sold-out nights). Called with the start of each day; unavailable days are struck through and can never be inside a range. - `maximumLength`: Longest allowed range, counted in `counting` units. While a start is pending, days that cannot close the range are dimmed. - `counting`: `.days` counts both ends (Mar 4 to Mar 9 is 6 days, and a single day is allowed). `.nights` counts nights (5 nights, and the end must be after the start). - `showsSummary`: Show the header line with the formatted range and its length chip. - `style`: Colors. Defaults to the SwiftPieces house palette (red glass start and end bubbles with dark ink, joined by a neutral glass band), adapting to light and dark. ## Expanding Track (`ExpandingTrack`) URL: https://swiftpieces.com/docs/components/inputs/expanding-track Category: Inputs. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: ExpandingTrack.swift Install: npx swiftpieces add ExpandingTrack A thumbless liquid glass slider: a glass bar that swells from 8pt to a 32pt bar under the finger with a solid color fill inside, shows detent dots while dragging, buds a glass readout bubble up out of the bar that rides the held edge on a liquid neck and melts back in when you let go, squishes past either end, and can carry an uppercase label with a big numeral; a second init selects a range with two sinking handles. Thumbless liquid glass slider that swells under the finger. Bind a `Double` for a single value or a `ClosedRange` for a range. The bar is a glass shape with the fill inside it. While you drag an untitled track, the value buds up out of the bar on a glass bubble that rides the held edge, joined to the bar by a liquid neck, and melts back in on release. ### Notes - There is no thumb: the whole 44pt row is the hit area and the value follows the finger's x position directly, so the bar can rest at 8pt and still be easy to grab. Tracking is never animated: from the first touch, including a tap far from the current value, the edge of the fill sits under the finger. With a `step`, the value snaps from step to step beneath it while the edge stays with the finger, and on release the edge lands on its step with a little give. A VoiceOver step or a change from your code glides the fill (and the range handles) onto the new value on the same spring. - The bar is liquid glass carrying the fill as a solid block, with the detent dots and handles. While held it inflates to 32pt (`Style.activeHeight`) on a quick spring with no overshoot, and the dots at each `step` fade in a beat behind it, so they never show through a thin bar. On release they fade first and the bar settles back to 8pt with a little give. The swell runs on its own spring, apart from the fill edge, the squish and the landing, and a drag moves offsets and masks rather than resizing the fill. - Without a `title`, the value buds up out of the bar on a glass bubble the moment you touch it. The bubble rides the held edge, kept over the bar, and rests a liquid neck's width above it, so the two stay joined while you drag. Let go and it melts back into the bar, its number blurring away as it heads home. The bubble draws above the track's frame, so leave a little room above an untitled track. - Pass `title` for the labeled layout: an uppercase caption with the value as a big numeral above the track, decimals dimmed at the locale's separator. The numeral rolls to a new value after a programmatic or VoiceOver change, without overshooting it, and updates directly while you drag, so it stays legible under the finger. A titled track reads its value there, so it buds no bubble. - Pushing past either end, the bar gives toward the far end with rubber-band resistance, never more than about 24pt, and bulges slightly across, like silicone pressed against a stop. The value stays at the bound, and the readout bubble stays over the end of the bar as it gives. On release it springs back at the speed the finger was pushing. Push past the end again while it is still springing back and it eases onto the finger from where it is drawn instead of jumping. - Haptics: a selection tick per `step` crossing (every tenth of the span without a step), and a rigid impact when the value reaches a bound, in place of that step's tick. No haptic is added for the press or release itself; a touch-down that moves the value plays that change's tick or impact. The optional symbol lights while held and plays `.variableColor` when the value rises past a tick. - The range init keeps the handles apart by `minimumDistance` and lets the active ink handle sink into the fill; VoiceOver gets separate "Minimum" and "Maximum" adjustable elements, prefixed with the title when there is one. - A drag the system cancels lets go exactly like a release, so the bar never stays swollen and the bubble always melts back in. - `Style` carries the bar's glass tint (`trough`, neutral glass by default), the fill block, ink, labels and track metrics. `.standard` is the house palette (a brand red fill in neutral glass); pass `.init(fill:)` to use another block. All text is semibold. `.disabled(true)` greys the fill. - Reduce Motion: the bar stays at 8pt and its fill darkens a step while held (brightens in dark mode) instead of swelling, the readout bubble opens and closes in place above the held edge instead of budding, the ends stop the bar without any give, the edge lands on its step and glides to a new value on a short spring with no overshoot, and the numeral changes without rolling. Haptics, the symbol tint and its pulse are unchanged. Reduce Transparency draws the glass as solid shapes. - The drag starts on touch-down so the track responds to taps; inside a vertical `ScrollView` or sheet, a drag that begins on the track moves the value rather than scrolling. ### Parameters - `value`: Bound single value (single-value init). - `range`: Bound selected range (range init). The two handles never cross. - `bounds`: Minimum and maximum selectable values. - `step`: Optional snapping increment. Also sets the spacing of the selection ticks and detent dots; without it, ticks fire every tenth of the span. - `minimumDistance`: Smallest gap the range handles keep between them (range init only). - `symbol`: Optional leading SF Symbol that plays a variable-color pulse each time the value rises past a tick. - `title`: Optional label. Shows an uppercase caption with the value as a big numeral above the track (decimals dimmed), and becomes the VoiceOver label. A titled track reads its value there, so it buds no readout bubble. - `style`: Colors and track metrics. Defaults to the SwiftPieces house palette, adapting to light and dark. - `format`: Formats the readout and the VoiceOver value. Defaults to up to two decimals. ## Filter Rail (`FilterRail`) URL: https://swiftpieces.com/docs/components/inputs/filter-rail Category: Inputs. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: FilterRail.swift Install: npx swiftpieces add FilterRail A scrolling rail of liquid glass filter chips with optional counts, resting apart as their own bubbles. Single-select marks the pick with a brand red glass puck that stretches between chips, its leading edge first, melting into each chip it crosses while every label turns to dark ink exactly under its edge; multi-select picks take the red tint with a check, file to the leading edge, and a counting Clear chip buds out of the first pick and melts back into the rail when cleared; the chosen chip scrolls into view, and edges fade only when content overflows. Horizontal rail of liquid glass chips bound to a `Set` of selected titles. The chips are glass bubbles resting apart. Single-select marks the pick with a tinted glass puck that stretches between chips and melts into each one it crosses; multi-select tints each pick, and a Clear chip buds out of the first pick to clear them all. ### Notes - Every chip is a liquid glass capsule. Chips are separate choices, so they rest apart, outside the distance at which glass melts together, and only goo while something crosses or buds from them. - Single-select marks the pick with one glass puck tinted `Style.indicator`, the brand red by default. At rest the selected chip carries the tint itself, so it moves with the chip through any layout change, such as counts refreshing. On a new pick the puck lifts off and travels edge by edge: the edge facing the new chip leads on a snap spring and the far edge follows a beat later on a slightly looser one, so the puck stretches toward the pick (by about an eighth of the jump) and gathers in on it, melting into each chip it crosses on the way. The edges move in points, so the pace stays even across chips of different widths. It lands with a little give, each edge a few percent of the jump past its mark, and settles without wobbling, and a pick made while it is still landing retargets it smoothly. At the first or last chip the rail's end stops the leading edge, so the puck squashes against the end with its round cap intact. Once the far edge settles, the new chip takes the tint back. Tapping the selected chip again keeps it selected, so include an "All" option if you need a neutral state. - While the puck travels, every label is drawn twice, in the chip's ink outside the puck and in `Style.onIndicator` inside it, so a label turns exactly under the puck's edge as it passes instead of crossfading. If a `Set` with several titles reaches a single-select rail, the puck sits on the first of them in `options` order. - Multi-select tints each picked chip's glass and blurs a check into it. The tint comes on as the press springs back, so a pick clicks on in one motion. Tints come from `Style.blocks` by option position, so a chip keeps its color wherever it moves; the house palette is the one brand red, since a pick is a selection. Picks file to the leading edge first and the rest of the chips make room a beat after. A chip that travels far can carry its give a little into the gap as it lands, and slips under its neighbor rather than over: a new pick under the chip before it, a deselected chip under the chip after it. - The first pick buds a glass Clear chip out of the leading end of the first chip, into room the rail opens for it, with a count of the picks that rolls as it changes. Clearing lets every pick go together and the Clear chip melts back into the chip that slides to the front as the room closes. - `counts` adds a result count after each title; VoiceOver reads it as the chip's value. - Selection changes set `scrollPosition(id:)`, so the chosen chip centers itself (single) or the rail returns to its leading edge (multi). The scroll follows a beat behind on a calmer spring, so it never stacks with the puck into one lurch. - Edge fades are computed from the content frame in a named coordinate space, so they show only on the side that actually has chips hidden. The rail keeps a 44pt row; the glass's shadows draw above and below it uncut, so give it room in a tight stack. - Chips use the shared press: they sink a few points on touch-down with no bounce and spring back on release. Pressing the selected chip sinks the puck and its ink with it. All text is semibold. `.disabled(true)` dims the whole rail. One selection haptic fires for every change to the bound set, including changes made from outside. - Under Reduce Motion the puck slides on a short spring with no overshoot and no stretch, the scroll follows on that same short spring, the check fades and the Clear chip opens and closes in place instead of budding, and a pressed chip (or the puck) shades instead of sinking. The ink handover, the tints and the haptic stay. Reduce Transparency draws the glass as solid shapes. - For a searchable list of commands instead of a rail, see the Command Palette screen in SwiftPieces Pro. ### Parameters - `options`: Chip titles, in order. - `selection`: Selected titles. Single-select keeps exactly one once anything is chosen. - `allowsMultiple`: Allow several chips at once. Selected chips move to the leading edge and a Clear chip buds out of the first. - `counts`: Optional result count per title, shown after the chip title and read by VoiceOver. - `style`: Colors and chip height. Defaults to the SwiftPieces house palette, adapting to light and dark. ## Form Field (`FormField`) URL: https://swiftpieces.com/docs/components/inputs/form-field Category: Inputs. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: FormField.swift Install: npx swiftpieces add FormField A liquid glass text field whose label rests inside as a placeholder and glides up into a caption on focus or once filled, with an optional leading icon and help text. A clear bubble buds out of the field's end while you type, a character counter buds down out of it and turns butter at a grapheme-accurate limit, bumping when extra input is refused, and validation waits until you leave the field before drawing an error ring and budding a butter warning bubble with the message, an error haptic and a VoiceOver announcement, shakes the field once when Return or a form submit meets an error, and shows a quiet sage check once fixed. Text field with a floating label, help and error text, a character limit and a counter. The field is a liquid glass shape. Whatever appears because of it buds out of it and melts back in: the clear button out of its end while you type, the counter out of its bottom edge while it is focused or filled, and the warning bubble that carries an error message. The bubbles rest joined to the field, so a liquid neck holds them. Chain fields by applying `.focused($focus, equals: .email)` to each `FormField` (focus reaches the text field inside) and moving focus in `onSubmit`. Apply ``SwiftUI/View/formFieldRevealsErrors(_:)`` to a form to show every field's validation at once, for example after a submit button is tapped. ### Notes - The field is a liquid glass shape (Liquid Glass on iOS 26, a frosted fallback before it, a solid fill under Reduce Transparency). Everything that appears because of it buds out of it and melts back in, and rests joined to it, 6pt away, so a liquid neck holds the two: the clear button out of its end, the counter out of its bottom edge, and the warning bubble that carries an error. Each bubble is born inside the field, springs out pulling the neck thin, and goes home on a spring with no bounce, its glyph or label gone the moment it heads back. `Style.field` tints the glass; `.clear`, the default, leaves it neutral. - The label is one `Text` in `.body` that moves between two alignments and scales down into a reserved caption slot. The scale is the caption-to-body line-height ratio at the current Dynamic Type size, so the label lands in the same place at every size, including the accessibility sizes, with no font swap and no jump. - `prompt` shows only while the field is focused and empty, under the floated label. The system placeholder is suppressed so the label is never drawn twice. - The clear button is a round glass bubble as tall as the field. It buds out of the field's end while the field is focused and holds text: the field's trailing edge pulls back on the bud's own spring and the bubble parts from it through a neck, so the whole control keeps its width. It sinks slightly under the finger, clears at once and keeps the keyboard up, then melts back into the field. - `limit` counts extended grapheme clusters (`String.count`), so a flag, a family emoji or a letter with a combining accent is one character. Typing or pasting past the limit trims with `prefix(limit)`, which never cuts a character in half. The counter is `count/limit` on a glass capsule that buds down out of the field while it is focused or holds text, with localized digits that roll without overshoot. At the limit the capsule floods `Style.error` (butter) with ink digits, and a refused keystroke fires a light impact as the counter swells and rebounds, a value pushed against its bound. The counter's place is kept while it is home, so reaching the limit or the counter coming and going never shifts the help text; while the clear bubble holds the column beside the field, the counter moves in with the field's end so it stays under the field. The trim runs a frame after an over-limit edit, and the counter never shows that frame's count. With an IME that composes text (Japanese, Chinese), trimming can end a composition early when it runs past the limit. - Validation timing: `.onBlur` (default) waits until the field has been edited and left, `.onSubmit` waits for Return, `.live` starts with the first edit. After that the field validates on every keystroke, so the error clears the moment the text is fixed and a sage check blurs in quietly inside the field. Once validation has started the check keeps its slot while the field has text, so typing never reflows as the text turns valid or invalid. An empty field gives the slot back, so its resting label keeps the full width. An `error` passed from outside shows immediately and takes priority. - `formFieldRevealsErrors(true)` on a container arms every field inside, for a Save button that should flag untouched required fields. Each field holding an error as it turns on shakes once. It is a `Bool`, so a second tap while it is already on does not shake again. - When an error first appears the ring turns `Style.error`, the help text blurs away, and a butter bubble with an exclamation mark buds down out of the field's leading corner with the message arriving beside it a beat behind the ring; an `.error` haptic plays once. When the message changes while an error is showing, the new text blurs in place. When the error is fixed the message goes, the bubble melts back into the field, and the help text returns once it is home. - A refused submit shakes the field once, with the `.error` haptic on the same beat: Return pressed while the field has an error, or `formFieldRevealsErrors` turning on. The bubbles under it hold still, so their necks stretch with the shake. An error found while typing or when you leave the field never shakes; the ring and the warning bubble carry it. One haptic per event: a new error plays it as it appears, and a refusal of an error already on screen plays its own. A Return whose `onSubmit` also turns `formFieldRevealsErrors` on is one submit, with one shake and one haptic. - Focus: apply `.focused($focus, equals: .email)` to the `FormField` itself and move focus in `onSubmit`, with `submitLabel: .next`. Tapping anywhere on the field, including the icon and padding, focuses it. The ring, icon and label answer focus together on one short spring that lands with only a hint of give. - `axis: .vertical` grows up to eight lines. Return inserts a newline there, so `onSubmit` does not fire; put a multiline field last in a chain. The clear bubble sits level with the field's top. - `.emailAddress` and `.URL` keyboards (and email, URL and username content types) turn off autocapitalization and autocorrection. - Type is one weight, semibold, for the label, the text, the help, the counter and every glyph; size alone sets the hierarchy. - VoiceOver reads the label, the text with the counter ("12 of 160 characters") as the value, and the error or help as the hint. A new or changed error is announced. The clear bubble is a button labelled "Clear" with the field's label. The floating label, icon, check and footer are hidden to avoid double reading. - `Style` sets the field tint, label, secondary, focus ring, error, success and ink colors plus minimum height (44pt floor, also the clear bubble's size) and corner radius. `.disabled(true)` dims the field and calls the clear bubble home. - Typing never waits on motion: nothing animates the typed text, and the check turning on or off never moves the input or caret. - Reduce Motion: bubbles fade in and out in place instead of budding, the label still moves (it is the state change) but on a short spring with no bounce, the check and messages fade instead of blurring, and the counter fades between counts instead of rolling. A refused keystroke dims the counter for a beat instead of swelling it. A refused submit plays its haptic and dims the error ring for a beat instead of shaking, so a Return on an error already showing still shows on screen. The clear button dims instead of sinking. Reduce Transparency draws the field and bubbles as solid shapes. ### Parameters - `label`: The label. Rests inside the empty field as its placeholder, floats up into a caption on focus or once filled, and is the accessibility label. - `text`: Bound text. - `prompt`: Placeholder shown under the floated label only while the field is focused and empty, e.g. "you@example.com". - `help`: Supporting text below the field, shown whenever there is no error. Also the VoiceOver hint. - `leading`: Icon at the leading edge, e.g. `Image(systemName: "envelope")`. - `limit`: Maximum length in characters (extended grapheme clusters, so an emoji or accented letter counts as one). Typing or pasting past it is trimmed without splitting a character, and a "12/50" counter buds out of the field while it is focused or filled. - `axis`: `.horizontal` for one line; `.vertical` for a multiline field that grows up to eight lines, where Return inserts a newline. - `validation`: When `validate` starts running. `.onBlur` (default) waits until the field has been edited and left once, then validates live. `.onSubmit` waits for Return. `.live` starts with the first edit. - `validate`: Returns an error message for invalid text, or `nil` when valid. A sage check appears once the text is valid. - `error`: An error from outside the field (e.g. "Email already in use" from a server). Shown immediately and ahead of `validate`. - `textContentType`: Autofill hint, e.g. `.emailAddress`, `.name`, `.oneTimeCode`. - `keyboardType`: Keyboard. `.emailAddress` and `.URL` also turn off autocapitalization and autocorrection. - `submitLabel`: The Return key label, e.g. `.next` or `.done`. - `onSubmit`: Called when Return is pressed in a one-line field. Move focus to the next field here. - `style`: Colors and field metrics. Defaults to the SwiftPieces house palette, adapting to light and dark. ## Location Picker (`LocationPicker`) URL: https://swiftpieces.com/docs/components/inputs/location-picker Category: Inputs. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: LocationPicker.swift Install: npx swiftpieces add LocationPicker A map picker for delivery, rides, events and listings: drag the map under a fixed red pin that lifts while the map moves and, when it settles, drops and presses into place with a light tap, the address under it is looked up after a short pause with stale answers cancelled and recent ones cached. The chrome over the map is liquid glass: a floating glass card shows the street and locality, morphing letter by letter as it changes (a dropped pin with coordinates where there is no address, and a Retry bubble that pinches off its end when the lookup fails), the glass locate button asks for location only when tapped and flies to you, and the red Confirm capsule buds down out of the card once the spot has its answer and melts back into it while the map moves. A map you drag under a fixed pin to choose an exact spot, with the address of wherever the pin lands. The pin stays in the middle of the visible map. While the map moves it lifts and its shadow spreads; when the map settles it drops and presses into place, `selection` is set to the coordinate, and the address is looked up after a short pause. A newer settle cancels the lookup in flight, so an address never lands on the wrong spot, and recent answers are cached by position (about 5 m), so nudging back costs no request. Location permission is only requested when the locate button is tapped. ```swift @State private var place: LocationPicker.Place? LocationPicker(selection: $place, initialCenter: store.coordinate) { place in order.dropOff = place } ``` ### Notes - The picker takes the size it is given, at least 320 pt tall: full screen, in a sheet, or `.frame(height: 340).clipShape(.rect(cornerRadius: 26))` inside a form. Bind `selection`, and pass `onConfirm` for the Confirm button. Add `NSLocationWhenInUseUsageDescription` to your Info.plist for the locate button. Full screen, the map already runs under the status bar and the home indicator while the card and the locate button stay clear of them, so leave out `.ignoresSafeArea()`, which would push both under the system bars. - The pin stays in the middle of the map the card leaves visible. While the map moves it lifts 12 pt, its shadow spreads and a small mark stays on the exact spot. Picked up by your drag, it springs about a point past its lift and settles; a flight (locate, a VoiceOver move, a new selection) lifts it on a short spring. When the map settles it drops on a spring that never overshoots, so the tip stops on the spot instead of sinking through it, then gives a little from the tip as it meets the ground and springs back into shape, with a light tap on that beat. Only a real change of the spot under the pin lifts it, so the camera reports MapKit sends on appear, on rotation and on resize do nothing. - `selection` is set when the pin settles (coordinate only) and again when the address arrives. Its coordinate is always where the pin points; the address is the nearest one found. Setting `selection` from outside, a search result or a saved place, moves the map there, and a place that already has an address is shown without a lookup. - The address is looked up 300 ms after the map settles. A new settle, or leaving the screen, cancels the lookup in flight and its request, and an answer for a spot that is no longer under the pin is dropped. Answers are cached per picker by position rounded to about 5 m (the 40 most recent), so nudging back to a known spot sends nothing, which also keeps clear of the geocoder's rate limit. - iOS 26 uses `MKReverseGeocodingRequest`, reading the street and postal code from the map item's placemark (dynamically, as Address Field does) with `MKAddressRepresentations` as the fallback. iOS 17 to 25 uses `CLGeocoder`, kept in a deprecated function that only runs below iOS 26, so the file builds without warnings on both. - The card is a floating liquid glass panel. It has one state per moment: the last address (or "Dropped pin" until one has been found) dimmed with "Locating…" from the moment the map moves until the next address arrives (the "Locating…" line breathes once the pin is down), loading lines only for the first lookup and for Retry, street and locality once found, "Dropped pin" with the coordinates where there is no address (open water), and "Couldn't find the address" with the coordinates when the lookup fails (offline, throttled). Every state keeps the same two lines, and the readout changes once per move: each line morphs letter by letter as the address arrives, so letters the old and new lines share hold still and a nudge along a street moves only the house number (a line too long to fit shrinks to fit and blurs across instead). The card keeps its tallest size and the map's center only moves when it grows, so nothing jumps as the text changes. - When the lookup fails, a glass Retry bubble pinches off the card's trailing end: the card gives up that width and Retry springs out of its end, joined to it by a liquid neck. It melts back into the card when tapped or when the map moves. - Confirm is a red glass capsule resting apart below the card. It buds down out of the card once the spot under the pin has its answer (its address, no address, or a failed lookup), born inside the card and pulling a liquid neck that thins and snaps as it springs to its place, and melts back up into the card on a spring with no bounce, its red draining as it goes, the moment the map moves. Its place keeps its height while it is away, so the card and the map's center never move. A Retry keeps it out; tapped while that lookup runs, it spins and waits for the address (up to four seconds), then hands over the place; after a failed lookup it hands over the coordinate alone. Return confirms with a hardware keyboard. Leave out `onConfirm` to hide it and read `selection` yourself. - The locate button asks for When In Use permission on its first tap, never on appear. It spins while finding you, flies the camera to you, and fills in once the pin sits on you; a fix from the last minute is used at once. When your location can't be found it shakes once, on the beat of the error haptic. Camera flights take longer the farther they go: about 0.55 s for a VoiceOver nudge or zoom, up to 1.2 s across town. With approximate location the map shows the area the fix covers instead of a street. When access is denied the arrow is slashed and a tap explains and offers Settings; when it is restricted a tap explains. Dragging works the same without permission. - Where the map starts: the place in `selection`, else `initialCenter`, else the person's location when the app already has permission, else central Lisbon. - MapKit's logo and legal notice stay visible above the card: the card and Confirm's place inset the map's safe area, which is also where MapKit puts the camera's center. - VoiceOver: the address readout is one element, "Selected location", with the address, "Dropped pin" and the coordinates, "Locating" or "Finding the address" as its value. Its actions move the map a quarter of the visible span north, south, east or west, zoom in and out, use your location and retry. After the map settles from a move the new address is announced. The pin is decorative and hidden; the locate button reads its state. - Haptics: a light tap as the pin meets the ground (at once if the map moves again before it lands, so every settle gets one), success when Confirm hands over the place, a selection tick on Retry, and an error when Retry or locating fails. Nothing while panning, and nothing for a lookup that fails on its own. - Dynamic Type: the card's text grows and wraps up to accessibility sizes (where it no longer morphs), Retry stays joined to the card's end, Confirm stays full width, and the locate button grows up to 64 pt. Right to left, the card mirrors and the locate button sits top left; the map is never mirrored. - Motion: Retry, Confirm and the locate button sink a couple of points under the finger and spring back with a little bounce, and the locate arrow blurs into its filled form, as the spinner and Confirm's label do into each other. The card changes its text in place without moving, and the map's own panning and zooming stay MapKit's. The pin and the map are content and stay as they are; only the chrome over them is glass. - Reduce Motion: the pin dims instead of lifting and lands without the press, its tap playing as the map settles; the camera jumps instead of flying; Confirm and Retry fade in and out where they rest instead of budding, and the readout cross-fades instead of morphing; the loading lines and "Locating…" still breathe in opacity, so a lookup never looks frozen; buttons shift brightness instead of dipping when pressed; and a failed locate blinks the glyph instead of shaking. Haptics and announcements are unchanged. Reduce Transparency draws the glass solid. - The map pans and zooms but doesn't rotate or tilt, so north stays up. A drag that starts on the map pans it, inside a `ScrollView` or a `Form` too; in a sheet, the grabber still dismisses. - `Style` sets the glass tint of the card, Retry and the locate button (`surface`, neutral glass by default), label, secondary label, the loading bars (`field`), the pin and its ink, Confirm's tint and its ink, the card's corner radius, points of interest (off by default, so the streets and the pin read first) and the muted map (on by default). `Messages` replaces any line, for example `confirm: "Deliver here"`. `Geocoder` swaps MapKit for your own backend, or a fixed answer for previews and tests. ### Parameters - `selection`: The picked place. Set when the pin settles (coordinate only) and again when its address arrives. Setting it from outside moves the map there; a place that already has an address is shown without a lookup. - `initialCenter`: Where the map starts when `selection` is `nil`. `nil` starts at the person's location when the app already has permission, otherwise in central Lisbon. - `span`: How much map shows around the pin at first, in meters across. Defaults to 500, street level. - `showsLocationButton`: Shows the locate button in the top trailing corner. It asks for When In Use permission on its first tap, never before. - `geocoder`: Where addresses come from. `.mapKit` (the default) uses `MKReverseGeocodingRequest` on iOS 26 and the system geocoder before it. Pass your own for a backend, previews or tests. - `messages`: The card's copy. Defaults are localizable through your String Catalog; replace any line, for example `confirm` with "Deliver here". - `style`: Colors, the card's corner radius and the map's look. Defaults to the SwiftPieces house palette, adapting to light and dark, with the red pin and Confirm. - `onConfirm`: Called with the place when Confirm is tapped. Tapped while the address is still on its way, Confirm waits for it (up to four seconds) and then hands over what it has. `nil` hides the button, for pickers inside your own form. ## Range Slider (`RangeSlider`) URL: https://swiftpieces.com/docs/components/inputs/range-slider Category: Inputs. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: RangeSlider.swift Install: npx swiftpieces add RangeSlider A liquid glass dual-thumb slider: two glass bubble thumbs joined by a tinted glass span over a quiet line, with a glass readout chip joined above each thumb on a liquid neck; drag either thumb or touch the track to pull the nearest one over, the thumbs stay under the finger and land on their step when you let go, values snap to a step without floating drift, the thumbs never cross and keep a minimum gap, a thumb pulled past the end of the track gives with rubber-band resistance and springs back, a grabbed thumb swells and its chip takes the span's tint, as the thumbs meet one chip melts into the other to read as one range, and every step ticks a selection haptic with a firmer impact at a bound or the gap. Liquid glass dual-thumb slider that binds a `ClosedRange` of any floating-point type. The thumbs are glass bubbles and the selected span is tinted glass joining them, so the three move as one liquid shape. Each readout chip rests joined above its thumb; when the two would touch, one melts into the other and the survivor reads the whole range. The simplest call is one line: `RangeSlider(value: $price, in: 0...1000, step: 10, format: .currency(code: "USD").precision(.fractionLength(0)))`. Pass a `FormatStyle` or a `(V) -> String` closure as `format`; it drives the readouts and the VoiceOver values. ### Notes - Generic over any `BinaryFloatingPoint` (`Double`, `Float`, `CGFloat`). Bind a `ClosedRange`; pass `format:` as a `FormatStyle` (`.currency(code: "USD").precision(.fractionLength(0))`, `.percent`) or a `(V) -> String` closure. The format drives the readouts and the VoiceOver values. - The slider is one liquid glass shape: the thumbs are clear glass bubbles, and the selected span is tinted glass resting a liquid neck's width from each of them, so it joins them through necks. When the thumbs come close the span gives way and they neck straight into each other. Outside the span the thumbs ride a quiet line that is not glass, so nothing else merges with them. - Drag either thumb, or touch the track and the nearest thumb glides to the finger in one quick move, landing with a little give, while your finger's own movement stays 1:1 on top of it. Grabbing a thumb keeps the finger's offset, so it never jumps under the touch, even while it is still landing from the last release. When both thumbs sit on the same point, the first clear movement decides: right (in left-to-right) takes the upper thumb, left takes the lower. - Tracking is never animated: the thumbs follow the finger directly, and only the glide from a track touch and the release spring. Each edge of the span rides its own thumb, so the span stays joined to both through any glide or release. A grabbed thumb lifts: it swells on a quick spring with no overshoot, and drops back with a little give when you let go. - The thumbs never cross. They stop `minimumDistance` apart, kept on the step grid (a 5 gap with step 10 stops at 10). Against the other thumb a thumb stops firm, with no give, and a touch on the track that lands there arrives without overshoot. - Past either end of the track the thumb gives with rubber-band resistance, never more than about its own width (24 to 32pt), while the value stays at the bound. On release it springs back at the speed it was moving. While pulled it draws past the slider's edge, so leave room beside it rather than clipping it. - `step` snaps from `bounds.lowerBound`. Both bounds stay reachable when the span is not a multiple of the step (0...1000 by 30 reaches 1000). Values are rounded to the step's decimal places, so `0.1` steps write `0.3`, not `0.30000000000000004`. Out-of-bounds or reversed bindings are shown clamped and written back clean on the first change. While you drag, the value moves step by step and the thumb stays under your finger; when you let go anywhere on the track it snaps onto its step from rest, so its give is a sliver of the distance left and never reaches a neighboring value. Only a thumb pulled past the end keeps its speed as it springs back. - Readouts: `.thumbs` (default) rests a glass chip a liquid neck's width above each thumb, joined to it and clamped inside the track. A grabbed thumb's chip takes the span's tint a beat after the thumb lifts. When the two chips would come within 6pt, the right one melts into the left, which widens to read "lower – upper" around the thumbs' midpoint, and it buds back out once they are 12pt apart again, so a thumb resting at the threshold never flickers. `.header` prints "lower – upper" above the track and stacks the values at large text sizes; after a VoiceOver step or an animated change from your code its digits roll without overshoot. `.hidden` shows nothing. - Haptics: `.selection` on each value change while dragging (every 5% of the span without a step), throttled to about 30 per second; a rigid `.impact` once when a thumb reaches a bound or the minimum gap. Nothing plays on press, release or the landing on a step. - Right-to-left: the track mirrors, so the lower thumb sits on the right, and the merged chip reads in the reader's direction. - VoiceOver: two adjustable elements, "Minimum" then "Maximum" (set `lowerLabel` and `upperLabel`, e.g. "Minimum price"). Swipe up or down moves one `step` to the next valid value, or 1% of the span without a step, and the thumb, the span's edge and the chip glide there on the snap spring, landing with the same give as a release. - Thumbs keep a 44pt touch and VoiceOver target even when `Style.thumbSize` draws them smaller. Readouts use text styles and grow with Dynamic Type; all text is semibold. - `Style` sets the line, the span's tint (`fill`, the brand red by default), the thumbs' and chips' glass tints (neutral glass by default), ink, labels, track height and thumb size. `thumbBorder` no longer draws anything: the thumbs are glass bubbles with no ring. `.disabled(true)` desaturates, dims and blocks input. - Reduce Motion: a grabbed thumb keeps its size and takes the span's tint instead of swelling, and its chip still fills; the ends of the track stop the thumb without rubber-band give, and glides and landings settle without overshoot. Thumbs follow the finger directly either way. As the chips merge, the right one closes where it is instead of melting across, and the header's digits crossfade instead of rolling. Reduce Transparency draws the glass as solid shapes. - The drag starts on touch-down so the track responds to taps; inside a vertical `ScrollView` or sheet, a drag that begins on the slider moves the slider rather than scrolling. - For a full filter sheet built around it, see the filter sheet in SwiftPieces Pro. ### Parameters - `value`: Bound selected range. Values outside `bounds` are shown clamped; the first change writes a clean, in-bounds range back. - `bounds`: Minimum and maximum selectable values. Both are always reachable, even when the span is not a multiple of `step`. - `step`: Optional snapping increment, measured from `bounds.lowerBound`. Snapped values are rounded to the step's decimal places, so `0.1` steps give `0.3`, never `0.30000000000000004`. Without a step the values are continuous. - `minimumDistance`: Smallest gap the two values keep. The thumbs stop against each other at this distance instead of crossing. Capped at the width of `bounds`. - `readout`: Where the values print: `.thumbs` (glass chips joined above each thumb that melt into one when they would touch), `.header` (a "lower – upper" line above the track), or `.hidden`. - `lowerLabel`: VoiceOver label of the lower thumb. Defaults to "Minimum". - `upperLabel`: VoiceOver label of the upper thumb. Defaults to "Maximum". - `style`: Colors and metrics. Defaults to the SwiftPieces house palette (a brand red span joining neutral glass thumbs over a quiet line), adapting to light and dark. - `format`: Turns a value into the text shown in the readouts and spoken by VoiceOver. A `FormatStyle` (such as `.currency(code:)` or `.percent`) or a closure. Defaults to a plain number with as many decimals as `step` has (up to two without a step). ## Scrub Stepper (`ScrubStepper`) URL: https://swiftpieces.com/docs/components/inputs/scrub-stepper Category: Inputs. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: ScrubStepper.swift Install: npx swiftpieces add ScrubStepper A liquid glass stepper: round minus and plus bubbles joined to a glass numeral pill by liquid necks. Tap, hold to repeat with acceleration, or scrub sideways across the pill for velocity-scaled steps while a ruler slides under the finger; at a bound that button melts into the pill and the pill strains against the stop, and the button buds back out the moment it can be used. Liquid glass minus/plus stepper with hold-to-repeat and a horizontal scrub gesture. The three parts rest joined, so liquid necks hold them together. At a bound the button that can no longer act melts into the numeral pill, and it buds back out of the pill as soon as the value can move that way again. The buttons keep their place in the layout while they are away, so the stepper never changes width. ### Notes - One `DragGesture(minimumDistance: 0)` covers the whole capsule and decides by start location: the round buttons at each end press, the numeral block in the middle starts a scrub. A press that moves more than 14pt sideways becomes a scrub; one that leaves the capsule by more than 20pt cancels without stepping. - Pressed buttons invert (glyph color fills the circle) and sink to 0.86 on a quick spring with no overshoot, then spring back with a little give when you let go; a button at its bound dims. A tap bounces its glyph; a hold or a scrub steps too fast for a bounce to read, so they don't. - Hold-to-repeat starts after 400ms and accelerates from 100ms to 60ms after 1.5s and 30ms after 3s. It runs in a `Task` that is cancelled on release, on cancel (including a touch the system takes over), on disappear, and when a bound is hit. - Scrubbing consumes one step per 14pt of travel, multiplied by a velocity gain of up to 4x. The value stops when your finger does; nothing coasts. While scrubbing, the block lifts (slightly larger, on a soft shadow) and a ruler fades in a beat later and runs with the finger, faster on a flick. The lift is visual only, so the capsule's layout never changes under the finger. - Digits roll on a tap, a VoiceOver step or a change from your code, landing without overshoot. While a hold repeats or a scrub steps they change in place, so they stay readable. - Every step ticks a `.selection` haptic, throttled to one per 35ms during fast repeats and scrubs. A scrub step that lands on the bound and hits the stop in the same moment fires only the stop's impact. - At a bound, a tap, a repeat tick or a VoiceOver step fires a rigid impact and knocks the block 4pt toward that end and back. A scrub that keeps pushing pulls the block against that end with rubber-band resistance, about 4pt at most, while the ruler stops and the value stays at the bound; the impact fires once each time it reaches the stop, and on release the block springs back at the speed it was moving. The stop holds back no more than 14pt of travel however far you push, so coming back the block relaxes within those 14pt and the value steps one step's travel later. - `Style` sets the trough, buttons, glyph, the numeral block and its ink, and the height (44pt minimum, 52pt by default). `.standard` uses the butter block; pass `.init(block:)` for another. - Supply an `.accessibilityLabel`; the value and adjustable action are provided. - Reduce Motion: no press scale, no lift scale, no glyph bounce and no block movement at a bound. The buttons still invert, the lifted block keeps its shadow, the ruler still runs, digits cross-fade instead of rolling, a bound darkens the block for a beat instead of moving it, and every haptic stays. ### Parameters - `value`: Bound integer value. - `range`: Allowed values. Pushing past a bound fires a rigid impact and knocks the numeral pill against that end; a scrub pulls it there with rubber-band resistance. - `step`: Amount added or removed per tap, repeat tick, or scrub unit. - `style`: Tints and size. Defaults to the SwiftPieces house palette (signal buttons around a neutral glass pill), adapting to light and dark. ## Secure Entry (`SecureEntry`) URL: https://swiftpieces.com/docs/components/inputs/secure-entry Category: Inputs. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: SecureEntry.swift Install: npx swiftpieces add SecureEntry A liquid glass password field with a focus ring and a round glass reveal bubble joined to it that keeps focus. Typing buds a strength chip out of the field that morphs its label from weak to strong beside a growing gauge, requirement chips bud out of the field and turn sage with drawn checks as each rule passes, and when all pass the field takes a sage tint with a check while the chips melt back into it; a refused entry shakes the field, tints it butter and buds an error chip joined below it. Liquid glass password field with strength meter, live requirements, and inline error. The field is a glass shape with a round glass reveal bubble joined to it. Everything below it buds out of the field when it appears and melts back into it when it goes: the error chip, joined just under it, and, apart from it, the strength chip and the requirement chips. ### Notes - The field is liquid glass, with a round glass reveal bubble joined to its end by a liquid neck. Revealing swaps `SecureField` for `TextField` instantly; the toggle re-targets `@FocusState` to the new field so the keyboard stays up, and its eye glyph blurs into the other. The bubble sinks under the finger. - Focus draws a 2pt ring around the field. The field's glass carries its state: `Style.error` (butter) while there is an error, `Style.success` (sage) once every requirement passes, neutral otherwise. Text on a tinted field turns to dark ink. - Everything below the field lines up with it and buds straight down out of it when it appears, then melts back into it when it goes, while the room it takes opens and closes on the same springs, so whatever sits below moves with it instead of jumping. - Setting `error` from `nil` shakes the field once with an `.error` haptic on the same beat, tints it butter, and buds a butter error chip out of its bottom edge to rest joined just under it. A new message for an error already showing blurs into the new wording in place, with no second shake or haptic, so live validation never buzzes per keystroke. To signal a later refusal, set `error` to `nil` when the user edits, so the next refused submit arrives from `nil` and shakes again. Clearing it melts the chip back into the field as the tint drains. VoiceOver hears each new or reworded message once, as an announcement. - The strength chip buds out of the field as typing starts and rests apart below it, beside a gauge that fades in. Its label morphs letter by letter from WEAK through FAIR and GOOD to STRONG, and the chip and gauge take the level's tint: butter while weak or fair, sage once good. The gauge changes length without overshoot, so the reading never wobbles. - Requirement chips bud out of the field while it is focused or filled, two to a row, resting apart. A met requirement clicks into sage glass with a small dip and swell, its check drawing in after the tint lands, with a `.selection` haptic. When all four pass, the field turns sage and a check blurs into it with a `.success` haptic in place of the tick and the pop of the last chip, and the chips stay a beat so the last one lands, then melt back into the field. Leaving the field melts them at once, and losing a rule during that beat keeps them. - `submitLabel` and `onSubmit` set the Return key and what it does, the same whether the password is hidden or revealed. Leave `submitLabel` as `nil` to keep a label set around the field. To chain fields, apply `.focused($focus, equals: .confirm)` to each `SecureEntry` (focus reaches the text field inside) and move focus in `onSubmit`. - `Style` sets every tint plus the field's minimum height and its radius. The field grows past that height when larger text needs it, and the reveal bubble grows with it, a circle as tall as the field on the same centre line. At accessibility text sizes the strength chip moves under its gauge, the requirement chips stack one to a row and wrap long labels, and a chip whose text wraps takes rounded corners in place of capsule ends. All text is semibold. `.disabled(true)` dims the field. - Reduce Motion: no shake and no press scale (the reveal bubble shades instead), the chips open and close where they rest instead of budding, the morphing label and the swapped glyphs crossfade, and met chips change tint without the pop. The tints, the drawn checks and every haptic remain. Reduce Transparency draws the glass as solid shapes. - For one-time codes after sign-in, see the Verify screen (code entry) in SwiftPieces Pro. ### Parameters - `label`: Static caption above the field, also the placeholder and accessibility label. - `text`: Bound password text. - `error`: Validation message. Setting it from `nil` shakes the field once with an error haptic, tints it with `Style.error`, buds an error chip out of its bottom edge, and announces the message to VoiceOver. A new message for a standing error swaps in place and is announced, without a second shake; clear it to `nil` on edit so the next refused submit shakes again. - `showsStrength`: Show the strength gauge and its label chip once text is non-empty. - `showsRequirements`: Show the 8+ / number / symbol / mixed-case chips while focused or filled. When all four pass the field takes the success tint with a check, and the chips melt back into it a beat later. - `submitLabel`: The Return key label, e.g. `.go` or `.next`, hidden or revealed. `nil`, the default, keeps the label set around the field, `.return` unless one is. - `onSubmit`: Called when Return is pressed, hidden or revealed. Submit the form or move focus to the next field here. - `style`: Colors and field metrics. Defaults to the SwiftPieces house palette, adapting to light and dark. ## Signature Pad (`SignaturePad`) URL: https://swiftpieces.com/docs/components/inputs/signature-pad Category: Inputs. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: SignaturePad.swift Install: npx swiftpieces add SignaturePad A signature field that lays smooth, velocity-weighted ink into a Canvas as the finger moves, thinning on quick flicks and pooling on slow turns, over a baseline whose Sign here hint fades on the first stroke. Under the paper floats one liquid glass toolbar: Undo buds out of one end of the Type instead pill and Clear out of the other the moment they can act, joined to it by liquid necks, and melt back into it when they can't; the pill morphs to Draw instead, and a sage glass Signed chip lands once the signature counts. Undo after Clear, strokes that rescale with the pad on rotation, and export to a transparent PNG or a vector PDF. Signature capture that feels like ink, bound to a `SignaturePad.Signature` value you own. Strokes are smoothed through midpoint quadratics and weighted by speed (fast strokes run thin, slow turns run full), drawn in a `Canvas` one layer per stroke so finished strokes never redraw while you sign. Points are stored in the pad's coordinate space together with the pad size they were captured at, so the signature rescales with the pad on rotation and exports at any size. A drag that starts in the pad draws and never scrolls an enclosing `ScrollView`; a drag that starts outside still scrolls. ```swift @State private var signature = SignaturePad.Signature() SignaturePad(signature: $signature) Button("Confirm delivery") { upload(signature.pngData(size: CGSize(width: 600, height: 200))) } .disabled(signature.isEmpty) ``` ### Notes - Ink is weighted by speed, the way a pen behaves: each sample's width falls from `Style.lineWidth` toward about half of it as the finger speeds up, eased over 35 ms so strokes swell and thin smoothly. The line runs through the midpoints of the touch samples as quadratic curves, cut into short pieces whose widths interpolate, so there are no corners or width steps, and long gaps from very fast strokes are bridged by the same curves. - Each stroke is its own `Canvas` layer, grouped into a few paths by width. Finished strokes never redraw while you sign, so long signatures stay smooth. - A tap draws a dot. `isEmpty` stays `true` until there is at least `Signature.minimumInk` of ink (12% of the pad width, about 40pt on a phone) or a typed name, so a stray tap or speck never enables your submit button. `strokes.isEmpty` tells you whether anything is on the pad at all, and `inkLength` lets you set a stricter rule. - Points are stored in the pad's coordinates together with the pad size they were captured at (`canvasSize`). When the pad changes size (rotation, split view, a different `Style.height`) the signature scales uniformly, anchored to the bottom and the leading edge so it stays on the line next to the cross. Strokes added after a resize join the same space. - Undo removes the last stroke with a short fade and a rigid tick. Clear fades out the whole signature at once with a medium tick, and Undo right after Clear fades it all back in, until anything else changes. A soft tick marks the first touch of each stroke. With a hardware keyboard, Command-Z undoes and Command-Delete clears. - The ink itself is never animated: each stroke draws under the finger as it moves and stays exactly as drawn when the finger lifts, so the export matches the screen. Only the chrome around it moves, and quietly. - The "Sign here" hint fades and drops a few points on the first touch, and settles back in when the pad is empty again. - Once the signature counts and the pad has been still for a moment, a small sage glass Signed chip pops in at the far end of the status line under the baseline, where the hint sat, with a bounce and a success tap, the first time for each signature. When it comes back after a later pause it lands with just a little give and no tap, so a signature written in parts doesn't celebrate on every pause. Ink and a typed name arm apart: the tap re-arms once the ink no longer counts or the name is blank, so Type instead and back never replays it for the same strokes, and a draft passed in already signed doesn't tap on appear. It steps out quickly when the finger comes back, and a stroke that starts under it still draws. - The toolbar is one liquid glass shape floating centred under the paper, on the page rather than on the paper, where clear glass would all but vanish. Type instead is the pill in the middle; Undo (a round bubble) rests joined to its leading end and Clear (a capsule) to its trailing end, so liquid necks hold the three together and they read as one control. Each bubble is there only while it can act. The moment there is something to take back or clear, it buds out of its end of the pill, born inside it and springing to its place; when there is nothing left to undo or clear it melts back into the pill on a spring with no bounce, its label going first. Clear melts home after Clear, while Undo stays out to bring the signature back. Both side slots keep their width while a bubble is away, so the pill never moves. - The bubbles sink about 1.5 pt a side under the finger and spring back with a little bounce, and their labels dim while pressed. Glass is neutral; `Style.control` tints it. - A drag that starts in the pad draws from the first touch and never scrolls an enclosing `ScrollView` or `Form`; a drag that starts outside the pad scrolls as usual. If the system cancels a touch (a call, a presented sheet), the stroke so far is kept. - Type instead turns the line into a text field set in a script face (Snell Roundhand, a system font, so nothing is bundled). The pill's label morphs letter by letter to Draw instead and its glyph blurs from the keyboard to the signature; Undo melts home, since typing has nothing to undo, and Clear buds back out beside the pill once there is a name to clear. The ink fades out first and the field follows a beat behind, so strokes and the name barely overlap; the field takes focus and the keyboard rises at once. Draw instead brings the ink back with a short, firm fade, at full strength about 0.3 s after the tap. The name sits on the same baseline and shrinks to fit when it is long. Scripts the face lacks fall back to the system font. Drawn strokes are kept while typing, so Draw instead brings them back; while `typedName` is non-nil it is the signature for `isEmpty` and export. - Export: `image(size:scale:ink:lineWidth:trimmed:)` and `pngData` give a transparent PNG, and `pdfData` gives a one-page vector PDF. `trimmed` (the default) crops to the ink so the signature fills the image; `false` keeps the whole pad area with the signature where it sat. Ink defaults to black, whatever the on-screen appearance. A typed signature exports in the same script face. Both can be called off the main actor. - `Signature` is `Codable` and `Equatable`, so a draft can be saved and restored, and `.onChange(of:)` sees every stroke. - VoiceOver: the pad reads "Signature" with a value of Empty, Too short to count as a signature, or Signed. Double-tapping lets a VoiceOver user draw directly on it (direct touch), and the pad offers Type your name instead, Undo and Clear as actions. Undo, Clear and restores are announced. The typed field reads "Signature, typed name". - Reduce Motion: the hint and the Signed chip fade without moving, Undo and Clear fade in and out where they rest instead of budding, the mode label cross-fades instead of morphing, and the controls shift brightness instead of sinking (their labels still dim). Reduce Transparency draws the glass solid. Strokes and mode switches are fades either way; the mode hand-off becomes a quick crossfade. Haptics and announcements are unchanged. - Dynamic Type: the toolbar controls scale, and Undo and Clear move to a second row under Type instead when they no longer fit on one line; they bud down out of the middle of the pill there. The hint grows up to accessibility sizes and shrinks to fit under the line; the cross and baseline keep a fixed position so they stay aligned with stored strokes. - `allowsTyping: false` removes Type instead. It is the accessible path for people who cannot draw a signature, so turn it off only when your flow offers another way to sign. - `Style` sets the pad, ink, label and secondary colors, the glass tint of the toolbar bubbles (`control`, neutral by default) and of the Signed chip (`signed`), ink weight, corner radius and the paper's height (`nil` fills the height offered, for a full-screen signing sheet). `.disabled(true)` dims the pad and stops drawing. - **v1.2:** the pad is one card. Type instead sits at the leading end of a toolbar along the top, Undo and Clear at the trailing end, and a hairline separates the toolbar from the drawing area. Ink is clipped to the drawing area, so a stroke carried up past the hairline never draws over the controls. Under the baseline is a status line: the hint while the pad is empty, the Signed chip at its far end once the signature counts. - **v1.3:** heavier ink. `lineWidth` defaults to 5pt (was 3.6) for drawing and for `image`, `pngData` and `pdfData`, and fast strokes thin to about half the width instead of about 40%, so a quick signature still reads as a confident pen line. - **v1.4:** liquid glass. Type instead, Undo and Clear are glass bubbles in one cluster at the leading end of the toolbar; Undo and Clear bud out of Type instead when they can act and melt back into it when they can't, instead of dimming. The Signed chip is sage glass. All text and symbols are semibold. - **v1.5:** the paper is the whole card, with a hairline edge and a soft lift, and no toolbar strip inside it. The toolbar floats centred under the paper as one liquid shape: Undo buds out of the Type instead pill's leading end and Clear out of its trailing end, resting joined to it, so the necks hold. ### Parameters - `signature`: The bound signature. Drawn strokes, or a typed name when the person chose Type instead. `isEmpty` stays `true` until there is a typed name or enough ink to count (a stray dot or speck does not), so it is the right check for enabling a submit button. Setting it from outside redraws the pad; a stroke removed from outside inside `withAnimation` fades out. - `prompt`: The hint under the baseline, shown until the first stroke. Defaults to "Sign here". - `allowsTyping`: Offers Type instead, which sets the typed name in a script face on the same baseline. This is the accessible path for people who cannot draw a signature (VoiceOver, motor impairments), so turn it off only when your flow offers another way to sign. - `style`: Colors, ink weight, corner radius and pad height. Defaults to the SwiftPieces house palette, adapting to light and dark. ## Token Field (`TokenField`) URL: https://swiftpieces.com/docs/components/inputs/token-field Category: Inputs. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: TokenField.swift Install: npx swiftpieces add TokenField A liquid glass tag and recipient field where each committed entry buds out of the glass input as a glass chip, born where it was typed while the input slides on, and chips wrap across lines resting apart while the input takes the rest of the last line. Return, comma or a paste of a whole list commits trimmed tokens with duplicate, limit and validation checks; backspace on an empty input highlights the last chip in the brand red and a second press melts it back into the input; and a glass suggestion list buds out of the input and filters as you type. Wrapping liquid glass token entry bound to an array of strings: tags, skills, recipients. The input is a glass capsule and every token a glass chip beside it, resting apart. A committed token buds out of the input where it was typed; a removed one melts back into it. Suggestions bud out of the input as a glass list. ### Notes - The input is a liquid glass capsule and every token a glass chip beside it. They sit in a private wrapping `Layout`: leading-aligned rows that rest apart, outside the distance at which glass melts together, with the input taking the rest of the last line. When that is narrower than the input's minimum (88pt, scaled) or its typed text, the input wraps to a new full-width line, and grows taller as wrapped text needs. A chip wider than the field truncates in the middle. - Return, comma (including the full-width `,` and `、`) and pasted line breaks commit the trimmed text by default. `separators` adds `.semicolon` and `.space` for recipient lists. Pasting `a, b, c` adds three tokens in one animated change. - A committed token buds out of the input as a glass chip: it is born inside the input, under the text it was typed as, and springs out to its place while the input slides on to make room, the neck between them stretching until it snaps. Several tokens from one paste bud out one after another. The reflowing chips and the input move on a short spring with a little give; on a field wider than 400pt (landscape, iPad), where a wrap travels most of the line, they move without overshoot so nothing crosses the field's edge. Chips added from outside bud out of the input's leading end the same way. - Every entry is checked in order: `maxTokens`, then duplicates (case-insensitive, the first spelling is kept), then `validate`. Entries refused by `validate` or past the limit stay in the input so they can be fixed: the field shakes once, the input takes the `Style.error` tint (butter) and an `.error` haptic plays on the same beat. A duplicate leaves the input and points at the chip already there instead: that chip lifts into its highlight and swells once with the `.error` haptic, every time, even when it is already highlighted, and the field stays still. The swell takes only the room the lift leaves before the next chip; one too long to swell at all dims for a beat instead. Each commit plays one haptic: `.selection` when everything was added, `.error` when anything was refused, even if other parts of the paste were added. - Backspace on an empty input highlights the last chip with a soft impact: it takes the `Style.selection` tint, the brand red, and lifts 4% on a short spring. A second backspace removes it with a rigid impact: the chip leaves the flow at once, so its neighbors and the input reflow into its place, and it melts back into the input from where it was, its tint draining as it goes. Tapping a chip highlights it the same way. The remove glyph on each chip sinks slightly under the finger and springs back with a little bounce. A zero-width space kept in front of the typed text makes this reliable on the software keyboard, where iOS reports no key event for backspace on an empty field. It is never shown or committed. - The input uses a vertical-axis `TextField`, so return inserts a line break the field turns into a commit and the keyboard stays up between tokens. Valid text left in the input is committed quietly, with no haptic, when the field loses focus. - Suggestions appear while typing, matched case- and diacritic-insensitively, prefix matches first, at most five, with the matched run in full ink and the rest quieter. Tokens already added are left out. The list is a glass panel that buds out of the input, shrunk inside it, and rests apart below the field, opening room for itself as it comes and closing it as it melts home, so whatever sits below moves with it. While you type its rows refine in place on a short spring with no overshoot, so the text you read never wobbles, and a row sinks slightly under the finger. - `Style.chips` tints chips by their text, so a token keeps its tint wherever it appears and removing one never recolors the rest; the house palette leaves every chip neutral glass. A tinted chip's suggestion row shows a dot in its tint. - At `maxTokens` the input stops accepting text and shows a quiet count such as `5/5`. A keystroke there meets a bound rather than an error: a light impact, and the count swells and rebounds, with no shake or error haptic on every key. While the input still holds text, such as entries kept past the limit, the count is hidden, so the ring dims for a beat instead. Backspace still removes. - Typing never waits on motion. An edit never animates the input or its text, even when it changes the suggestions, clears the error tint or wraps the input to a new line; only a commit or a removal moves the input, along with the chips. - Tapping anywhere in the field that is not a chip focuses the input. Keyboard type, capitalization and content type pass through: apply `.keyboardType(.emailAddress)` and friends to the `TokenField`. - VoiceOver: each chip is one element read as "Swift, token" with a Remove action; the input's label carries the placeholder and the token count; adds, removals and rejections are announced. - `Style` sets the input's and list's glass tint, chip tints, ink, label, placeholder, the error and selection tints, the list's corner radius and the field's minimum height. All text is semibold. `.disabled(true)` dims the field and disables removal. - Reduce Motion: chips and the suggestion list open and close where they rest instead of budding and melting, and chips reflow on a short spring with no bounce. A highlighted chip changes tint without lifting, and a duplicate dims its chip for a beat instead of swelling it. A refused entry plays its haptic and dims the ring for a beat instead of shaking, so a refusal while the error tint is already showing still shows on screen. A keystroke at the limit dims the count for a beat instead of swelling it, or the ring while the input holds text, as with motion on. A pressed remove glyph or suggestion row fades instead of sinking. Reduce Transparency draws the glass as solid shapes. ### Parameters - `placeholder`: Shown in the input while it is empty, and the input's accessibility label. - `tokens`: Bound tokens, in order. Changes from outside bud in and melt out like typed ones. - `suggestions`: Candidates listed under the field while typing, matched case- and diacritic-insensitively (prefix matches first, then contains), excluding tokens already added. - `maxTokens`: Upper limit. When reached the input stops accepting text and shows a quiet count such as "5/5", which bumps when a keystroke is refused; backspace still removes. - `allowsDuplicates`: When `false`, an entry that matches an existing token case-insensitively is not added; the existing chip is highlighted instead and the first spelling is kept. - `separators`: Keys and characters that commit the typed text. `.standard` is return, comma and pasted line breaks. Full-width commas and semicolons count as their ASCII forms. - `style`: Colors and field metrics. Defaults to the SwiftPieces house palette, adapting to light and dark. - `validate`: Accepts or rejects a trimmed entry (for example an email check). A rejected entry stays in the input, the field shakes, the input takes the error tint and an error haptic plays. ## Depth Carousel (`DepthCarousel`) URL: https://swiftpieces.com/docs/components/lists/depth-carousel Category: Lists. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: DepthCarousel.swift Install: npx swiftpieces add DepthCarousel Paged cards that recede in depth as they leave center and hand each page a phase value for inner parallax. A held neighbor starts forward and comes to center on tap. Underneath, a glass page counter joined by a liquid neck to a scrubbable glass track, whose tinted glass pill stretches between the dots as the pages move, flattens against the ends and follows the finger when scrubbed, computed in absolute page coordinates so it never jumps. Centered paging carousel with depth recession, per-page parallax phase, and a progress-linked indicator. ### Notes - Snapping uses `.scrollTargetBehavior(.viewAligned)` with content margins sized so the aligned page is centered; the first and last pages center too. Fractional progress is read from the page row's position in a coordinate space named on the scroll view itself (the built-in `.scrollView` space starts inside the content margins), so it lands on whole pages and works on iOS 17 without `onScrollGeometryChange`. - Depth is driven by the same progress: pages shrink by `style.recede`, fade by `style.dim`, and their shadow gets lighter and leans back toward the centered page as they leave center. The `phase` handed to your content is -1...1, for inner parallax; the example drifts a cropped ink disc by `phase * 40` inside a solid color card. Under Reduce Motion the phase is 0 and only the dim remains. - Hold a neighbor page and it starts forward, about a third of the way to center; tap it and it comes the rest of the way on the snap spring that every tap, scrub and VoiceOver page move uses. A swipe that starts on a neighbor scrolls as usual and cancels the press. The centered page keeps its own gestures. - The pages stay your content; the indicator under them is liquid glass. A "01 / 04" counter (current page in rounded semibold, total dimmed, with a short roll and no overshoot as it changes) sits on a glass capsule joined by a liquid neck to the glass track that carries the dots, so the two read as one control, centered under the pages. The current page is a pill of tinted glass (`style.indicatorActive`) riding inside the track. It is drawn in absolute page coordinates, as a pure function of the scroll position, so it never lags the pages and never jumps when one settles. Between two dots its leading edge reaches the next dot first and its trailing edge follows, so it stretches by up to half the dot spacing and gathers in as the page lands. Past the first or last page it flattens against the end dot as the scroll view rubber-bands, and springs back with it. On iOS 26 this is Liquid Glass; before that frosted Material, and under Reduce Transparency solid fills. - While you drag the pages or touch the track, the dots dim and the pill widens, on one press spring. They settle back on the snap spring as soon as you let go of the track, or once dragged pages come to rest, so after a fling they stay active until the pages land. The whole 44pt-tall track is scrubbable: the pill glides onto the finger in about 0.1s while following its movement 1:1, flattening against the ends if you pull past them, while the pages follow on the snap spring, with a selection haptic per page. On release the pill eases back to the page position; touch the track again before it gets there and it is picked up from where it is drawn, without a jump. - `Style` (`DepthCarousel.Style`, also `DepthCarouselStyle`): `recede`, `dim`, `shadowOpacity`, `indicatorActive`, `indicatorInactive`, `showsCounter`. With `showsCounter` off the track stands alone. - Reduce Motion: pages don't scale or hand out a phase, and keep their dim. The pill slides rigidly without stretching, stops at the ends and doesn't widen; the dots still dim. A held neighbor shades slightly (darker in light mode, lighter in dark) instead of moving forward. The counter fades instead of rolling, and page moves land on a short spring with no overshoot. Native paging and its fling are unchanged. - VoiceOver treats the carousel as one container that reads "Page x of y" and supports swipe up/down to change pages; the indicator is hidden from it. - `scrollClipDisabled()` lets neighbor pages peek past the carousel's own bounds, so give it full width. - Want blur-and-tone depth, pinch peek-zoom and a full album screen? That is the Depth Gallery screen in SwiftPieces Pro. ### Parameters - `data`: Identifiable collection of pages. - `itemWidth`: Width of each page. Pages are centered in the carousel. - `spacing`: Gap between pages. - `showsIndicator`: Show the counter and pill indicator under the carousel, on liquid glass. The pill track is scrubbable. - `selection`: Optional binding to the centered element's id, for reading the page or moving programmatically. - `style`: Depth amounts, indicator colors and whether the page counter shows. Defaults to the house palette. - `content`: Builds one page from an element and its phase: 0 at center, -1 one page toward the leading edge, 1 toward the trailing edge. Use it for inner parallax, for example `.offset(x: phase * 24)` on a shape inside a clipped card. ## Drag Select Grid (`DragSelectGrid`) URL: https://swiftpieces.com/docs/components/lists/drag-select-grid Category: Lists. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: DragSelectGrid.swift Install: npx swiftpieces add DragSelectGrid A multi-select photo and file grid where one drag paints a whole reading-order range the way the system photo library does, a sideways drag or a short hold starts painting while vertical swipes still scroll, dragging back unpaints, starting on a selected item deselects, the grid auto-scrolls faster the deeper the finger sits in the top or bottom edge, and selected cells inset under a liquid glass badge that buds out of the print's corner, floods signal red as the check stamps down and melts back into the corner, with a haptic tick per item. A scrolling grid of `Identifiable` items with drag-to-select: photos, files, attachments, media. In selection mode a sideways drag (or a short hold, then any direction) paints every item from the first one touched to the one under the finger, in reading order. Dragging back unpaints. Starting on a selected item deselects instead. A vertical swipe still scrolls, and holding the finger near the top or bottom edge scrolls the grid while painting. Tap toggles one item. ### Notes - In selection mode, a drag that starts mostly sideways (within about 40 degrees of horizontal) paints. So does a short hold (0.25s) followed by a drag in any direction, which is how you paint straight down a column. Anything steeper than 40 degrees without a hold scrolls, the same as any grid. - Painting covers the reading-order range from the first item touched to the one under the finger, like the system photo library. Dragging back shrinks the range and each item returns to what it was before the drag. Starting on a selected item deselects the range instead. - Hold the finger in the top or bottom band (72pt by default) and the grid auto-scrolls while you paint. Speed grows linearly with depth into the band, up to 1200pt a second at the edge, and rows scrolled into view keep painting under a still finger. - A tap toggles one item. Outside selection mode taps reach your cell content (buttons, links), and with `longPressToSelect` a 0.45s hold turns selection mode on, selects that item and keeps painting. Pass `false` if your cells have their own context menu. - A hold shows itself building: once the finger has rested 0.1s, the print under it sinks a couple of points, and when the hold fires the selection takes over from there. A quick scroll moves past the 8pt decision before then and never dips a cell. A scroll that starts after a pause, a lift or a cancelled touch lets the print back up at once. - The gesture is a small UIKit recognizer the grid installs on its own scroll view. It decides between painting and scrolling within the first 8 points of movement, and the grid's scroll view (and any scroll view around it, such as a screen-level `ScrollView`) waits for that decision, so a paint never scrolls and a scroll never paints. - Columns are as many as fit at `minimumCellWidth`, so the grid adapts to rotation, iPad and split view. Selection is kept by ID across column changes. Items are hit-tested from the grid's geometry, not from cell frames, so painting works through rows a lazy grid has not built yet. - When items disappear, their IDs drop out of `selection`, and a drag in progress ends. Turning `isSelecting` off keeps `selection`; clear it yourself if your design does. - Selected cells inset (`selectedScale`, 0.86) under a liquid glass badge in the bottom trailing corner. In selection mode every print shows the badge as clear glass with a light rim, so it is clear it can be picked; picked, it floods the house signal red with a dark check, so a picked print reads at a glance on any thumbnail. Each badge is its own glass bubble, a cell apart from the next. The badge scales with Dynamic Type up to 34pt, and never past a third of the cell. - Selecting is a press then a stamp: the print presses into its slot on a firm spring with a small give as its badge floods red, and a beat later the check comes down onto the badge and lands with a visible bounce. Deselecting lifts the check off quickly, drains the tint and lets the print back out with only a slight give, so dragging back across a range reads as a soft release. Your cell content changes on the same spring as the inset. Painting never waits on any of this: the selection changes the moment the finger reaches an item. - Badges bud out of their print's corner: each is born just past the corner, shrunk and hidden by the print's rounded clip, and springs out to its place. When a long press turns selection mode on, they spread out from the held print, one row or column per beat, all within about 120ms. Turned on from your own control, they bud together. Leaving selection mode, the badges of unpicked prints melt back into their corners together, on a spring with no bounce. - Haptics: a soft impact when a paint starts, a selection tick per item crossed (at most about 22 a second, so fast drags and auto-scroll do not buzz), and a selection tick on tap. - VoiceOver: each cell is one element with your content's label and the selected trait. In selection mode it is a button that toggles on double-tap. Every cell has a Select or Deselect action, and Select also turns selection mode on, so everything painting does is reachable without it. After a paint, the new count is announced. - Right-to-left layouts mirror the grid; painting follows the mirrored reading order. - Reduce Motion: the inset and the badge tint change on a short spring with no overshoot, the check fades in place instead of stamping, the badges fade in and out in place together instead of budding, and a held print dims instead of sinking. Reduce Transparency draws the badges as solid shapes. Auto-scroll still runs, since it is driven by the finger, and every haptic still plays. - `.disabled(true)` turns off painting, taps and the hold. `Style` sets spacing, corner radius, the selected inset, badge, check and ring colors, the auto-scroll band and speed. - The example is a small library of flat scenes in the house blocks (hills, a sunset, portraits, a night sky, an arch, fields), drawn in a `Canvas` so they read as photos, with a liquid glass toolbar under the grid: a signal Done (Select outside selection mode) and the live count, which buds out of Done with the first pick, joined to it, and melts back in when the selection empties. Done also clears the selection there. ### Parameters - `items`: The items, in reading order. When items disappear, their IDs are dropped from `selection`. - `selection`: The selected IDs. Painting and taps write here; set it yourself for Select All or Clear. - `isSelecting`: Selection mode. While `false`, cells behave like your content (taps reach your buttons and links) and painting is off. Turning it off keeps `selection`; clear it yourself if you want to. - `minimumCellWidth`: The narrowest a cell may be. The column count is the most that fit at this width, so it adapts to rotation, iPad and split view. - `aspectRatio`: Cell width divided by height. `1` is square; `0.75` suits portrait posters or documents. - `longPressToSelect`: When `true`, holding a cell outside selection mode turns selection mode on, selects that cell and keeps painting as the finger moves. Pass `false` if your cells have their own context menu. - `style`: Spacing, corner radius, the selected inset, badge colors and auto-scroll tuning. Defaults to the SwiftPieces house palette. - `cell`: Builds a cell's content from the item and whether it is selected. It fills the cell and is clipped to the cell's rounded shape; the grid draws the badge and inset. ## Follow Scroll (`FollowScroll`) URL: https://swiftpieces.com/docs/components/lists/follow-scroll Category: Lists. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: FollowScroll.swift Install: npx swiftpieces add FollowScroll A chat, log and live-feed scroll container that opens on the newest item and stays pinned to it while the reader is at the bottom, but never yanks them away once they scroll up to read; items that arrive below are counted into a liquid glass pill that buds up out of the bottom edge on a neck, jumps back to the latest and melts back down into the edge once the reader is caught up, older items loaded at the top keep the visible row exactly where it was while a glass spinner buds down from the top edge, and keyboard, rotation, growing rows and removals all hold the reader's place. Colors and metrics for `FollowScroll`. `.standard` is the house palette. ### Notes - Opens on the newest item and, while the reader is within `followThreshold` (48pt) of the bottom, scrolls each new item into view with a short smooth scroll. Many items arriving in one update scroll once. - Once the reader scrolls up, new items never move the content. They are counted into a liquid glass pill at the bottom, such as "3 new", with a red dot and a down arrow. Tapping it (or ⌘↓ on a hardware keyboard) jumps to the latest and clears the count; scrolling back down by hand clears it too. Items removed before they are seen drop out of the count. - The pill buds out of the scroll view's bottom edge, the edge the rows slip under (the screen's bottom, or the top of a composer). Just past that edge lies a hidden glass lip: the pill is born inside it, rises through the edge pulling a liquid neck that snaps just clear of it, and settles with a small give above the safe area. When the reader is caught up it melts back down into the edge on a spring with no bounce, keeping the count it had, its label blurring away first so nothing rides it down. Each new count rolls the number and eases the pill's width without overshoot, so a busy feed never keeps it wobbling and the count never shows a number that isn't true. - More than three quarters of a screen above the latest with nothing new, the pill shows as a compact round jump button, and it stays until the reader is back within about 60% of a screen, so it never flickers at the line. When news arrives it changes form rather than being swapped, with a slight give: the same arrow slides over as the red dot grows in just behind it, and the count opens beside it. Turn it off with `Style(showsJumpButton: false)`. - `alwaysFollow` jumps to the bottom for chosen items even when scrolled up. Pass `\.isMine` so the reader's own sent message always comes into view. - `onReachTop` loads older items when the reader nears the top (within about three quarters of a screen). Prepend them to `items` before it returns: the rows on screen stay exactly where they are, including when the reader is resting on the very top edge. A small glass bubble with a spinner buds down out of the top edge while it runs and melts back up into it when it is done. It never runs twice at once, it runs again while a short list still reaches the top, and a call that adds nothing is not repeated until the reader scrolls away and back, so an exhausted history or a failed request never loops. - Stays pinned to the bottom when the container changes size while following: the keyboard (for a composer in `safeAreaInset(edge: .bottom)`), rotation, or the last row growing as text streams into it. Scrolled up, the reader's place holds through all of these. - Replacing the whole array (another conversation, a new filter) starts again at the latest with the count cleared. Removing the newest items is not treated as a reset. - Only a scroll by the reader leaves the bottom: rows inserted above, lazy rows re-measuring and resizes never do. On iOS 18 and later a finger on the content also holds back any automatic scroll until the gesture ends. - `isFollowing` (optional binding) reflects whether the view is pinned, for a "Live" badge or a toolbar button; set it to `true` to jump to the latest. - `empty` is shown centered while there are no items. Rows are laid out in a `LazyVStack`, so long threads and logs stay cheap. - Tracking: iOS 18 and later read `onScrollGeometryChange` and `onScrollPhaseChange`; iOS 17 measures the content in the scroll view's coordinate space. Both feed the same logic, and position is held through `scrollPosition(id:anchor:)` on the bottom-most row. - Pill and spinner: Liquid Glass on iOS 26 (interactive on the pill), a frosted material with a light rim and a soft shadow earlier, and the solid `Style.surface` under Reduce Transparency. All text and glyphs are semibold. On iOS 26 the interactive glass gives the press; elsewhere the pill sinks slightly under the finger and springs back. - VoiceOver: the pill is a button read as its label ("3 new") with the hint "Jump to latest"; the scroll view offers a "Jump to latest" action while scrolled up; new items that arrive while scrolled up are announced once per burst with the final count. - Localization: `newItemLabel` gives the pill text and the announcement. The default is a localized "3 new" through `String(localized:)`; add plural variants in your String Catalog, or pass your own. The pill mirrors in right-to-left layouts. - Dynamic Type: the pill and its dot scale, the label wraps to two lines at the largest sizes, and the hit area never drops below 44pt. - Reduce Motion: jumps are instant instead of scrolling, the pill and the spinner fade in and out in place instead of budding and melting, the red dot fades instead of growing and the arrow keeps its size, the count crossfades instead of rolling, and where the pill is not Liquid Glass a press dims it instead of sinking it. In either mode rows never pick up an animation, and the corrections that keep the bottom glued (keyboard, rotation, a growing row) and the prepend restore are instant. - Haptics: a light tap when the pill first appears with news, and a soft one when you jump. Nothing fires for each new item. - `Style` sets the pill's label, signal dot and ink, the Reduce Transparency surface, the spinner color, row spacing, horizontal and vertical padding, the follow threshold and whether the compact jump button shows. ## Paged List (`PagedList`) URL: https://swiftpieces.com/docs/components/lists/paged-list Category: Lists. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: PagedList.swift Install: npx swiftpieces add PagedList An async/await infinite-scroll list that prefetches the next page a few rows before the end, never runs two loads at once, drops duplicate ids across pages, ignores stale responses after a refresh or cancel, and shows pulsing skeleton rows; its footer is one liquid glass bubble that spins while a page loads, widens into a "Couldn't load more" pill with a Retry bubble budding out of it on a liquid neck that keeps loaded rows, and becomes a quiet "You're all caught up" pill at the end, with pull to refresh that replaces rows only on success. An infinitely scrolling list driven by an async page loader, with every pagination state handled. The simplest call is one line: `PagedList(load: api.page) { item in Row(item) }`, or for page-number APIs `PagedList(pageSize: 20) { page in try await api.fetch(page: page) } row: { item in Row(item) }`. To restart with new parameters (a search query, a filter), give the list `.id(query)`. ### Notes - `load` receives `nil` for the first page, then each page's `next` cursor, which can be a page number, an offset or an opaque token. Return `PagedListPage(items:next:)` with `next: nil` on the last page. For page-number APIs, `PagedList(pageSize:)` builds the cursor for you and ends the list at the first short or empty page. - The next page starts when the row `prefetchDistance` (5) rows from the end appears, or when the footer is on screen, so short pages keep loading until the screen fills. Only one load runs at a time; extra triggers are ignored. - Items whose `id` has already been seen are dropped, so an API that shifts under you (new items pushing old ones into the next page) never produces duplicate rows. Eight pages in a row that add nothing new end the list, to guard against a cursor that never ends. - Every load takes a generation number. A refresh, a retry or leaving the screen cancels the load in flight and starts a new generation, and any response from an older one is dropped, so a slow page can never overwrite fresher rows. A load cancelled by disappearing resumes when the list appears again. Throwing `CancellationError` or `URLError.cancelled` is never shown as an error. - States: skeleton rows while the first page loads, with a pulse that travels down them a beat apart (pass `placeholder:` to render your own row redacted instead), and a footer that is one liquid glass bubble: it spins while more loads, widens into a "Couldn't load more" pill with a butter warning disc and a signal Retry bubble that keeps every loaded row, and becomes a quiet "You're all caught up" pill with a sage check at the end (`endMessage: nil` hides it). If the first page fails, a full error view shows a butter glass warning bubble and a signal glass Try again button. - Motion stays out of the data's way: the first page, page appends and refresh replacements are never animated. Only the status around them moves. The footer changes form about its centre on a spring with a slight give, its glyph and label blurring from one state to the next. On a failure the Retry bubble buds out of the pill's trailing end, held to it by a liquid neck, and when Retry is tapped it melts back in, its tint draining, as the pill folds back to the spinner. The error and empty views fade in and out over the list. On iOS 26 Retry and Try again are interactive glass; elsewhere they sink a few points when pressed. - Empty: the built-in state is a short message. Pass `empty:` for your own view, for example `ContentUnavailableView("No receipts", systemImage: "doc.text")`. - Pull to refresh reloads from the first page and replaces the rows only when it succeeds. If it fails, the old rows stay and a "Couldn't refresh" pill appears above them, and its Retry bubble buds out of it. With `layout: .plain` the pill eases in; a `List` inserts it without animation, so no row slides under a pull that is still held. While a refresh runs, a spinner stands in for Retry's arrow and Retry ignores taps, so it can't restart the refresh it is waiting on; if it fails again, the warning disc gives one short shake. - `layout: .list` (default) is a plain `List`, so swipe actions and row modifiers work in your row. `layout: .plain` uses a `LazyVStack` in a `ScrollView` with `Style.rowSpacing` and `Style.contentPadding`. Appending pages never moves the scroll position in either. - To restart with new parameters, such as a search query or filter, give the list `.id(query)`. - Concurrency: state lives on the main actor; `load` is a `@Sendable` async closure that runs off it. Items must be `Identifiable & Sendable`. - `Style` (`PagedListStyle`): `text`, `secondaryText`, `field`, `error`, `retry`, `end`, `ink`, `rowSpacing`, `contentPadding`, `skeletonRows`. Colour follows meaning: `error` (butter) marks warnings, `retry` (signal red) tints the primary action, `end` (sage) marks success, and the pill itself is neutral glass (`field` under Reduce Transparency). The pill, discs and Retry scale with Dynamic Type, and at accessibility sizes Retry buds out below the pill instead of beside it. All text is semibold. - VoiceOver: "Loaded 20 more" is announced when a page appends, and each error is announced. The skeleton reads as a single "Loading" element, the spinner as "Loading more", and Retry is a button with a hint, read as "Retrying" and dimmed while a refresh runs. - Reduce Motion: the skeleton rows breathe together instead of the pulse travelling down them, the spinner becomes three fading dots, Retry fades in and out in place instead of budding and melting, glyphs and labels crossfade, the refresh pill fades instead of scaling, Retry dims instead of sinking when pressed where it is not Liquid Glass, and the disc doesn't shake. Reduce Transparency draws the glass as solid shapes. A light haptic plays on Retry; routine loads are silent. ### Parameters - `load`: Loads one page. Receives `nil` for the first page, then each page's `next` cursor. Throwing shows an error state; cancellation is ignored. - `pageSize`: Page-number convenience only. A page with fewer items than this (or none) ends the list. - `firstPage`: Page-number convenience only. The number of the first page, usually 1 or 0. - `fetch`: Page-number convenience only. Returns the items for a page number. - `layout`: `.list` (a plain `List`, the default) or `.plain` (a `LazyVStack` in a `ScrollView` for card layouts). - `prefetchDistance`: How many rows before the end the next page starts loading. - `endMessage`: Quiet footer shown after the last page. `nil` hides it. - `emptyMessage`: Text of the built-in empty state when the first page has no items. - `placeholder`: A sample item rendered through your `row` with `.redacted(reason: .placeholder)` as the loading skeleton. `nil` uses generic skeleton rows. - `errorMessage`: Turns a thrown error into the text shown in the full error view. Defaults to `localizedDescription`. - `style`: Colors and metrics for the built-in skeleton, status pill, empty and error views. Defaults to the house palette. - `row`: Builds the row for one item. - `empty`: A custom empty state, shown when the first page has no items (for example a `ContentUnavailableView`). ## Status Timeline (`StatusTimeline`) URL: https://swiftpieces.com/docs/components/lists/status-timeline Category: Lists. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: StatusTimeline.swift Install: npx swiftpieces add StatusTimeline A vertical status timeline on liquid glass nodes: the live node is tinted and breathes, completed ones stamp to a tinted check as the ink runs down the line to the next stop, and a step's detail buds out of its node as a glass panel joined to it, melting back in as the relay moves on. Vertical timeline for order tracking, delivery, or verification flows. Each step's node is a liquid glass bubble, tinted by its status. An open step's detail sits on a glass panel that buds out of its node and rests joined to it by a liquid neck, so the node and its detail read as one shape. ### Notes - Every node is a liquid glass bubble, tinted by its status: the live step signal (`style.current`) with a breathing ring, completed steps sage with a drawn check, a failed step butter with an x, and pending steps clear glass with a quiet number. On iOS 26 this is Liquid Glass; before that frosted Material, and under Reduce Transparency solid fills. - An open step's detail sits on a glass panel that buds out of its node: born inside the node as a small round blob, it grows out into the panel behind the step's text and rests joined to the node by a liquid neck, so the node and its detail read as one shape. The live step's panel takes the signal tint with dark ink, a failed step's the butter tint, any other step's stays clear glass. Closing, the detail blurs away, the panel's tint drains and it melts back into the node. - Layout is nodes leading, content trailing: a 44pt column holds the 30pt node and the connector below it, which stretches to the row's own height, so long titles and expanded detail never break the line. Rows hug their content, so the timeline never stretches to fill a tall container. Each row's glass is its own liquid group, so a node holds its own panel and never melts into its neighbour. - A status change plays as a relay down the trail. The step that completes stamps first: its number blurs out, its node takes the sage tint, the check draws a beat later, and as it lands the node pops with the `.success` haptic. Its open panel drains and melts back into the node. The ink then runs down its connector in the text color, on a fixed 0.4s clock, and the next step takes its new status only as the ink reaches it: its node lights signal, and with `expandsCurrent` its panel buds out a beat after that. - A `.failed` step lands the same way: its node turns butter with an x, gives a small shake with the `.error` haptic on the same beat, and the connector below it turns dashed, so the trail visibly stops. A reset or any change that doesn't complete a step lands in one beat, with checks and ink clearing quickly. - A change that completes three or more steps at once is catching up rather than handing off, so it lands in one beat. Steps are tracked by title: a step that moves still relays its change, a renamed one shows its status at once, and a repeated title keeps its own state. A newer change mid-relay carries on from what is on screen, and a stamp it cuts short still plays its haptic once. Each change plays each haptic once. A timeline that appears shows its current state without replaying anything, its open panel already out, and one that changed while off screen catches up as it returns, its tints, checks and ink settling together with no relay and no haptics. VoiceOver reads every step's real status at once, never the relay's delayed drawing of it. - Tap any step with `detail` to expand it; one step is expanded at a time. The row grows around the detail on a calm spring while the panel buds out and the text blurs in, clipped to the panel so it never shows past its edge; closing is quicker. `expandsCurrent` opens the current step on appearance and follows it as the live step moves on, including a move made while the timeline was off screen, which it shows as it stands on return. - `Style` (`StatusTimeline.Style`): `current`, `complete`, `failed`, `ink`, `text`, `muted`. `surface` is kept so existing code compiles but no longer draws anything: open detail sits on glass. `tint:` still overrides both the current and complete colors. - Under Reduce Motion every change lands in one short beat with no relay: tints, checks, ink and detail crossfade without drawing, popping or shaking, panels fade in place instead of budding, and the rows reflow once. The breathing ring becomes a still halo that breathes in opacity, so the live step still reads as in progress. The colors, the marks and both haptics still carry each change. Each step is a single VoiceOver element that reads its title, timestamp and status, and is a button only when it has detail. ### Parameters - `steps`: Steps in order, each with a title, optional detail and timestamp, and a status. - `tint`: Glass tint for the current step and completed nodes. `nil` uses `style.current` and `style.complete`. - `expandsCurrent`: Start with the current step expanded. - `style`: Tints, text colors and connector. Defaults to the house palette. ## Swipe Action Row (`SwipeActionRow`) URL: https://swiftpieces.com/docs/components/lists/swipe-action-row Category: Lists. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: SwipeActionRow.swift Install: npx swiftpieces add SwipeActionRow Wraps any row in a liquid glass card whose swipe actions are tinted glass bubbles. As the card slides they bud out of its edge, joined to it and to each other by liquid necks, and melt back in as it closes; the card lands in its detent at the speed you let go, a full swipe clicks in with a haptic as the edge bubble swallows the others and takes the bar, a destructive one carries the card out of the row, and a shared binding keeps one row open at a time. Row with swipe-to-reveal actions that works in a plain `VStack` or `ScrollView`, not only in `List`. The row is a liquid glass card and its actions are tinted glass bubbles. As the card slides they bud out of its edge and rest joined to it and to each other, so the open row is one liquid surface; closing, they melt back in. ### Notes - The row is a liquid glass card (26pt continuous radius) and its actions are tinted glass bubbles with dark ink, `style.tileGap` (6pt) from the card and from each other. That gap sits inside the liquid merge distance, so an open row is one liquid surface: card and bubbles held together by necks. Pass light tints as `tint`; the house uses signal (`#FF0000`) for the one destructive or primary action and butter for a warning-like one such as snooze. On iOS 26 this is Liquid Glass; before that the same shapes draw as frosted Material, and under Reduce Transparency as solid fills. - Drags are only claimed when they start horizontally, so the enclosing `ScrollView` keeps vertical scrolling. The card stays under your finger. The row clips sideways, and the clip fades out over a short distance past each edge, so the glass's shadow is never cut by a hard line and a card slid past the edge slips away under the fade. A drag the system cancels (a scroll that takes over) lands the card in the nearest position and fires nothing. - Letting go keeps the card's speed: it settles into the open bubbles with a visible give, more the harder you flick, though never more than about a sixth of its travel past them, and a drag that closes comes home firmly. Tapping the card or a bubble, or opening and closing through the `open` binding, snaps it into place with a smaller give. Whether it opens or closes still depends on where the flick would carry it. Grabbing a card that is still landing never jumps: it finishes landing under your finger over the first tenth of a second. - Bubbles bud out of the card's edge: each is born just inside it, shrunk, where the two are one shape, and the reveal draws it out to its slot at the row's edge, pulling a neck as it goes. The edge bubble leads and each one further in follows a step behind, all driven by the card's place, so they never trail the finger and bud and melt exactly with the card when it springs. A bubble's color fills in as it clears the card and drains as it heads home; its glyph and label arrive once it is clear and blur away the moment the card heads home, so nothing ever sits on the card's glass. Opened past their slots, the bubbles stretch to fill the gap. A pressed bubble sinks a few points, glass and glyph together. - A full swipe (past 60% of the row width, and never less than two bubbles) clicks in like a detent: the other bubbles melt into the edge bubble as it swells across the whole bar, its glyph bounces once and a rigid impact fires as it arms, never as it lets go. Drag back before letting go and they bud out of it again. Past that line the card moves against rubber-band resistance; where the open bubbles reach further (several actions, or a narrow row), the resistance starts at them instead. Releasing runs that action. A destructive action (`role: .destructive`) carries the card on out of the row at the speed you let go, so removing the row in the handler reads as one motion. The edge bubble stays put and can't be tapped again while the card is out. If the row is still there 0.35s after the card is out (the handler asked first, or deletes later), the card comes back home and the bar melts into its edge. That pause outlasts a `List` delete or a default `withAnimation` removal; a removal slower than that shows the card returning as the row fades. Any other action brings the card home the same way. The first declared action sits at the screen edge, matching `.swipeActions`. - Pass the same `open:` binding to every row in a list (with a unique `id:` per row) and only one row can be open at a time. Set the binding to `nil` to close all rows, or to a row's id to reveal its trailing actions (leading when there are none). - Swiping toward an edge with no actions gives with rubber-band resistance, never more than 60pt, and springs back. - `background:` sets the card's glass: `nil` leaves it neutral, a color tints it. `.clear` suits a list that draws its own surface: there is no card, the content slides on its own, and with nothing to bud from the bubbles wait in their slots and are uncovered as the content slides off them. - `Style` (`SwipeActionRow.Style`, also `SwipeActionRowStyle`): `tileInk`, `cornerRadius`, `tileWidth`, `tileGap`. `surface` is kept so existing code compiles but no longer draws anything: the card is glass. Set `cornerRadius` and `tileGap` to 0 for classic full-bleed list rows. - Reduce Motion keeps every state change and the arm haptic, but lands the card quickly with no overshoot or give, lets the bubbles wait in their slots and fade in with the reveal instead of budding, skips the arm bounce, and shades a pressed bubble instead of sinking it. All actions are exposed as VoiceOver custom actions on the row. - Need grouped activity with categorize, split and expanding detail? That is the Transaction Row screen in SwiftPieces Pro. ### Parameters - `id`: Identity used with `open`. Defaults to a private id, which still lets a parent close the row. - `leading`: Actions revealed by swiping right. The first one sits at the edge and fires on a full swipe. - `trailing`: Actions revealed by swiping left. The first one sits at the edge and fires on a full swipe. - `background`: The card's glass. `nil` leaves it neutral glass and a color tints it. `.clear` suits a list that draws its own surface: the content slides with no card behind it, and the bubbles only show in the gap the row opens. - `open`: Shared binding to the id of the open row. Swiping a row stores its id here and closes every other row bound to it; set it to `nil` to close all, or to a row id to reveal that row's trailing actions. - `style`: Corner radius, bubble ink, bubble width and gap. Defaults to the house palette. - `content`: Row content. ## Task Row (`TaskRow`) URL: https://swiftpieces.com/docs/components/lists/task-row Category: Lists. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: TaskRow.swift Install: npx swiftpieces add TaskRow A task card with liquid glass chrome. Its check is a glass bubble, stamped in ink and popping as the check draws while the card flashes into a solid block, then settling to tinted glass as the row compacts; the priority sits on a tinted glass chip; a swipe-right buds a complete bubble out from under the card that completes with a strike-through; a swipe-left buds Snooze and Delete bubbles that part to rest apart; and a long-press lift reports reorder moves. Interactive task row for to-do lists, driven by a bound `Status`. The card stays a solid surface; its chrome is liquid glass. The check is a glass bubble, the priority a tinted glass chip, and the swipe actions are glass bubbles that bud out from under the card's edge as it slides. ### Notes - Rows are soft cards (`style.surface`, 22pt radius) with liquid glass chrome. The check is a glass bubble: clear glass with a faint ring while open, the complete tint once done, the snooze tint with a moon while snoozed. Priority sits on a small tinted glass chip (HIGH signal, MED butter, LOW sky) with dark ink; due dates carry a clock glyph; a snoozed task gets a lilac glass Snoozed chip. On iOS 26 this is Liquid Glass; before that frosted Material, and under Reduce Transparency solid fills. - Completion is a sequence, not a toggle: the card flashes into the solid `tint` block (sage by default) while the check bubble is stamped in ink and pops with a `.success` haptic, the check draws in with a trimmed path, the strike-through grows across the title, then the row settles back and folds down compact with a slight give (smaller padding, type and check), the ink fading to show the tinted glass and the due label and priority folding away together. Setting `status` to `.completed` from outside runs the same sequence; rows that load already completed start settled. `.open` reverses it: the check and strike clear quickly, the bubble's tint drains and the row opens back up. Snoozing morphs the card in place: the check turns lilac with a moon, the due label swaps for the Snoozed chip and the priority folds away, staying hidden if the snoozed task is then completed. - Swipe right to the 104pt detent to complete (or reopen a completed row). The card stays under the finger, and a round glass bubble buds out from under its leading edge: born there shrunk, drawn out to the middle of the gap by the swipe, its sage tint filling in as it clears the card. Its glyph stamps on with a rigid impact as the swipe arms, and past the detent the card moves against rubber-band resistance. Swipe left and Snooze (lilac) and Delete (signal) bubbles bud out the same way, only for the callbacks you pass: the outer one leads, the inner one follows a step behind, and they part through a liquid neck to rest apart. Pulled past them, the outer bubble stretches into the pull. Every bubble's label arrives once it is clear of the card and blurs away the moment the card heads home, and its tint drains as it goes back under. - On release the card keeps its speed: it settles into the bubbles with a visible give if the flick was heading there, more the harder you flick though never more than about a sixth of its travel past them. Let go on the right, completing or not, it comes home with a small give that stays short of the bubbles on the other side; closed from the left or flicked home fast, it comes home firmly. It springs back with a visible give from a side with nothing to reveal, and a tap on the card or a bubble snaps it home with a small give. Grab it again while it is still settling and it finishes landing under your finger. Bubbles and the check sink slightly when pressed, glass and glyph together. - Long-press (0.35s) to lift the row: once your finger has rested a moment the card presses into the table (a tap, scroll or swipe never dips it), then springs up to scale 1.03 with a soft impact while its shadow spreads a beat behind. Drag it vertically and `onMove` receives the number of row slots moved on release (negative is up). The card drops back into its slot at the speed you let go and its shadow gathers in after it, the card staying above its neighbors until both have landed, also when you let go without moving it. Animate your own array move in `onMove` if you want the other rows to slide. A touch the system cancels sets the card down without reporting a move. The gesture is only installed when `onMove` is set. - Reduce Motion: the swipe and the lift still track the finger, and every release lands on a short spring with no overshoot. The card doesn't press in or scale up (the shadow and haptics carry the lift), swipe bubbles wait at their places and fade in with the reveal instead of budding, the complete glyph brightens instead of stamping, the check doesn't pop, pressed bubbles and the check shade instead of sinking, labels fade instead of moving, and the completion steps crossfade at once. - `.disabled(true)` dims the row to 50% and turns off the swipe and lift. - `Style` (`TaskRow.Style`): `surface`, `text`, `muted`, `ink`, `complete`, `snooze`, `delete`, `high`, `medium`, `low`, `cornerRadius`. The tints color glass; `tint:` still overrides the complete color. - Stack rows with 8pt spacing on the ground. VoiceOver reads the row as one button with status, due date and priority, and exposes Complete/Reopen, Snooze, Delete and Move up/down as custom actions. - Need grouped-by-day activity with split and expanding detail? That is the Transaction Row screen in SwiftPieces Pro. ### Parameters - `title`: Task text. - `status`: `.open`, `.completed`, or `.snoozed`. Set it from outside and the row animates to match. - `due`: Optional secondary label, such as "Today, 5 PM". - `priority`: Optional priority chip at the trailing edge. - `tint`: Color for the check, the completion flash and the complete bubble. `nil` uses `style.complete`. - `style`: Card surface, text colors, priority and action tints, and corner radius. Defaults to the house palette. - `onTap`: Called when the row body is tapped, typically to open the task. - `onSnooze`: Adds a Snooze bubble behind a left swipe and is called when it is chosen. - `onDelete`: Adds a Delete bubble behind a left swipe and is called when it is chosen. - `onMove`: Enables long-press lift and reports how many rows the user dragged it (negative is up) on release. ## Attachment Tray (`AttachmentTray`) URL: https://swiftpieces.com/docs/components/media/attachment-tray Category: Media. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: AttachmentTray.swift Install: npx swiftpieces add AttachmentTray A horizontal attachment strip for composers, support forms and listings: a liquid glass add tile opens the photo picker with the remaining limit, picks drop onto the strip as their own tiles carrying a glass status bubble with a live progress ring, and the remove badge buds out of it; the ring closes before the thumbnail, downsampled off the main thread, develops in place and the bubble melts into the badge, failures flood the bubble red as a retry, the remove badge cancels an in-flight load, duplicates and over-limit picks are caught, and the caller owns every attachment for upload. A strip of photo and video attachments with an add tile, per-item loading, retry, remove and a limit. Thumbnails are content; everything you press or read over them is liquid glass. A tile that is loading, failed or uploading carries a status bubble at its centre, joined to the remove badge in its corner. The badge buds out of that bubble when the tile drops onto the strip, and the bubble melts into the badge once the photo has developed. The caller owns the attachments. Picks from the built-in add tile are appended for you; anything else (a camera capture, a paste, a drop, a draft restored from disk, your own picker) is added by appending an ``Attachment`` to the binding, and the tray runs the same loading pipeline for it. ### Notes - Thumbnails are content; everything you press or read over them is liquid glass. The add tile is a glass tile with a solid red plus disc. A tile that is loading, failed or uploading carries a glass status bubble at its centre, joined by a liquid neck to the remove badge in its corner: the badge buds out of the bubble a beat after the tile drops onto the strip, and once the photo has developed the bubble melts up into the badge. On iOS 26 this is Liquid Glass; before that, a frosted material; under Reduce Transparency, solid fills. - You own the attachments through a `@Binding [AttachmentTray.Attachment]`. Picks from the add tile are appended for you; the tray writes load progress, the thumbnail and the payload back by id, and removes an attachment when its badge is tapped. - Anything else goes through the same pipeline by appending an attachment: `Attachment(image:)` for a camera capture or paste, `Attachment(data:)` for a drop or a saved draft, `Attachment(fileURL:)` for a file, `Attachment(item:)` for your own `PhotosPicker`, and `Attachment { progress in ... }` for your own download. For drops, add `.dropDestination(for: Data.self) { items, _ in attachments += items.map { .init(data: $0) }; return true }`. - Each tile goes loading, then loaded or failed. The ring in the status bubble spins until the source reports progress (an iCloud download, or your loader setting `totalUnitCount` and `completedUnitCount` on the `Progress` it is given), then fills smoothly with no overshoot. When a load finishes the attachment is loaded at once: the payload is set and VoiceOver reads Loaded. A ring that was showing progress fills and holds full for a beat (about 150 ms), then the photo develops under the bubble, settling from a slight zoom inside the tile without ever showing the tile's edge, while the bubble melts into the remove badge on a spring with no bounce. A video's duration badge, a glass capsule, rises in a beat later, and an upload dim you start right away appears with the photo. A failed load floods the bubble red, blurs the ring into a retry arrow and hangs "Retry" under it; tapping the tile, or calling `attachments[i].retry()`, drains the red and swaps the ring back as it loads again. - Thumbnails are cut with ImageIO on a background task at the largest tile size times the screen scale, with orientation applied. The full-size image is never decoded, so a 48 MP photo costs a few hundred kilobytes, not a hundred megabytes. A text size change never triggers a reload. - Picked images keep their original bytes in `data` (HEIC stays HEIC, nothing is transcoded). `jpegData(maxPixelSize:compressionQuality:)` gives an upload-ready JPEG, downsampled, with location and other metadata removed. - Videos (with `matching: .videos` or `.any(of: [.images, .videos])`) are copied to the temporary directory, never loaded into memory. `fileURL` points at the copy, the tile shows a poster frame and a duration badge, and `duration` is set. Delete the copy after upload if you need the space sooner than the system does. - Removing a tile cancels its load, including the photo library transfer. A load that finishes after its tile was removed, or after a newer attempt started, is dropped. - The picker only allows the remaining count. Photos already in the tray (matched by library identifier, also within one pick) are skipped: the strip glides to their tile, which bumps once as it lands. Anything past the limit is left out with a warning haptic and one announcement. At the limit the add tile shows "6/6" and disables. - Set `uploadProgress` on an attachment to show your upload: the status bubble buds back out of the remove badge with the upload ring while the tile dims below 1, and once it reaches 1, a beat after the dim lifts, the bubble lands in the opposite corner as a sage check, whether or not you animate the write. Clearing `uploadProgress` melts the check back into the badge. - Empty, the add tile is wide and shows the title with "Up to 6". With attachments its glass narrows to a square with the count: the plus disc travels into place, the title clears out first and the count arrives after it, and later counts roll, never past the number they show. Picks drop onto the strip, settling from just above full size with a little give that dips just inside it (at most 5 pt past each edge, so larger tiles stay inside the strip), and every load starts at once. A first pick into an empty tray drops one after another in pick order, a beat apart (up to five beats, so a big pick never drags). Later picks, and tiles you append yourself, land together, and if the tray already had tiles the strip glides to the newest one. Removed tiles shrink away quickly with their glass and the rest close the gap. Tiles already in the tray when it appears (a restored draft) start with their badges in place. - Loaded tiles call `onSelect` when tapped, for example to open a full-screen viewer. Every tile has a context menu with Remove (and Retry when failed). - VoiceOver: each tile is one element read as "Photo 2 of 4" with a value such as "Loading, 40%", "Couldn't load", "Uploading, 62%" or "Loaded", and Remove and Retry actions. The add tile reads its title and "3 remaining" or "Limit reached". Adds, skipped picks, removals and failures are announced once, and "All attachments loaded" when the last load finishes. - Haptics: a soft impact when picks land, a rigid impact on remove, a warning for skipped picks, an error when a load fails (one for a batch that fails together, such as when offline), a selection tick on retry. Presses play none. - Dynamic Type grows tiles up to 1.5 times `Style.tileSize`; text inside tiles stops growing at the same point so it never outgrows its tile. Right to left, the add tile leads from the right and badges sit top left; thumbnails are never mirrored. - Tiles with a tap action, the add tile and the remove badge sink slightly on press and spring back, centered so scrolling the strip is never disturbed; a pressed tile's glass sinks with it. A tile with nothing to do on tap (still loading, or loaded with no `onSelect`) stays still. - Reduce Motion: tiles, the photo and the glass bubbles fade in place instead of budding and moving, picks arrive together, presses dim instead of sinking, a tile picked again flashes brighter instead of bumping, the add tile's plus disc and count crossfade instead of travelling and rolling, and the spinner is the system indicator. The progress ring still closes before the photo shows, and every haptic and announcement stays. - Horizontal inset: apply `.contentMargins(.horizontal, 20, for: .scrollContent)` to the tray so tiles line up with your content and still scroll to the screen edge. - `Style` sets the placeholder (`tile`), label and secondary colors, the badges' glass tint (`badge`, clear by default) and ink, the plus disc and retry tint (`signal`) and its ink, the uploaded check's tint, the corner radius, tile size and spacing. `.disabled(true)` dims the add tile, and the remove badges melt into their status bubbles (or thin away where there is none). ### Parameters - `title`: Label of the add tile while the tray is empty, and its VoiceOver label. Once there are attachments the tile shrinks to a square showing the count. - `attachments`: Bound attachments, in display order. The tray appends picks, writes load progress, thumbnails and payloads back by id, and removes an attachment when its remove badge is tapped. Loads for attachments you remove are cancelled and their results ignored. - `limit`: Most attachments the tray lets people pick (at least 1). The picker only allows the remaining count; at the limit the add tile shows "6/6" and disables. Extra picks and picks already in the tray are not added. - `matching`: What the picker offers, such as `.images`, `.videos` or `.any(of: [.images, .videos])`. Videos load as a file copied to the temporary directory with a poster frame thumbnail and a duration badge. - `style`: Colors and tile metrics. Defaults to the SwiftPieces house palette, adapting to light and dark. - `onSelect`: Called when a loaded tile is tapped, for example to open it full screen. When `nil`, loaded tiles have no tap action. ## Link Preview (`LinkPreview`) URL: https://swiftpieces.com/docs/components/media/link-preview Category: Media. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: LinkPreview.swift Install: npx swiftpieces add LinkPreview A rich card for any URL that holds its final size while a skeleton sweeps, then settles the page's image and title into place under a liquid glass host chip that is there from the first frame (riding over the picture on the large card), fetched once with LinkPresentation, shared by every card showing the same link and cached so scrolling never refetches, falling back to a quiet card with the address when a page cannot be read, opening on tap and offering Copy Link and Share on long press. A rich preview card for a URL: the page's image, title and host, fetched once and cached. The card is content; its one piece of chrome, the host, sits on a small liquid glass chip. It shows from the first frame (the host is known before the page is) and rides over the picture on the large card. Tapping opens `url` through the environment's `openURL`, so an app that shows links in its own browser routes it with `.environment(\.openURL, OpenURLAction { url in … })` like any other link. ### Notes - `LinkPreview(url:)` fetches the page with LinkPresentation and shows its image, title and host. Pass `metadata:` to show your own (previews, tests, links your server already unfurled) with no request. - The card is content; its one piece of chrome, the host, sits on a small liquid glass chip: over the top leading corner of the picture on the large card, above the title on the compact one. The host is known before the page is, so the chip is there from the first frame, through the skeleton. A redirect blurs the final host in over the old one as the glass reshapes around it. - Three states, one footprint. While loading, the card is a skeleton built from the real layout, so the large card keeps its exact size when the content arrives. The picture develops first, under its chip, fading in from just above full size and settling flat on the placeholder; the title starts on the same beat (nothing you read is held back) and rises a few points into the place its skeleton held, on a slightly softer spring with no bounce, so it settles just after and text never wobbles. A card that starts from the cache appears at once, with no animation. A page that cannot be read (offline, timeout, unknown host, a page with nothing in it) becomes the quiet address card: a link glyph, the host on its chip and the address, still tappable. - `.compact` is a row with a square thumbnail, for chats and lists. `.large` puts a 1.91 : 1 image on top, capped at 280pt tall so a wide card crops instead of growing. Both take the width they are offered. A failed large card closes to the compact address card with no overshoot, so the feed below reflows once without bobbing. - No image falls back to the site icon on a quiet tile, then to the host's first letter on a solid block picked from the host, so a site keeps its color. A tiny "image" is treated as an icon rather than stretched. - Links that are not `http` or `https` never make a request: email, phone and message links show the address card with their own glyph and a chip such as "Email". - Redirects show the final host. Titles are trimmed with runs of whitespace collapsed, at most two lines (three at accessibility sizes). Long addresses truncate in the middle. - One cache for the whole app: at most 150 pages and about 48 MB of decoded images in an `NSCache`, emptied by the system under memory pressure. Cards showing the same URL share one fetch, a card that scrolls back into view starts from the cache with no skeleton, and a failure is remembered for 30 seconds so a failing link does not refetch on every scroll. - A card that disappears or changes its URL stops waiting at once; the fetch itself is cancelled when no card is waiting for it. A late result can never land on a card whose URL has changed. `timeout` (15 seconds) is enforced even while a connection is still being attempted. - Each fetch uses its own `LPMetadataProvider`, created, started and cancelled on the main actor. Images are decoded and downsampled with ImageIO off the main actor and never kept at full size. - Pressing sinks the card by a couple of points at each edge, from its center so it never fights a scroll, and it springs back on release. It stays opaque. - Tap opens the link through the environment's `openURL`, so an app with its own browser routes it with `.environment(\.openURL, OpenURLAction { … })`. Long press shows Open Link, Copy Link and Share (with the title and image in the share sheet); copying plays a success haptic. - VoiceOver: one element read as "Title, host" with the link trait, a Copy Link action, and "Loading preview" as its value while loading. Copying is announced. - Dynamic Type: text scales, the thumbnail grows to 96pt, and at accessibility sizes the compact card stacks the text under the thumbnail at full width. - Reduce Motion: the skeleton stops sweeping and breathes in place instead (a slow opacity pulse partway toward the highlight and back, on the same shared clock), so a loading card still shows it is working and its bones never fade into the card. The press darkens the card slightly (lightens it in dark mode) instead of sinking, and the picture and text fade in without scale or rise. - All text is one weight, semibold; size sets the hierarchy. The chip is Liquid Glass on iOS 26, a frosted material before that, and a solid fill under Reduce Transparency. - `Style` sets the card, skeleton, highlight, title, secondary (the chip's text) and tile colors, the letter ink and the corner radius. `.disabled(true)` dims the card. ### Parameters - `url`: The link to preview and open. `http` and `https` links are fetched; any other scheme (email, phone, app links) shows the quiet link card at once, without a request. - `layout`: `.compact` is a row with a square thumbnail on the leading side, for chat bubbles and lists. `.large` puts a wide image on top, for feeds and composers. Both take the width they are offered. - `metadata`: Metadata to show as is, with no request: for previews, tests, or links your server has already unfurled. `nil` fetches. - `timeout`: Seconds before a fetch gives up and the card falls back to the address. - `style`: Colors and corner radius. Defaults to the SwiftPieces house palette, adapting to light and dark. ## Now Playing (`NowPlaying`) URL: https://swiftpieces.com/docs/components/media/now-playing Category: Media. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: NowPlaying.swift Install: npx swiftpieces add NowPlaying A mini player that grows into the full player in one continuous liquid glass motion: a glass bar with the artwork, the title over the artist and a red progress hairline, with a red play bubble and a forward bubble joined to its end. Tap it or pull it up and the bar stretches with the finger, then settles open with the flick's speed: the same glass grows into a card, the artwork, title and play button fly to their places, and previous and next bud out of play. Scrub the track with a swelling thumb while the times follow, skip with a letter by letter title morph and a blurred artwork swap, and drag the card down to fold it back into the bar. Playback stays the app's: the piece shows the time it is given and reports taps, skips and seeks. A mini player that grows into the full player: one liquid glass shape that morphs from a bar into a card. Collapsed, it is a glass bar with the artwork, the title over the artist and a red progress hairline, with a red play bubble and a forward bubble joined to its end. Tap it, or pull it up (it stretches with the finger), and the same glass grows into a card: the artwork, title and play button fly to their places, previous and next bud out of play, and a scrubber and the times blur in. Drag the card down, or tap its grabber, to fold it back. Playback is the app's. The piece never runs a clock: it shows the `time` it is given, and calls back when people play, pause, skip or seek. Put it in the bottom safe area inset of your screen; the card opens over the content above the bar. ```swift .safeAreaInset(edge: .bottom) { NowPlaying(title: track.title, artist: track.artist, isPlaying: player.isPlaying, time: player.time, duration: track.duration, expanded: $showsPlayer, onPlayPause: { player.toggle() }, onSkip: { player.skip($0) }, onSeek: { player.seek(to: $0) }) { track.artwork.resizable().scaledToFill() } .padding(.horizontal, 12) } ``` ### Notes - Put it in the bottom safe area inset of your main screen, `.safeAreaInset(edge: .bottom) { NowPlaying(…).padding(.horizontal, 12) }`. Its layout size is only the bar, so your scroll content stops above it, and the card opens over the content above the bar, never past the top safe area. - Playback is yours. The piece never runs a clock: pass your player's `time`, `duration` and `isPlaying`, and act in `onPlayPause`, `onSkip` and `onSeek`. Time updates are eased linearly over the gap between them, so a player that reports once a second still creeps smoothly. - Collapsed, it is a glass bar (the artwork, the title over the artist, a red progress hairline) with a red play bubble and a forward bubble joined to its end on liquid necks. Tap the bar, or pull it up: the glass stretches after the finger with a rubber band's resistance, and a pull or flick that would carry past 72 points opens it; a shorter one springs back. - Opening is one continuous morph: forward melts into play, the same glass grows into the card, the artwork flies from its tile to its square, play flies to the middle of the transport and swells, and previous and next bud out of play and rest apart from it. The title and artist blur out as they leave the bar and back in at the card's size, so they never cross the growing artwork. A pull carries the finger's speed into the morph. - Drag the card down from anywhere but the scrubber and it follows the finger; let go past about a fifth of its height, or flick, and it closes: the controls blur out, previous and next melt home, the card folds into the bar and forward buds back out. Tap the grabber to close it too. `expanded` follows every change, and setting it from your code runs the same choreography. - The scrubber: touch the track anywhere and the thumb jumps under the finger and swells; the times follow, your player's updates are ignored while the finger is down, and `onSeek` fires once on release. The thumb then holds the new position until your `time` catches up (or a second and a half passes), so it never jumps back. `onSeek: nil` makes it a read-only progress bar. - Skipping: `.tracks` calls `onSkip(.previous)` or `onSkip(.next)`; the title morphs letter by letter and the artwork blurs into the new one when your title changes. `.seconds(back: 15, forward: 30)` (or `.podcast`) shows `gobackward.15` and `goforward.30` and seeks through `onSeek` instead. Without a handler the skip bubbles don't appear. - States: a `duration` of `0` is a live stream (no hairline, no scrubber, Live in place of the times); an empty `title` shows "Not playing"; long titles truncate in one line; the default artwork is a sand tile with a note. - The card's artwork fills its width and the card hugs the height left above the bar: on a short window it shrinks, and in landscape or at the largest text sizes the card switches to a row with a small artwork beside the lines. - VoiceOver: collapsed, the bar is one button, "Now playing, Slow Mornings, Aster Vale", valued Playing or Paused, with Play or Pause, Next track and Open player actions. Open, focus moves to the title (a header); the scrubber is adjustable in steps of 15 seconds and reads "1 minute, 12 seconds of 3 minutes, 42 seconds"; the transport and the grabber ("Close player") are buttons, and the escape gesture closes. A skip announces the new title and artist. - Hardware keyboard: Space plays or pauses, Return opens, Escape closes, and the left and right arrows seek 15 seconds while open. - Reduce Motion: no stretch under the finger, the glass changes shape on a short ease without overshoot, the artwork and lines fade out and back in at their new places instead of flying, and the bubbles fade in place. - Dynamic Type: the lines, the artwork tile and the mini buttons scale, the bar grows taller and the lines truncate; the card's line heights follow, and its artwork gives way first. Right to left mirrors the bar and the card's lines; the transport, the scrubber and the hairline keep left to right, as media controls do. - Haptics: a light impact on play and pause, a selection tick on skip and when a finger grabs the scrubber, and a soft impact when the player lands open or closed. - Red marks three things only: the play bubble, the progress hairline and the played part of the track. Everything else is clear glass and house ink. All text is one weight, semibold. - `Style` sets the accent and its ink, the label colours, the track, the thumb, the grabber and the placeholder tile. `Messages` replaces any of the copy and the VoiceOver strings, such as `untitled: "Nothing queued"`. ### Parameters - `title`: The track, episode or station. It morphs letter by letter when it changes, and truncates when it doesn't fit. An empty title shows `messages.untitled`. - `artist`: The secondary line: the artist, show or author. - `isPlaying`: Whether the app is playing. The red play bubble shows pause while it is `true`. - `time`: The playback position in seconds, from your player. Updates between ticks are eased, so the hairline and the scrubber creep smoothly at any update rate. While people scrub, updates are ignored. - `duration`: The length in seconds. `0` (or any value that isn't positive and finite) is a live stream: the hairline, the scrubber and the times give way to `messages.live`. - `expanded`: Whether the full player is open. Bind it to open or close the player from your own code; any change animates. - `skipping`: What the skip buttons do: `.tracks` (previous and next, through `onSkip`, the default) or `.seconds(back:forward:)` for podcasts and audiobooks (through `onSeek`). - `messages`: The built-in copy and VoiceOver strings. Defaults are localizable through your String Catalog; replace any line. - `style`: Colors. Defaults to the SwiftPieces house palette, adapting to light and dark, with red for play, the hairline and the played part of the track. - `onPlayPause`: Called when the play bubble is tapped (or Space is pressed). Toggle your player here. - `onSkip`: Called with `.previous` or `.next` in `.tracks` mode. `nil` hides the skip bubbles. - `onSeek`: Called once with the new position when a scrub ends, a seconds skip is tapped, or VoiceOver adjusts the scrubber. `nil` makes the scrubber a read-only display. - `artwork`: The artwork, filled into a square. Omit it for a house tile with a note. It blurs into the new one whenever the title or artist changes. ## Photo Cropper (`PhotoCropper`) URL: https://swiftpieces.com/docs/components/media/photo-cropper Category: Media. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: PhotoCropper.swift Install: npx swiftpieces add PhotoCropper A crop step for profile photos, posts and listings that keeps the picked photo covering a frame of the chosen aspect at every zoom and offset, pinches around the fingers while it pans, rubber-bands past its limits and springs back with a red flash on the frame and a rigid tick at a zoom limit, morphs between circle, square, 4:5, 16:9 and the photo's own shape without losing the subject as a red liquid glass puck stretches along the aspect chips' glass track, turns a quarter at a time, fades in a rule-of-thirds grid under the finger, floats its zoom level over the photo on glass, and renders the crop off the main thread at the original's full resolution together with its pixel rect and rotation for cropping again on a server. A crop step for a picked photo: the photo under a frame of the chosen aspect, with pinch, pan, rotate and aspect chips. The photo is content; the controls around it are liquid glass. The aspect chips sit on one glass track, with a red puck under the selected one that stretches between picks. Rotate and Reset rest joined as one pair of tools, apart from Cancel and the red Choose capsule, and the zoom level floats over the photo on glass while you pinch. The photo always covers the frame. It can't be zoomed out or dragged so far that an empty edge stays in the frame: past a limit it resists and springs back on release. The framing is stored relative to the photo (zoom relative to the smallest scale that fills the frame, and the photo point at the frame's centre), so rotating, switching aspect and size changes keep the same subject in frame. Choose renders the crop from the original, off the main actor, and hands you a ``Crop``. A drag that starts on the photo pans it; it never scrolls an enclosing scroll view or pulls a sheet down. ```swift @State private var photo: UIImage // keep it in state, never create it in `body` PhotoCropper(image: photo, aspect: .circle, onCancel: { dismiss() }) { crop in avatar = crop.image dismiss() } ``` ### Notes - The photo is content; the controls around it are liquid glass. The aspect chips sit on one glass track with a red puck under the selected chip. Rotate and Reset rest joined, one pair of tools for the crop, apart from Cancel and the red Choose capsule. The zoom level and any notice float over the photo on dark glass, since the well is dark in both appearances. On iOS 26 this is Liquid Glass; before that, a frosted material; under Reduce Transparency, solid fills. - Put it after a `PhotosPicker` (or the camera) in a `.fullScreenCover`, a pushed screen or a sheet. Keep the photo in state, for example `@State private var photo: UIImage?`, never created in `body`: a new image starts the cropper over. - The photo always covers the frame. Zoom is relative to the smallest scale that fills the frame (1 to 5, less for very small photos so a crop never drops below 32 pixels), and the framing is stored as that zoom plus the photo point at the frame's centre. Rotating, switching aspect, rotating the device and resizing all keep the same subject in frame. - Pinch zooms about the fingers while they pan, both at once, and the photo stays exactly under them. Past the edges of the photo, or past 1x and the maximum zoom, it follows with a scroll view's resistance. Let go and it lands: a flick carries on at the photo's own speed into a short coast, a pull past an edge springs back from where the band left it, and a pinch past a limit comes back from rest. None of it bounces or runs past its stop: a coast stops where it's going, and a hard flick into an edge arrives fast and stops at the edge. Touch the photo before it has landed, or while a double tap, a key step or Reset is still zooming or moving it, and it stops where it is, under the finger, the way a scroll view does; a change of aspect or a turn always runs to its end. At a zoom limit the frame's edge and corner marks flash red for an instant with a rigid tick. Double-tap toggles between filling the frame and 2.5x about the tapped point. - A rule-of-thirds grid fades in the moment a finger touches the photo and out half a second after the last one lifts. The zoom level settles in above the frame on a glass capsule while pinching and lifts away with the grid. - The aspect chips default to circle, square, 4:5, 16:9 and the photo's own shape; pass your own list (`[.square, .portrait, .ratio(3, 2)]`), retitle one with `.titled("Post")`, or pass a single aspect (or none) for a fixed frame. The red puck travels to the chip you pick as two tinted shapes, one landing with a little give and the other a beat behind, so it stretches along the track like a drop of liquid and gathers as it lands; the chip's picture turns solid ink on it. Meanwhile the frame and the photo morph to the new shape together without overshoot, so the photo never leaves part of the frame uncovered. `.circle` shows a round frame but the crop is square: clip it when you draw it. - Rotate turns the photo a quarter to the left each tap; quick taps keep turning the same way. The turn never swings past its stop, and mid-turn the photo grows just enough that the frame's corners never leave it. `.original` turns with the photo. - Chips, Rotate, Reset, Cancel and Choose sink a little under the finger and spring back. Reset's label dims while there is nothing to reset. - Choose renders from the original, off the main actor, in the photo's own color space (Display P3 stays P3), upright, at up to `maxOutputDimension` (4096) on the longer side and never scaled up. `onCrop` gets a `Crop` with the `image`, the `rect` in pixels of the upright original and `quarterTurns` (to the left, applied after the rect), so a server can repeat the crop on the original, plus the `framing`. Choose spins until your `onCrop` returns, so you can await an upload there, then a check blurs in, and a beat later the label blurs back. The capsule holds its width throughout. If the cropper goes away mid-render the render is dropped; an `onCrop` that has started is never cut off. - `framing:` binds the aspect, zoom, centre and rotation instead of `aspect:`. Store `crop.framing` (it is `Codable`) and pass it back to reopen a crop where it was left, or drive the cropper from your own controls. Reset returns to the photo filling the starting aspect and is enabled only once something changed. - EXIF orientation is respected on screen and in the output. A photo that is much larger than the screen is shown from a copy cut once, off the main actor, so a 48 MP photo never decodes on the main thread; a quiet placeholder holds the frame meanwhile. A photo that can't be drawn shows "Couldn't open this photo" in the frame, and Choose and the puck drain to neutral glass. A failed render shows a short notice, rising in on glass at the bottom of the well, with an error haptic. - A drag on the photo pans it: it never scrolls an enclosing scroll view or pulls a sheet down, because those pans wait for the cropper's own. Pinch and pan come from UIKit's recognizers, which report the fingers' moving centre. - Haptics: a selection tick when the aspect changes, a light impact on rotate, a rigid tick at a zoom limit or an edge (once for a held key, not on every repeat), a soft tick on Reset, success when the crop is delivered, an error if rendering fails. - VoiceOver: the photo is one adjustable element, "Crop area", with a value such as "2.5×, 4:5, Rotated left 90°". Swipe up and down to zoom, and use the Move up, down, left and right actions (a fifth of the frame each), Rotate left and Reset. Aspect chips are buttons with the selected trait ("4 to 5"), and rotations, resets and the finished crop are announced. - Hardware keyboard: the arrow keys move and + and - zoom when the photo is focused, Return chooses, Escape cancels and Command-R rotates. - Reduce Motion: the photo lands, zooms and changes aspect on a short spring with no overshoot, a quarter turn crossfades to the turned photo and frame instead of swinging round, and the puck fades over to the chip you pick instead of travelling. The grid, the zoom level, the photo appearing and the red limit flash still fade, so you can follow what changed, and pressed buttons dim instead of sinking. - Dynamic Type: chips and buttons scale (chips stop at the second accessibility size so the photo keeps its room). When the buttons no longer fit on one line, Choose moves to its own full-width row, and at the largest sizes Cancel does too; separate actions never come closer than the merge distance, so their glass never necks. Right to left mirrors the toolbar; the photo is never mirrored. All text is one weight, semibold. - `Style` sets the toolbar ground, the photo's well (dark in both appearances so the photo, scrim and frame read the same), the scrim, the frame's edge, the red accent (Choose, the puck and the limit flash) and the ink on it, labels, the well's corner radius (`0` runs the photo edge to edge) and the frame inset. `control` and `selectedControl` are no longer used: the controls are neutral glass and the puck takes `accent`. `Messages` replaces any of the copy, such as `choose: "Use photo"`. ### Parameters - `image`: The photo to crop, at full size, such as one loaded from a `PhotosPicker` item. Its orientation is respected. An on-screen copy is cut from it once, off the main actor; the crop is always rendered from this original. Keep it in state rather than creating it in `body`, or every update starts over. - `aspect`: The frame to start with. Defaults to `.square`. - `framing`: Bound framing, instead of `aspect`: the aspect, zoom, centre and rotation, updated as people adjust them. Set it to reopen a crop where it was left (`crop.framing` from an earlier result) or to drive the cropper from your own controls. While a finger is down it can briefly sit past the limits (the rubber band); it settles inside them on release. - `aspects`: The aspect chips under the photo, in order. Defaults to circle, square, 4:5, 16:9 and the photo's own shape. One aspect, or none, hides the chips for a fixed frame. An aspect that isn't listed is added at the front. - `maxOutputDimension`: The longest side of the rendered crop, in pixels. Defaults to 4096. Larger crops are scaled down to it; smaller ones keep the original's pixels and are never scaled up. - `messages`: The cropper's copy. Defaults are localizable through your String Catalog; replace any line, for example `choose` with "Use photo". - `style`: Colors, the photo's well and the frame inset. Defaults to the SwiftPieces house palette, adapting to light and dark, with red for Choose, the selected aspect and the limit flash. - `onCancel`: Shows a Cancel button (and Escape on a hardware keyboard) that calls this. `nil` hides it. - `onCrop`: Called on the main actor with the rendered ``Crop`` when Choose is tapped (or Return is pressed). Choose keeps spinning until this returns, so you can await an upload here. ## Photo Viewer (`PhotoViewer`) URL: https://swiftpieces.com/docs/components/media/photo-viewer Category: Media. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: PhotoViewer.swift Install: npx swiftpieces add PhotoViewer A full-screen photo surface with anchor-correct pinch zoom, momentum panning that gives a little at the bounds and rebounds, double-tap zoom about the tap, paging between images, and pinch-out or drag-down to dismiss with a fading scrim. A liquid glass close button and page counter float over the photos; while zoomed the counter floods with a light tint and rolls into a live zoom readout, and a single tap hides them. Photos-style viewer: page horizontally at rest, zoom and pan a page, pinch past the minimum or drag vertically to dismiss. ### Notes - Zoom is anchored to the pinch start and to a double tap, so the content under the fingers stays put. Past `maxScale` the pinch meets resistance that stiffens toward a ceiling about 35% higher, with a rigid tick; releasing brings it back into range. A pinch released past `maxScale` comes back with no overshoot, so the photo never dips below its scale or opens its edge. A pinch released below `minScale` that doesn't dismiss rebounds with visible give when swelling past rest covers the photo's swing, as it does for any pinch that began at rest; one pinched out from a zoomed, off-center view comes back with no overshoot instead. A double tap or a VoiceOver action zooms in with a little give and zooms out with none. None of these swings the photo past an edge. Returning to `minScale` plays a soft tick. - Pinch and pan follow the fingers directly, never animated. Past the scaled bounds a pan stiffens like a scroll view's edge. A released pan leaves at the finger's speed and coasts to `predictedEndTranslation`, clamped to the bounds, for longer the further it glides. An axis that runs into an edge keeps the finger's speed, carries on past the edge by a give that grows with the flick (under 80 pt), then rebounds; a pan that catches it there picks it up from inside the band instead of jumping. - At rest a page ignores horizontal drags so the paging `ScrollView` handles them; vertical drags and any drag during a pinch belong to the dismiss. Paging is disabled while zoomed or dismissing. - Two dismiss paths: drag vertically past 140 pt (or flick past a projected 280 pt) and the page slides off along the release direction at the speed you threw it; pinch below 80% of `minScale` and release, and it shrinks away. Both fade the scrim with progress and call `onDismiss` when the animation is logically complete. A pull that doesn't commit slides home at the speed you let go and settles with some give, and the scrim and chrome come back with the photo rather than ahead of it. A drag that catches it on the way home picks it up where it is drawn, so it never jumps to the finger. - A pinch and a drag can end in either order: the page settles once both have let go, so it never strands below `minScale` or off center. A touch the system cancels (a call, Control Center) lets go the same way, without dismissing. - Reduce Motion keeps every settle short with no overshoot or edge rebound, fades a committed dismiss where it is instead of flying or shrinking it, dims the close button on press instead of sinking it, and crossfades the counter instead of rolling its digits. Haptics and the scrim fade still mark each step. VoiceOver gets Zoom in, Reset zoom, and the escape gesture dismisses. - Floating chrome in liquid glass, apart in its corners. A 44 pt glass close button (shown when `onDismiss` is set) fades the viewer out and calls `onDismiss`; a glass counter reads "2 / 3" at rest and, while zoomed, floods with a light tint as its digits roll into the zoom readout ("2.5×"). The chrome is the dark glass with paper ink in either appearance, since the viewer is a darkroom. A single tap hides or shows the chrome: it snaps into place with a slight give and leaves quickly, and fades as a dismiss drag progresses. The close button sinks on press; the counter's glass reshapes with a little give, into the readout or wider when the count gains a digit, while its digits roll without overshoot, so the count and zoom level never read past their value. With a single photo the readout drops in on its own once you zoom. - `style:` (`PhotoViewer.Style`) sets `scrim` (near-black #121212), `chromeInk`, `zoomFill` (the counter's tint while zoomed) and `zoomInk`, `showsCloseButton`, and `showsCounter`. `chromeFill` is no longer used: the chrome is liquid glass. `.bare` draws photos only. The chrome is Liquid Glass on iOS 26, a frosted material before that, and solid under Reduce Transparency; its text is semibold, and the photos you pass in keep their own type. - The example pages through flat color prints; a dismiss collapses the viewer and the example presents it again, so the preview only ever shows the viewer. - For a full gallery screen, see the Pro Gallery screen (`depth-gallery`). ### Parameters - `items`: The photos to page through. One item disables paging. - `selection`: Optional binding to the visible item's id. - `minScale`: Resting scale of a page. - `maxScale`: Largest committed zoom; pinching past it rubber-bands, ticks, and springs back. - `style`: Scrim and chrome. `.standard` draws a near-black scrim and, in liquid glass over it, a close button when `onDismiss` is set and a counter that reads "2 / 5" at rest and the zoom level while zoomed. - `onDismiss`: Called once a dismiss gesture or the close button has finished animating; end the presentation here. - `content`: The photo for an item, typically a resizable image with `.scaledToFit()`. ## Story Strip (`StoryStrip`) URL: https://swiftpieces.com/docs/components/media/story-strip Category: Media. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: StoryStrip.swift Install: npx swiftpieces add StoryStrip A Stories-style segment strip with a built-in clock that advances segments on a duration, a long-press hold that freezes the strip and buds a liquid glass Paused badge down out of the bars (it melts back up into them on release), tap zones for back and forward that answer with a glass chevron bubble, and a manual progress mode when you own the timing. Segment strip plus the gesture surface of a story player. Pass `duration` to let it run itself, or leave it nil and drive `progress`. ### Notes - With `duration` set the strip runs itself: a `TimelineView(.animation)` draws the fill and a task sleeps for the rest of the segment, then advances `current`. Pausing (a hold, or `isPaused`) records the pause time and shifts the segment start on resume, so the fill continues exactly where it stopped. - Without `duration` the strip is stateless like before: you own `current` and `progress`, and steps animate with a short linear curve so 30 Hz updates read as continuous. - Holding anywhere on the surface for 200 ms pins the strip: the clock freezes on its frame and the bars dim at once with a soft tick. Releasing lifts the dim and resumes. A touch the system interrupts, such as an incoming call or Control Center, also lets go of the hold, so a story never stays paused. A shorter touch is a tap: the left 30% goes back (restarting the first segment when `duration` is set) and the rest advances, the moment the finger lifts. - Segment steps fill or empty the bars on a short spring that never overshoots, since the bars say where the story is. The running fill is a clock: it is drawn from the time every frame and never animated, so it stays exact and never bounces. - Haptics: a soft impact when a hold engages, and one `.selection` tick for each move you ask for with a tap or a VoiceOver swipe that changes something, including restarting the first segment when `duration` is set. A move with nothing to change stays silent: forward after the story has finished, or back on the first segment when you drive `progress`, since there is no clock to restart. The clock's own advances and changes you make to `current` stay silent too, since a story can run for minutes. - `fillsContainer` stretches the invisible surface over the whole story with the bars pinned to the top; set it to false to use only the bar row. - `onFinish` fires once when the last segment completes. VoiceOver reads "3 of 5", swipes up or down move between segments, and a custom action pauses or resumes. - Holding buds a liquid glass "Paused" badge, tinted with the strip's `tint`, down out of the trailing end of the bars once the strip is pinned: it is born under the bars, springs out to its place, and its label sharpens just after. Let go and it rises back into the bars on a spring with no bounce, its label gone at once and its tint draining; the bars are not glass, so it thins away on the way and is gone by the time it gets there. A tap that moves back or forward flashes a glass chevron bubble in that zone, tinted the same way, which springs in with a little give, nudging in the direction of the move, and drifts on as it fades. Bars are 3 pt by default. - With Reduce Motion the badge and the chevron fade in place instead of moving, and segment steps settle without overshoot. Under Reduce Transparency the glass draws solid. The dim, the haptics and the running fill are unchanged, so the strip still says when it is paused and where it is. - `style:` (`StoryStrip.Style`) sets `barHeight`, `spacing`, `trackOpacity`, `inset`, `showsPausedBadge`, `showsTapHints`, and `ink` (the glyph and text color on the glass, which is tinted with `tint`). `.minimal` restores the version 1 look. All text is one weight, semibold. - The example runs the strip in dark ink over solid color slides with one big idea each. ### Parameters - `count`: Number of segments. - `current`: Index of the segment currently filling. The strip advances it when it drives the clock. - `progress`: Fill of the current segment from 0 to 1, used only when `duration` is nil. - `duration`: Seconds per segment. When set, a `TimelineView` clock fills the segment and advances at the end. - `isPaused`: Optional external pause, for example while a reply sheet is up. Holding the strip pauses on its own. - `tint`: Segment color; the track is the same color at low opacity, and the paused badge and tap hints are liquid glass tinted with it. - `style`: Bar geometry, track strength, and the paused badge and tap hints. `.standard` draws 3 pt bars and shows both. - `fillsContainer`: Expand the gesture surface to the whole container so holds and taps work over the story, with the bars pinned to the top. - `onFinish`: Called when the last segment completes. ## Voice Recorder (`VoiceRecorder`) URL: https://swiftpieces.com/docs/components/media/voice-recorder Category: Media. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: VoiceRecorder.swift Install: npx swiftpieces add VoiceRecorder The hold-to-record voice message button for chat, notes, journaling and support composers: press and hold the glass mic beside your field and it swells red as a glass recording capsule buds out of it over the field, with a live clock and speech bars scrolling toward the edge and a lock lane budding up above it; slide toward the leading edge and a glass bin buds out to take the recording, which tips closed and melts away, slide up the lane to lock for hands-free recording with a red send button and a trash bubble, or simply let go to send an .m4a with its duration and waveform samples ready for Waveform Scrub, with the microphone permission flow, a teaching hint for taps that are too short, a red countdown over the last ten seconds, interruptions and a simulated source for previews all handled. The hold-to-record voice message button for a chat, notes, journaling or support composer. Your field sits at rest with a glass mic beside it. Press and hold the mic: after a short beat (so taps and scrolls never record) it swells red and a glass capsule buds out of it over your field, with the elapsed time and live speech bars scrolling toward the leading edge, while a lock lane buds up above it. Then one of three things: - Let go to send. The capsule melts back into the mic and `onSend` gets a ``Recording``: an `.m4a` in the temporary directory, its duration, and loudness samples that drop straight into `WaveformScrub(samples:)`. - Slide toward the leading edge to discard. A glass bin buds out at the threshold; let go there (or keep going) and the capsule gathers into it, it tips closed and melts away, the file is deleted and `onCancel` runs. - Slide up the lane to lock. The recorder goes hands-free: the mic turns into a red send button and a trash bubble buds out of the capsule. Tap send to finish or trash to discard. Holds shorter than `minimumDuration` and plain taps are discarded with a small hint that teaches the gesture. The first press asks for the microphone (add `NSMicrophoneUsageDescription` to your Info.plist); when access is off the hint offers Settings. Over the last ten seconds before `maximumDuration` the time counts down on red, and at the maximum the take is sent. A call, Siri, an alarm, a lost route, the app going to the background or the view going away discards a held take and stops a locked one, keeping it so Send and Delete still work. ```swift @State private var draft = "" VoiceRecorder { recording in chat.sendVoice(recording.url, duration: recording.duration, waveform: recording.samples) } content: { TextField("Message", text: $draft) } ``` Pass a ``Session`` you own to drive it from code (`start()`, `lock()`, `send()`, `cancel()`) or to read its `phase` and `level`; `Session(source: .simulated)` records a speech-like tone instead of the microphone, for previews and the simulator. ### Notes - One liquid glass control beside your field. At rest a glass mic rests apart from your content at the trailing end. Recording, the mic swells red and a glass capsule buds out of it over your content, joined to it by a short neck, while a narrow lock lane buds up out of it above. Hands-free, a trash bubble buds out of the capsule's leading end and the mic becomes a red send button. Red appears only on the recording mic, the send button, the recording dot and the last-ten-seconds countdown. On iOS 26 this is Liquid Glass; before that, a frosted material; under Reduce Transparency, solid fills. - Put it where your composer is, usually in `.safeAreaInset(edge: .bottom)` under a chat `ScrollView`, and pass your field as `content`. Your content is shown as it is (style it yourself) and fills the leading space; it fades out under the capsule while recording and comes back as the capsule melts home. Without `content` the mic rests alone at the trailing end and the capsule spans the whole width. - Add `NSMicrophoneUsageDescription` to your Info.plist (the piece's metadata carries "Records voice messages you send."). The first press asks for the microphone and records nothing; when access is granted the hint "Hold to record, release to send" buds out, and when it is off the hint reads "Microphone access is off" with Settings, which opens your app's settings. Without the usage description it never asks (asking would end the app): it shows the access hint and, in debug builds, prints a reminder. - Press and hold: after 0.18 seconds with the finger still, recording starts with a medium tap. A quicker tap, or a hold shorter than `minimumDuration` (0.6 seconds), is discarded and teaches the gesture with the hint. A finger that moves first is a scroll: the press gives way and the enclosing scroll view or sheet moves as usual. Once recording, nothing steals the drag. - Let go to send. The capsule melts back into the mic with a light tap and `onSend` gets a `Recording`: `url`, an AAC `.m4a` (24 kHz, mono) in the temporary directory that is now yours to upload, move or delete; `duration` in seconds; and `samples`, loudness from 0 to 1 at about 20 a second (at most 160, the peak of each stretch), ready for `WaveformScrub(samples:duration:…)`. - Slide toward the leading edge to discard. The mic follows the finger, the capsule shortens with it, "Slide to cancel" is eaten from its far end and fades, and the lock lane melts away. At the threshold (110 points, less on narrow screens) a glass bin buds out at the leading end with a selection tick; let go there, or keep going well past it, and the capsule gathers into the bin, which tips closed and melts away with a warning tap. The file is deleted and `onCancel` runs. Back short of the threshold, the bin melts home and everything returns. - Slide up to lock. The lane stretches with the finger; 56 points up the lock shuts with a rigid tick, the lane melts into the mic, the trash bubble buds out of the capsule and the mic turns into the red send button. Tap send to finish, tap trash to discard. A hold on the send button sends when it lifts. - The capsule shows a red dot that breathes once a second, the elapsed time (`0:07`, `1:02:07` when takes can run an hour; monospaced, so nothing shifts) and live bars, one every tenth of a second, scrolling toward the leading edge from beside the mic. Before there is sound the row is quiet dots. Over the last ten seconds before `maximumDuration` (5 minutes) the time counts down on a red block with ink digits, and at the maximum the take is sent, held or locked, with a success tap. - Interruptions: a call, Siri, an alarm, a lost audio route, reset media services, the app leaving the foreground, the control being disabled or the view going away discard a held take (the hint reads "Recording stopped" and `onCancel` runs) and stop a hands-free one, keeping it: the dot greys, the time and bars freeze, and Send and Delete still work. The audio session is set to play and record for each take and put back to the app's own category afterwards with `.notifyOthersOnDeactivation`, so other apps' audio resumes. - Recording runs off the main actor. Takes are numbered, so a release during a slow microphone start never leaves a recorder running, and every discarded take's file is deleted. A take is too short by how long it was held, so a slow start never turns a real hold into a hint. - `VoiceRecorder.Session` is the recorder's state and audio. Keep one in `@State` and pass `session:` to drive it from code: `start()` (hands-free at once), `press()` and `release()` (push-to-talk from your own control), `lock()`, `send()` and `cancel()`. Read `phase` (`.idle`, `.holding`, `.locked`, `.stopped`), `level` (0 to 1, live) and `elapsed`, for example to dim the conversation while recording. One session drives one recorder. - `Session(source: .simulated)` records no microphone and needs no permission: a deterministic speech-like level (phrases with short pauses) drives the bars, and the sent file is a soft tone shaped by it, so the URL plays and its waveform matches. Use it in SwiftUI previews, on the simulator and for screenshots. - Haptics: medium when recording starts, a selection tick at the discard threshold (both ways), rigid when it locks, a warning on discard, light on send, soft with a hint, success when the maximum sends it. Never per frame. - VoiceOver can't hold and drag, so the mic is a button, "Record voice message": a double tap starts a hands-free take at once, "Recording" is announced, and Send and Delete are buttons. The capsule is one element, "Recording", whose value is the elapsed time ("12 seconds", or "9 seconds left" in the countdown); it stays quiet while recording. The mic's actions are Record, and while recording Send and Delete. Escape (the two-finger scrub) deletes, and a two-finger double tap starts or sends. Hints are announced. - Hardware keyboard: while hands-free, Return sends and Escape deletes. - Reduce Motion: the mic doesn't swell, the chevrons don't drift, the capsule, lane, bin and hints open and close in place instead of budding, the bin doesn't tip, and digit changes fade. The bars still move: they are information. Pressed buttons dim instead of sinking. - Dynamic Type: the capsule, mic and glyphs grow with the text, part way and up to 1.4 times, so the bars keep their room; the time stops growing at the largest standard size. At accessibility sizes "Slide to cancel" drops to its chevron, and hints wrap onto more lines. All text is one weight, semibold. - Right to left mirrors everything: the mic sits on the left, discards slide to the right (the leading edge), the bars scroll right and the chevron points right. Hints wrap in the reading direction. - `Style` sets the red accent and the ink on it, the label and secondary label colours, the bars' colour, width and spacing, and the control's height (52 points, at least 44). `Messages` replaces any of the copy, such as `slideToCancel: "Slide to delete"` or `holdToRecord`; every default goes through your String Catalog. - Wire it up: in `onSend`, upload `recording.url` (or move it into your app's storage first), keep `duration` and `samples` with the message, and show it with `WaveformScrub(samples: message.samples, duration: message.duration, …)`. Delete the file once it is uploaded. ### Parameters - `session`: The recorder's state and audio. Pass your own to drive it from code or read its phase and level, or a `Session(source: .simulated)` for previews and the simulator. `nil`, the default, keeps one inside the view that records from the microphone. - `maximumDuration`: The longest take, in seconds. Over the last ten seconds the time counts down on red, and at the maximum the take is sent, held or locked. Defaults to 5 minutes. - `minimumDuration`: The shortest take that is sent, in seconds. Anything shorter, and any tap on the mic, is discarded with a hint that teaches the gesture. Defaults to 0.6 seconds. - `messages`: The piece's copy: the hints, "Slide to cancel" and the VoiceOver labels. Defaults are localizable through your String Catalog; replace any line. - `style`: Colours, the control's height and the bars. Defaults to the SwiftPieces house palette, adapting to light and dark, with red for the recording mic, the send button and the countdown. - `onSend`: Called with each finished take. The file is yours: upload it, move it out of the temporary directory, or delete it once sent. - `onCancel`: Called when a take is discarded: slid away, deleted, or stopped by an interruption while held. The file is already deleted. Not called for takes that are too short. - `content`: Your composer, usually a text field. It fills the leading space at rest and hides under the recording capsule while recording. ## Waveform Scrub (`WaveformScrub`) URL: https://swiftpieces.com/docs/components/media/waveform-scrub Category: Media. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: WaveformScrub.swift Install: npx swiftpieces add WaveformScrub A voice note player whose waveform is the scrubber: a red play button joined to a glass capsule of bars resampled from any number of samples to the width it gets, inked crisply mid-bar as your app's playback moves, a touch that drops the playhead under the finger, lifts the bars around it and buds a glass time bubble up out of the capsule that rides the finger and melts home as one seek is sent on release, a speed chip that morphs through 1×, 1.5× and 2×, quiet shimmering dots while the waveform loads, and a helper that reads the peaks of an audio file off the main thread. A voice note player whose waveform is the scrubber, for voice messages, audio notes, journals and podcast clips. One liquid glass control: a red play button joined to a glass capsule that holds the waveform and the time, with an optional speed chip joined to its far end. Played bars are inked in the text colour and the rest stay muted, cut exactly at the playhead, mid-bar. Touch the waveform and the playhead drops under the finger, the bars around it rise, and a glass bubble with the time buds up out of the capsule and rides the finger; let go and it melts home and `onSeek` fires once. A tap seeks too. While a finger is down the `time` you pass is ignored, so playback updates never fight the scrub, and after the seek the playhead holds where you left it until your player catches up. A vertical drag that starts on the waveform still scrolls an enclosing scroll view. Playback is your app's: the piece never runs a clock of its own. Pass the player's `time` and `isPlaying`; between your updates the playhead glides on at the current speed, so a timer that reports a few times a second still draws smooth progress. `WaveformScrub.samples(from:count:)` reads the peaks of an audio file for `samples`, off the main thread. ```swift @State private var player = VoiceNotePlayer(url: note.url) // your AVAudioPlayer wrapper @State private var samples: [Float] = [] WaveformScrub(samples: samples, duration: player.duration, time: player.time, isPlaying: player.isPlaying, rate: $player.rate, onPlayPause: { player.toggle() }, onSeek: { player.seek(to: $0) }) .task { samples = (try? await WaveformScrub.samples(from: note.url)) ?? [] } ``` ### Notes - One liquid glass control: a red play button joined to a glass capsule that holds the waveform and the time, and, when you pass `rate`, a smaller speed chip joined to the capsule's far end. Joined parts rest 4 points apart, so short liquid necks hold them together. Red appears only on the play button. On iOS 26 this is Liquid Glass; before that, a frosted material; under Reduce Transparency, solid fills. - Playback is your app's. Pass your player's `time`, `duration` and `isPlaying`, start or pause it in `onPlayPause`, and move it in `onSeek`. The piece never runs a clock: between your updates the playhead glides on at the current speed for up to a second, so a periodic time observer or a timer that reports a few times a second still draws smooth progress, and a player that stops reporting never runs away. - `samples` are loudness from 0 to 1, any count. They are resampled to the bars that fit the width (3 point bars, 2 points apart by default): the peak of each stretch when there are more samples than bars, smoothly in between when there are fewer. The loudest sample reaches full height and silence is a dot. Played bars are inked in the text colour and the rest stay muted, cut exactly at the playhead, mid-bar. - Read `samples` from an audio file with `WaveformScrub.samples(from:count:)`: it decodes the file in chunks on a background thread (never the main actor), takes the peak of `count` equal stretches (160 by default) and scales them so the loudest is 1. Cancelling the calling task stops the read. Call it from `.task(id: url)` and store the result; it throws what `AVAudioFile` throws for a file it can't open. - Touch the waveform and the playhead drops under the finger at once, the bars around it rise, and a glass bubble with the time buds up out of the capsule above the finger on a liquid neck and rides with it. Let go and the bubble melts home and `onSeek` fires once, with the time in seconds. A tap seeks too. Past either end the playhead stops at the start or the end with a soft tick. While a finger is down the `time` you pass is ignored, and after the seek the playhead holds where you let go until your `time` arrives near it (or 1.5 seconds pass), so it never jumps back while your player catches up. - The bubble rises about 40 points above the control while a finger is down. Leave that room above it, or let it draw over what's there for that moment; at the very top of a scroll view, the scroll view's edge cuts it. - Scrolling is never blocked. Touches come from a UIKit recognizer that begins on a sideways move and fails on a vertical one, and enclosing scroll views, sheets and the back swipe wait for it to fail. Inside a scroll view the press waits about a tenth of a second, so starting a scroll on the waveform never flashes the scrub. - States: empty `samples` (the waveform is loading) shows a quiet row of dots with a soft light passing along them once a second (the dots hold still under Reduce Motion), and seeking still works. A `duration` of 0 (still unknown) turns seeking off. The time inside the capsule reads the total at the start and the elapsed time otherwise, in `0:42` form, or `1:12:30` from an hour; it keeps the width of the longest time, so the waveform never shifts. When the note has played to its end and stopped there, play turns to replay, and tapping it calls `onSeek(0)` and then `onPlayPause()`. Disabled (`.disabled(true)`), the control greys out, red included, and ignores touches. - The speed chip cycles through `rates` (1×, 1.5× and 2× by default) on each tap, writing the `rate` binding with a selection tick, and its label morphs letter by letter. It keeps the width of the widest speed, so the waveform never moves, and its target is 44 points tall. Set your player's rate from the binding (`AVAudioPlayer.enableRate` must be on). Pass no `rate` for a plain voice note without the chip. - Haptics: a light impact on play and pause, a selection tick when a finger grabs the waveform and when the speed changes, a soft tick when a scrub reaches the start or the end. - VoiceOver: the play button reads Play, Pause or Play again. The waveform is one adjustable element labelled "Voice note" with a value such as "12 seconds of 42 seconds"; swipe up or down to move 5 seconds through `onSeek`. The speed chip is a button, "Playback speed", with a value such as "1.5 times" or "Normal". A two-finger double tap plays or pauses from anywhere on the control. - Hardware keyboard: with the waveform focused, the arrow keys step 5 seconds (in reading order) and Space plays or pauses. - Reduce Motion: the bars don't rise, the bubble fades in and out in place instead of budding, the glyph and digit changes cross-fade, and a seek while paused lands on a short spring with no overshoot. - Dynamic Type: the text grows and the shapes follow it part way, up to 1.4 times, so the waveform keeps its room. The time inside the capsule and the speed stop growing at the largest standard size, the bubble at the first accessibility size. All text is one weight, semibold, with monospaced digits. - Right to left: play stays on the leading side (now the right), the speed chip moves to the left, the bars play from the right and the time sits on the left of the capsule. The transport glyphs and the speed label are never mirrored. - `Style` sets the play button's red and the glyph on it, the played and unplayed bars, the labels, the control's height (52 points, at least 44) and the bars' width and spacing. `Messages` replaces the VoiceOver labels, the speed label ("1.5×") and the position value ("12 seconds of 42 seconds"); every default goes through your String Catalog. - Wire it to `AVAudioPlayer`: keep the player in an `@Observable` model, publish `currentTime` from a timer or `CADisplayLink` while playing, call `player.currentTime = seconds` in `onSeek`, set `player.rate` when `rate` changes, and set `isPlaying` false in `audioPlayerDidFinishPlaying`. For `AVPlayer`, read the time from `addPeriodicTimeObserver(forInterval:)` and seek with `seek(to:toleranceBefore: .zero, toleranceAfter: .zero)`. ### Parameters - `samples`: Loudness from 0 to 1, oldest first, any count: they are resampled to the bars that fit, the peak of each stretch when there are more samples than bars, smoothly in between when there are fewer, and scaled so the loudest reaches full height. Empty while the waveform loads: a quiet row of dots shimmers once a second in its place (they hold still under Reduce Motion), and seeking still works. - `duration`: The note's length in seconds. Formats the times and maps the waveform to seconds. Zero (still unknown) draws the waveform but turns seeking off. - `time`: Where your player is, in seconds. Ignored while a finger is on the waveform. After a seek the playhead holds at the seek until `time` arrives near it, or 1.5 seconds pass. - `isPlaying`: Whether your player is playing. Swaps play for pause, and lets the playhead glide between your `time` updates at the current speed. - `rate`: The playback speed. Shows a speed chip joined to the end of the capsule that cycles through `rates` on each tap; set your player's rate from it. `nil`, the default, hides the chip. - `rates`: The speeds the chip cycles through, in order. Defaults to 1×, 1.5× and 2×. - `messages`: The piece's copy: VoiceOver labels and the speed label. Defaults are localizable through your String Catalog; replace any line, for example `title` with "Message from Ines". - `style`: Colours, the control's height and the bars' width and spacing. Defaults to the SwiftPieces house palette, adapting to light and dark, with red for the play button. - `onPlayPause`: Called when play or pause is tapped (and on Space or a two-finger double tap in VoiceOver). Start or pause your player. When the note has finished (`time` at `duration`, not playing), the button shows replay and `onSeek(0)` is called first. - `onSeek`: Called once when a scrub or tap ends, for each VoiceOver or arrow key step of 5 seconds, and with 0 before a replay, with the time in seconds to move your player to. ## Drag to Dismiss (`DragToDismiss`) URL: https://swiftpieces.com/docs/components/motion/drag-to-dismiss Category: Motion. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: DragToDismiss.swift Install: npx swiftpieces add DragToDismiss A modifier that lets a photo, card, or sheet follow a two-axis drag, rounds and shrinks it toward the finger with progress over a styled scrim, then springs back or is tossed off along the drag vector at the speed of the flick past a distance or velocity threshold. A liquid glass close bubble drops in at the top as the drag gets going, and a "Release to close" pill buds out of it on a liquid neck the moment the drag passes the threshold, melting back in if it falls short. Photos-style dismiss: drag in any direction, the view rounds and shrinks toward the finger as it travels, the scrim thins, and a far enough drag or a fast flick tosses it off screen along the direction it was moving, at the finger's speed. Under Reduce Motion it fades where it is instead. The hint is liquid glass and stays put at the top of the layout frame while the view travels: a round close bubble drops in once the drag is under way, and the moment the drag arms, a pill with `style.hint` buds out of it, born inside the bubble and widening as it springs out to rest joined to it by a liquid neck. Fall back under the threshold and the pill melts home; let go and the pill melts home and the bubble lifts away. ### Notes - Apply it to the full-size root of a sheet or full-screen cover, or to a card wrapped in `.frame(maxWidth: .infinity, maxHeight: .infinity)`, so the scrim covers the screen while only the content moves. - The view tracks the finger 1:1 in both axes and shrinks toward the point you grabbed, so that point stays under the finger. Past the threshold it keeps shrinking a little against rubber-band resistance instead of stopping dead, and the moment it arms it gives another 2% on the snap spring with the rigid tick, so the threshold shows as well as it feels. - Commit happens past `threshold` points of travel or when `predictedEndTranslation` projects past twice that. The fly-off follows the release direction and travels far enough to clear the container, each axis leaving at the finger's speed on a spring with no bounce, so a flick is thrown at its own pace and a slow release still leaves unhurried. The view stays solid for most of the flight and fades over its last stretch. `onDismiss` fires once, when the fly-off is logically complete. - Let go short of the threshold and it springs home at the speed it was moving, on a spring between the snap and the elastic settle (about 4.6% overshoot). Let go at the threshold, it passes home by about 5 points before it settles, partly covered as the view springs back to full size; the elastic swing would show a full-screen view's edge. Grab it again on the way home and it picks up under the finger without a jump, and the shrink moves its pivot to the new grab point within about a tenth of a second. A touch the system cancels (a call, an alert) brings it home too and re-enables the inner scroll view. - Scroll cooperation: a scroll view inside the content keeps priority until it is at its top. On iOS 18 the modifier watches the scroll view with `onScrollGeometryChange`; on iOS 17 feed it `scrollOffset` yourself. Upward drags at the top go to the scroll view, and the scroll view is disabled for the duration of a captured drag so it cannot bounce underneath. - Bind `progress` and scale your presenter from 0.94 toward 1 with it for sheet-like depth. It stays linear and reaches 1 at the threshold. - The hint is liquid glass and stays put at the top of the layout frame, like the scrim, while the view travels. Once the drag has gone a third of the way to the threshold, a round close bubble drops in on its own. The moment the drag arms, with the tick, a "Release to close" pill buds out of it: born inside the bubble as a small round bubble, it springs out to rest beside it on a liquid neck, widening to fit its words as it goes, and the pair stays centred. Fall back under the threshold and the pill narrows and melts home on a spring with no bounce; let go, and the pill melts home first and then the bubble lifts away. It never takes a touch. - Phases run idle, dragging, armed (a rigid tick at the threshold), returning, committing. Changes to idle, dragging, armed and returning arrive on the snap spring, so anything you bind to `phase:` swaps smoothly; committing rides the toss (the fade, under Reduce Motion). VoiceOver's escape gesture commits a downward dismiss. - Reduce Motion keeps the scale at 1, so there is no shrink and no arming give; instead the view dims slightly the moment it arms, on the same beat as the tick, a hint of the fade a release brings. It returns home on a short spring with no overshoot, and commits with a quick fade where the view is instead of flying it across the screen. The scrim, the rounding and the haptic stay, and the hint's bubble and pill fade in and out where they rest. Before iOS 26 the hint draws on a frosted material with a light rim and the pill sits beside the bubble without a neck; under Reduce Transparency it is solid. - `style:` sets the scrim (`#141414` at 0.5 by default, `.clear` for none), the shrink at full progress and the hint's words (`hint`, "Release to close" by default). Set `hint: nil` to hide the glass hint when the presenter draws its own from `phase:`. - The `#Preview` is the single card the modifier moves: a butter boarding pass, with the modifier's own scrim behind it and its glass hint at the top. - For a full gallery built around this gesture, see the Pro Gallery screen. ### Parameters - `threshold`: Drag distance in points that arms the dismiss. A flick whose projected travel passes twice this commits from a shorter drag. - `cornerRadius`: Corner radius the view reaches at full progress; it starts square so full-screen content keeps its edges at rest. - `progress`: Optional binding that receives the dismiss progress (0 at rest, 1 armed) so the presenter can scale its background from 0.94 back to 1. - `scrollOffset`: Vertical offset of a scroll view inside the content. The drag is only captured at 0. On iOS 18 the modifier observes the inner scroll view itself. - `phase`: Optional binding that receives the current phase (idle, dragging, armed, returning, committing), to dim chrome while dragging or drive a hint of your own (set `style.hint` to `nil` then). - `style`: Scrim color and opacity at rest, how much the view shrinks at full progress, and the hint's words. `.standard` is a near-black `#141414` scrim at 0.5 with a 0.15 shrink and a "Release to close" hint. - `onDismiss`: Called once the view has flown off (faded, under Reduce Motion); end the presentation here. ## Floating Dock (`FloatingDock`) URL: https://swiftpieces.com/docs/components/navigation/floating-dock Category: Navigation. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: FloatingDock.swift Install: npx swiftpieces add FloatingDock A floating liquid glass dock: every item is its own glass bubble, resting apart, and the selected one floods with the signal tint. A drag across the dock lifts each item under the finger like a key while one glass name bubble buds out above it on a liquid neck, glides with the finger and morphs its name, then melts back in as the release commits; badges are signal bubbles joined to their items, and the whole dock tucks away on scroll. Floating bottom dock of 4–5 items, each its own liquid glass bubble, with a tinted selection and a scrub-to-pick gesture. The items rest apart, as separate bubbles. A finger scrubbing across the dock buds a name bubble out of the item under it, joined to that item by a liquid neck, and melts it back in on release. Badges are small signal bubbles joined to their items. ### Notes - Every item is its own liquid glass bubble, 48 pt across, resting apart from the next so each reads as a separate action. The selected item floods with the signal tint (or `tint`) and its symbol fills in house ink; the others are clear glass with muted symbols. Before iOS 26 the bubbles are frosted material with a light rim, and under Reduce Transparency they are solid. - Selection moves the same way whether a tap, a scrub, VoiceOver or your binding changed it: the old bubble drains as the new one floods and each symbol swaps with a blur, on a quick spring that lands with a small give. The symbol bounces once as the tint lands under it, not as it leaves the old item. - Touch and drag across the dock to pick. The finger belongs to the nearest item, so the gaps between bubbles never drop a scrub. The item under the finger rises like a key (up 5 pt, to 114%) with a little give and drops back firm when the finger moves on, so neighbours never wobble. One glass name bubble buds out of the first item and rests above it on a liquid neck; it glides from item to item with the finger while its name morphs letter by letter, and on release it narrows to a drop and melts back into the item, which the release selects. A `.selection` tick fires per item; release commits with a soft impact. - A touch that doesn't travel presses the item: it sinks about 2.5 pt per edge (92% for a bubble) and selects it on release. A touch the system cancels lets go without selecting. - Badges are small signal bubbles joined to their items by a liquid neck, up the trailing side, with ink counts that roll with `.numericText` on a spring with no overshoot, since a count is read. A badge buds out of its item when a count arrives and melts back in when it clears. It also melts in while the name bubble is over its item, and buds back out once the name has gone. On the selected item it drains to clear glass so it reads apart from the selection. Counts over 99 show "99+". - `Style` sets `indicator` (the selection's tint), `selectedInk`, `inactive` and `badge`. `surface` and `usesGlass` no longer do anything, since the dock is always liquid glass; they stay so existing code compiles. - `scrollProgress` slides the dock 120 pt down and fades it. Drive it from your scroll direction (0 while scrolling up, 1 while scrolling down): the dock tucks away quickly and firmly and comes back a little slower, landing with a slight give. Values between 0 and 1 move it directly with no animation, so a progress derived from the scroll offset tracks the scroll without lag. Above 0.5 it is also hidden from VoiceOver. - VoiceOver sees a tab bar container whose items are buttons with selected state and badge value; double-tap selects. - Reduce Motion keeps the selection moving on a short spring with no overshoot and removes the lift, the scale and the symbol bounce. The name bubble and the badges fade in and out where they rest instead of budding, and the name bubble steps from item to item instead of gliding. A pressed item dims instead of sinking, counts cross-fade, and the tuck slides on short eases. Haptics are unchanged. - A floating tab bar with a lens that tracks and stretches toward the finger ships in SwiftPieces Pro (`lens-tab-bar`). ### Parameters - `items`: Dock items in order. Four or five work best. - `selection`: Index of the selected item. - `tint`: Glass tint of the selected item. Defaults to the style's indicator, the signal red. - `scrollProgress`: 0 keeps the dock in place, 1 tucks it below the screen. Feed it a value derived from scroll direction to hide the dock while scrolling down. - `style`: Tints and ink. Defaults to `.standard`, the house palette. ## Index Scrubber (`IndexScrubber`) URL: https://swiftpieces.com/docs/components/navigation/index-scrubber Category: Navigation. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: IndexScrubber.swift Install: npx swiftpieces add IndexScrubber An A to Z section index for long lists in any scroll view: the letters sit on a liquid glass rail on the trailing edge. Touch or drag it to jump to the section under your finger with a selection tick per new section, while a signal glass bead rides the rail under the active letter and a large letter bubble buds out of the rail beside it on a liquid neck, follows the finger, rolls its letter in the direction of travel and melts back in on release. Letters without a section are dimmed or hidden, a dotted collapse covers rails too short for every title, and there is one adjustable VoiceOver element and type-to-jump on a hardware keyboard. A section index rail for long sectioned lists (contacts, glossaries, countries, recipes) that works with `ScrollView`, `LazyVStack` and `List` on iOS 17 and later. Touch down or drag along the rail to jump to the section under the finger. Most apps use the `.indexScrubber(_:proxy:)` modifier on a scroll view inside a `ScrollViewReader`; use the view itself when you scroll some other way, and place it on the trailing edge yourself. ### Notes - `.indexScrubber(titles, proxy: proxy)` on a `ScrollView` or `List` inside a `ScrollViewReader` is the whole setup. Give each section an id equal to its title (`ForEach(sections, id: \.title) { Section { … } }`). Jumps scroll without animation, the section's top at `anchor` (`.top` by default), the way the system index does. - The modifier insets the scroll view's content by the rail's width (44pt at the default text size), so rows lay out beside the rail instead of under it. With no reachable sections the rail takes no space and no touches. - Use `IndexScrubber(titles, selection: $current) { title in … }` when you scroll some other way; place it on the trailing edge yourself. `onSelect` fires once on touch down, then only when the finger reaches a new section, plus on VoiceOver and keyboard moves. - `index: .sections` shows your titles as they are. `.alphabet` shows the current locale's index letters (A to Z and # in English, from `UILocalizedIndexedCollation`) and matches each section by its whole title, then its first character, then its first character without accents; titles starting with a digit or symbol fall under #. `.custom([...])` takes your own entries. - Entries without a section are dimmed by default and a touch on one lands on the nearest section after it (or before it at the end). `missing: .hidden` leaves them out. The letter bubble and the haptic only change when the landing section changes. - When the rail is too short for every entry (landscape, accessibility text sizes), it shows evenly spaced entries with a dot between each, like the system index. The finger still maps proportionally along the whole column, so a dot lands on the letters it stands for and the bubble shows the real one. - While a finger is down the rail keeps its touch-down geometry, pinned on screen, so a jump that resizes the scroll view (a large title collapsing) never moves letters under the finger. On release it moves into its new place at once, on a short spring with no overshoot that is most of the way there in about a tenth of a second. - The letters sit on a clear liquid glass rail, always there, like the chrome of a tab bar. Touch down swells the rail a little either side of the letters, a signal glass bead buds out of the rail under the entry, and the letter bubble buds out of the rail beside it a beat behind: born as a drop inside the rail, it springs inward and opens to its full size, held to the rail by a liquid neck. While scrubbing the bead glides from entry to entry on a snap with a small give, the letter under it turning ink as it arrives, and the bubble rides a beat behind the bead, not the finger, its neck sliding along the rail. Its letter rolls in the direction of travel, blurring as it goes, without overshoot, like a counter. Scrubbing faster than about seven sections a second, or turning back, blurs the letter from old to new in place instead, so the bubble never smears. - Dragging on past either end of the column (or further out than where the finger came down, when that was in the empty rail beyond the letters), the bubble gives a little with rubber-band resistance, never as far as the next row, stretching its neck. Only the drawing goes past: the section stays the first or last, so nothing scrolls or ticks. On release the bubble narrows to a drop and melts back into the rail at its letter, its tint draining as it goes, then the bead melts in after it and the rail settles; a bubble pulled past an end draws back to its letter on the way. - A `.selection` haptic plays on touch down and on each new section, never on dimmed entries, repeated positions or the pull past an end. - The drag lives on the rail itself, outside the scrolling content, so it never competes with the scroll view's pan; the scroll view keeps scrolling everywhere else. A system-cancelled touch resets the rail cleanly. - VoiceOver: the rail is one adjustable element, "Section index", whose value is the current section. Swipe up or down to step through every section (including any the rail's entries do not reach); each step scrolls. Write `selection` from your own scroll tracking so it starts from the section on screen. - Hardware keyboard: when the rail has focus, typing a letter jumps to it, arrow keys step between sections, and Home and End go to the first and last. The rail swells while focused, and each key buds the bead and the bubble out briefly before they melt home. - Dynamic Type: letters, rows and the bubble scale up to the first accessibility size, then the rail collapses entries instead of growing; the bubble shows the section at display size while scrubbing. Over a dot of a collapsed rail the bead shrinks to stand for the dot. - Right-to-left: the rail sits on the leading edge and the bubble buds out to its inner side. - Reduce Motion: the bead and the bubble fade in and out where they rest instead of budding, the rail does not swell, the letter cross-fades instead of rolling, the bead and the bubble move between entries on a short spring with no overshoot, and there is no pull past the ends. The haptic and colors are unchanged. Before iOS 26 the rail, bead and bubble are frosted material with a light rim, and under Reduce Transparency they are solid. - `Style` sets the `label`, `dimmed`, `bubble` (the bubble's and the bead's glass tint) and `ink` colors. `track` no longer does anything, since the rail is clear glass; it stays so existing code compiles. Every letter is semibold. `.disabled(true)` dims the rail and ignores touches. ### Parameters - `titles`: Your section titles, in list order. They are also the values passed to `onSelect`, so use the same strings as the ids you scroll to. Duplicates and empty strings are ignored. - `selection`: The section last jumped to. Written on every jump; write it yourself (for example from your own scroll tracking) so VoiceOver starts adjusting from the section on screen. - `index`: What the rail lists. `.sections` shows exactly `titles`; `.alphabet` shows the current locale's index letters (A to Z and # in English) and matches each section to a letter by its first character; `.custom` takes your own entries. - `missing`: What happens to index entries that have no section: `.dimmed` keeps them in place in a quieter color (the finger lands on the nearest section after them), `.hidden` leaves them out. - `style`: Colors for the letters, the letter bubble and its ink. Defaults to the SwiftPieces house palette, adapting to light and dark. - `onSelect`: Called with the section title to jump to: once on touch down, then each time the finger reaches a new section, and on VoiceOver or keyboard moves. Scroll without animation here, the way the system index does. ## Scroll Island (`ScrollIsland`) URL: https://swiftpieces.com/docs/components/navigation/scroll-island Category: Navigation. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: ScrollIsland.swift Install: npx swiftpieces add ScrollIsland A floating liquid glass island for long screens such as articles, recipes, docs, settings and product pages: it names the section in view and rings it in red with how much of the page has been read, morphs the title letter by letter as the page scrolls, buds a joined back-to-top bubble once the reader leaves the first section, scrubs sections under a sideways drag with a tick for each, and on a tap or a swipe up grows the same glass into a contents panel where a red puck stretches from row to row under the finger and the pick scrolls the page straight there. A floating section island for long scrolling screens: which part am I in, how much is left, and a way to jump. The island names the section in view, rings it with how much of the page has been read, and opens in place into the screen's contents. It reads and writes the same selection your scroll view's `scrollPosition(id:)` does, so scrolling the page moves the island and picking a section scrolls the page. Put it over the scroll view with `.overlay(alignment: .bottom)`: it keeps above the home indicator and the keyboard, and only takes touches on itself, so the page behind it keeps scrolling. ```swift @State private var section: Recipe.Part.ID? @State private var read: Double? ScrollView { LazyVStack { ForEach(recipe.parts) { RecipePart($0).id($0.id) } } .scrollTargetLayout() } .scrollPosition(id: $section, anchor: .top) .overlay(alignment: .bottom) { ScrollIsland(sections: recipe.parts.map { .init(id: $0.id, title: $0.title, detail: $0.readingTime) }, selection: $section, progress: read) } ``` How much has been read, from the scroll offset (iOS 18; leave `read` nil before that and the island estimates it): ```swift .onScrollGeometryChange(for: Double.self) { geometry in let travel = geometry.contentSize.height + geometry.contentInsets.top + geometry.contentInsets.bottom - geometry.containerSize.height return travel > 0 ? (geometry.contentOffset.y + geometry.contentInsets.top) / travel : 1 } action: { _, fraction in read = min(max(fraction, 0), 1) } ``` ### Notes - Put it over a long `ScrollView` with `.overlay(alignment: .bottom)`, and give the island the same binding as the scroll view's `scrollPosition(id:)`: each section's view carries `.id(section.id)` inside a `.scrollTargetLayout()`. Scrolling the page updates the island; a pick, a scrub, an arrow key or the back-to-top bubble sets the binding, and the scroll view takes the page there. A `nil` selection reads as the first section. - It sits 8 points above the bottom safe area and 16 from each side, rides above the keyboard, and only takes touches on its own glass, so the page behind it keeps scrolling everywhere else. - Closed, it is one glass capsule hugging its bar: a 26 point ring whose red arc is the part of the page read, the section's title (it morphs letter by letter as the page moves on, and truncates with an ellipsis when it can't fit), a quiet "3 of 6" and a chevron. One section shows its title alone; an empty list draws nothing. - Pass `progress` from the scroll offset for a true reading (the doc comment and the example show the iOS 18 `onScrollGeometryChange` that computes it). `nil` estimates it from the section's place. The arc tracks a scroll as it goes and sweeps without overshoot after a jump. - Once the reader is past the first section a round back-to-top bubble buds out of the island's leading end and rests joined to it on a short liquid neck. Tapping it selects the first section; it melts back into the island when the page is back at the top, and while the contents are open. `showsTopButton: false` leaves it out. - Tap the island, or swipe up on it, and the same glass grows upward in place into the contents (up to `style.maxWidth`, 360 by default, and about 60% of the space it is given). The bar stays where it was, now reading the contents title and how much has been read; the rows arrive once the glass has grown and leave before it shrinks. Longer lists scroll inside the panel, opened on the current section. - In the contents the current section sits on a red puck with its title in dark ink. Press a row, or slide over the rows (a swipe up that carries on onto a row does the same, like a menu), and the puck follows: its leading edge first and the far edge a beat behind, so it stretches toward each row and gathers on it, with a selection tick per row. Let go and the page jumps there as the panel melts back into the pill. Lifting off the rows, or a touch the system takes, picks nothing. When the rows scroll, a tap picks instead. - Drag the closed island sideways to step through sections live, one per 32 points, with a tick for each; the title morphs as you go, the island gives with rubber-band resistance past the first and last, and letting go commits. The axis is decided after 8 points, so a swipe up opens it rather than scrubbing. Right to left mirrors the drag: later sections lie toward the trailing side. - Bind `isExpanded` to open or close the contents from your own controls (a toolbar button, say) or to know when they are open; omit it and the island keeps that state itself. A selection that changes while the contents are open moves the puck. - VoiceOver: closed, the island is one adjustable button, "Contents", with a value such as "Method, Section 3 of 6, 62% read" and the hint "Shows every section"; swipe up or down to step sections, double-tap to open. Open, focus moves into the list onto the current section; each row is a button with the selected trait and its reading time as the value, the bar closes the contents, and Escape (the two-finger scrub) closes them too. The bubble reads "Back to top". - Hardware keyboard: focus the island, then the left and right arrows step sections and Return, Space or the up arrow opens the contents; open, the up and down arrows move the puck, Return or Space picks and Escape closes. - Reduce Motion: the glass grows and shrinks on a short ease, the puck slides on one short spring without stretching, the title cross-fades instead of morphing, the bubble appears and leaves in place, the scrub has no lift or give, and a press shades instead of sinking. Reduce Transparency draws solid surfaces through the shared foundation. - Dynamic Type: the bar, the ring, the rows and the bubble scale. At the accessibility sizes the closed island drops the quiet position (VoiceOver still reads it) so the title keeps its room, and the contents scroll sooner. All text is one weight, semibold. - Haptics: a selection tick for each section a scrub or the puck crosses, an arrow key or the bubble; a light impact when the contents open and close. - Red is the read arc and the puck, nothing else. `Style` sets the accent, the ink on it, the label and secondary label colours, the ring's track and `maxWidth`. `Messages` replaces any of the copy: the contents title (such as "On this page"), the bubble's label, the hints, and the position, spoken position and read amount as closures over formatted numbers. ### Parameters - `sections`: The screen's sections, in page order. Each has the `id` its view carries in the scroll view, a title and an optional short detail shown at the end of its row in the contents, such as a reading time. One section shows its title alone; none draws nothing. - `selection`: The section in view, the same binding as the scroll view's `scrollPosition(id:)`. Scrolling the page updates it; picking a section, scrubbing the island or the back-to-top bubble sets it, which scrolls the page there. `nil` reads as the first section. - `progress`: How much of the page has been read, from 0 to 1, drawn as the red arc of the ring and read out as "62% read". Compute it from the scroll offset (on iOS 18, `onScrollGeometryChange`). `nil` estimates it from the section's place in the list. - `isExpanded`: Bind it to open or close the contents from your own controls, such as a toolbar button, or to know when they are open. Omit it and the island keeps this state itself. - `showsTopButton`: Buds a round back-to-top bubble out of the island's leading end while the reader is past the first section. Defaults to `true`. - `messages`: The island's copy. Defaults are localizable through your String Catalog; replace any line, such as `contents` with "On this page". - `style`: Colors and the widest the island and its contents may grow. Defaults to the SwiftPieces house palette, adapting to light and dark, with red for the read arc and the contents' selection puck. ## Step Trail (`StepTrail`) URL: https://swiftpieces.com/docs/components/navigation/step-trail Category: Navigation. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: StepTrail.swift Install: npx swiftpieces add StepTrail A liquid glass progress trail for checkout, booking, account setup and verification flows: every step a glass stop joined to the next by a short neck, done steps carrying a check, the current step a red capsule with its number and title, steps people already reached in full text and the rest muted; moving on stretches the red from one stop to the next and gathers it there while the old title blurs into a check, a tap goes back to any step already reached, a tap ahead of that shakes the stop and nothing moves, a sideways drag carries the capsule stop by stop with a tick each and gives at the last step it may reach, and when the titles don't fit the capsule keeps its number, the caption names the step and the stops shrink rather than wrap or clip. A step progress indicator for multi-step flows that shows where you are, what is done, and lets people go back without letting them jump ahead of what they have filled in. The stops are liquid glass resting joined, so the whole trail reads as one shape with short necks. Done steps carry a check, the current step is a red capsule with its number and title, steps people have already reached beyond the current one show their number in full text, and steps not reached yet show it muted. Moving to another step stretches the red from one stop to the other, its leading edge first, and gathers it there, while the titles and checks blur over. Tap a done or reached step to go there. A tap on a step that hasn't been reached shakes that stop and nothing moves. Drag sideways along the trail to carry the capsule across the reachable steps, with a tick on each; it gives at the last one it may reach and lands where you let go. Vertical swipes still scroll the screen. When the trail doesn't fit, the capsule drops its title (the caption names the step instead), and then the stops shrink. It never wraps and never clips. The trail hugs its content and starts at the leading edge, keeping the width of its widest step so nothing around it moves. ### Notes - Put it at the top of a checkout, booking, account setup or verification flow and bind `selection` to the step your flow shows: `StepTrail(steps: ["Cart", "Address", "Payment", "Review"], selection: $step)`. Your Continue button moves on with `step += 1`; the trail never changes steps on its own except when a person taps or drags it. - To let people return forward after going back, keep the furthest step they have filled in and pass it as `reached`: `.onChange(of: step) { _, value in furthest = max(furthest, value) }`. Without it, only earlier steps can be revisited. Lower `furthest` yourself when an edit invalidates later steps. - Every stop is liquid glass, resting joined, so the trail reads as one shape with short necks. Done steps carry a check, the current step is a red capsule with its number and title in dark ink, steps already reached beyond the current one show their number in full text and steps not reached yet show it muted. Red marks the current step and nothing else. - Moving to another step closes one capsule and opens the other while the red stretches across: the edge facing the move leads and the far edge lets go a beat later, so the red reaches over the neck and gathers on the new stop. The old title blurs out as its capsule shrinks into a circle and its check blurs in; the new title blurs in as the capsule opens. A move made while another is still landing picks up from where everything is. - Tap a done or reached step to go there, with a selection tick. A tap on a step that hasn't been reached shakes that stop with a firm tick and nothing moves. A tap on the current step does nothing. - Drag sideways along the trail to carry the capsule across the reachable steps. It lifts under the finger, moves to a stop once the finger is a little past the midpoint (so it never flickers between two), ticks on each, and between stops its facing edge reaches toward the finger with rubber-band resistance. At the last step it may reach it only gives, with one firm tick, and let go it lands where it is: the binding changes once, on release. The drag starts only when it runs sideways, so vertical swipes that begin on the trail still scroll the screen or move a sheet. - Set `selection` to `steps.count` when the flow completes: every stop shows a check, the red drains from the last capsule and the trail lands with one small beat. The caption and VoiceOver read "Complete". Tapping a done step from there goes back to it, like any other done step; disable the trail if the flow is final. - When the titles don't fit, the capsule keeps only its number and the caption names the step ("ID photo Step 4 of 6"); when the numbers don't fit either, the stops shrink to fit. It never wraps and never clips. The trail hugs its content, starts at the leading edge and keeps the width of its widest step, so nothing around it moves as the capsule changes. Whether titles show is decided by the longest title, so the form never flips between steps. Titles longer than `Style.maximumTitleWidth` truncate. - An empty `steps` draws nothing; a single step shows its capsule. Values of `selection` outside `0...steps.count` are drawn clamped, and a change to `steps` puts the trail on the selection without motion. - VoiceOver reads the trail as one adjustable element: the label "Progress" and a value such as "Step 2 of 4, Address". Swipe up and down to move within the reached steps, or use the Go to actions for each reachable step. With a hardware keyboard, focus the trail and use the left and right arrow keys (mirrored right to left). - Reduce Motion: the red stays in the stops and crossfades from one to the other with no stretch, the capsules resize on a short spring with no overshoot, the checks, numbers and titles fade, the shake and the lift stay still, and a press shades instead of sinking. - Dynamic Type grows the stops, numbers and titles until they are a little over twice their size, and the caption all the way; when they no longer fit, the title moves to the caption and the stops shrink to fit. Right to left mirrors the trail: it starts at the right, the number sits at the capsule's right end and a drag runs the other way. - Haptics: a selection tick when a tap, a key or a drag lands on a step and for each stop a drag crosses, and a firm tick for a refused tap or a drag pushed against its last step. Changes your app makes stay silent, so the trail never doubles your own feedback. - On iOS 26 the stops are Liquid Glass and merge through their necks; before that, and under Reduce Transparency, they draw as frosted or solid stops on their own. `.disabled(true)` shows the progress dimmed and ignores touches. - `Style` sets the accent (the current capsule) and the ink on it, the label and secondary label colors, the stop height (34 by default, at least 28) and the longest title. `Messages` replaces any line of copy: the VoiceOver label, the caption, the spoken value, the finished state and the Go to action, for example `.init(label: "Checkout progress")`. All of them are localizable through your String Catalog. ### Parameters - `steps`: The step titles, in order, such as `["Cart", "Address", "Payment", "Review"]`. They show in the current step's capsule and are read by VoiceOver. An empty list draws nothing. Titles longer than `Style.maximumTitleWidth` truncate in the capsule. - `selection`: The index of the current step. A tap, a drag or VoiceOver writes it; set it yourself to move on (a Continue button). `steps.count` shows every step done, for the moment a flow completes. Values outside `0...steps.count` are drawn clamped. - `reached`: The furthest step people have filled in, so they can return forward to it after going back. Steps up to it stay tappable and show their number in full text. `nil` (the default) is the selection, so only earlier steps can be revisited. Keep it in your own state, for example `furthest = max(furthest, step)`. - `showsCaption`: Shows a quiet caption under the trail, such as "Step 2 of 4". When the titles don't fit in the trail, the caption names the current step too. - `messages`: The built-in copy: the VoiceOver label ("Progress"), the caption, the spoken value, the finished state and the VoiceOver actions. Defaults are localizable through your String Catalog. - `style`: Colors and size. Defaults to the SwiftPieces house palette (a red current step on neutral glass), adapting to light and dark. ## Stretch Header (`StretchHeader`) URL: https://swiftpieces.com/docs/components/navigation/stretch-header Category: Navigation. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: StretchHeader.swift Install: npx swiftpieces add StretchHeader A hero header for your own ScrollView and NavigationStack: a semibold display title and uppercase eyebrow over a solid block hero that stretches on overscroll, a title that shrinks toward the real navigation bar, a row of liquid glass stat pills that pins under the bar, and a solid bar that fades in. Hero header placed at the top of scroll content, paired with `.stretchHeaderBar(title:progress:style:)` on the scroll view. ### Notes - Two parts: `StretchHeader` goes first inside your `ScrollView` content, and `.stretchHeaderBar(title:progress:style:)` goes on the `ScrollView` inside a `NavigationStack`. Share one `@State` progress between them. The modifier hides the system bar background (and, on iOS 26, the system's soft scroll edge, which would haze the top of the hero), extends the scroll view under the bar, and draws its own solid bar in the style's `surface`, fading in with progress and gaining a soft shadow instead of a hairline. - The title is display type: semibold, tight, 40 pt and scaled with Dynamic Type, with an optional uppercase `eyebrow` above it. Overscroll stretches the hero upward from its bottom edge, which stays joined to the row below, and the title grows with the pull, quickly at first and then easing toward 12%, the way the scroll view's own edge resists. Scrolling moves the hero at 70% speed inside its own slot, so neither its drawing nor its tap area hangs over the content below, while the title lags further and shrinks, sinking behind the hero's lower edge (or the pinned row) with the eyebrow fading as it goes. The inline title rises in over the last 20% of the collapse; each fade is eased, and the solid bar turns opaque gently as the pinned row arrives. Every value is a direct function of the scroll offset with no animation on top, so the header never trails the finger and the scroll view's own deceleration and bounce drive it. States: expanded, stretching, collapsing, collapsed. - `Style`: `.standard` puts ink on a solid block hero with no scrim; `.overImage` uses a white title over a soft scrim for photos. Both set `surface`, the house ground behind the pinned row and the bar. Copy a style and change one property to customize. - The `subtitle` row (stats, avatar, tabs) pins under the bar once the hero has scrolled past it and draws above the content that follows. For stats, `StretchHeaderStats(["5.4 mi", "1,000 ft up", "3.5 hours"])` draws each one on its own liquid glass pill, resting apart from the next, with the row's padding built in; two or three short stats fit a phone. Anything else you pass brings its own padding. - Progress comes from `onScrollGeometryChange` on iOS 18 (fed through the modifier) and from the header's own position in the `.scrollView` coordinate space on iOS 17. `progress` itself stays linear in the offset; the bar eases its own fades from it. One soft haptic marks a deep pull. - Reduce Motion drops the hero lag, the title parallax, the title's growth on a pull and the inline title's rise, but keeps the stretch, the collapse and every fade. The title then rides up with the hero instead of sinking, and the eyebrow, the title and the inline title fade over in turn, so the hand-off to the bar still reads. The hero reads as one header element and is hidden from VoiceOver once collapsed, when the inline title takes over. The stat pills are frosted material before iOS 26 and solid under Reduce Transparency. - A full settings screen built around a header like this ships in SwiftPieces Pro (`settings-screen`). ### Parameters - `title`: Large display title over the hero; the same string fades into the navigation bar as it collapses. - `height`: Resting hero height in points. - `progress`: Written by the header: 0 while expanded, 1 once collapsed behind the bar. Pass the same state to `.stretchHeaderBar`. - `eyebrow`: Optional small uppercase label above the title, such as a place or a category. Defaults to none. - `style`: Title ink, scrim, and surface colors. `.standard` suits solid block heroes; use `.overImage` for photos. Defaults to `.standard`. - `hero`: Hero content, typically a solid color block or a resizable image. It is sized to fill the header. - `subtitle`: Optional row under the hero (stats, avatar, tabs) that pins beneath the bar once the hero has scrolled away. `StretchHeaderStats` draws stats as liquid glass pills. ## Tracking Tabs (`TrackingTabs`) URL: https://swiftpieces.com/docs/components/navigation/tracking-tabs Category: Navigation. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: TrackingTabs.swift Install: npx swiftpieces add TrackingTabs Tabs on a liquid glass track over a paging ScrollView. The indicator is a signal-tinted glass capsule in the same liquid as the track, riding the pages through fractional scroll progress and swelling a little while it travels, with title ink that turns exactly under its edge, optional counts, a pressed state that sinks it with the selected title, and a selection tick each time the page changes. Tab bar on a liquid glass track with a scroll-linked, tinted glass indicator driving horizontally paged content. The track and the indicator are one liquid control: the indicator is a tinted glass capsule resting inside the clear glass track, in the same liquid group, and the titles sit on both. ### Notes - Pages live in a horizontal `ScrollView` with `.scrollTargetBehavior(.paging)` and `containerRelativeFrame`, so each page is exactly one container wide. `selection` is synced with `scrollPosition(id:)` in both directions: a tap, or a change to `selection` from outside, lands the page on a spring with no overshoot, like native paging, so the pager never shows past its last page, and the pager fits the new page's height on that same spring, so both arrive as one move. - The titles sit on a clear liquid glass track, and the indicator is a signal-tinted glass capsule inside it, in the same liquid. It is positioned from fractional page progress (`contentOffset.x / containerSize.width` via `onScrollGeometryChange` on iOS 18, the page row's position in the `.scrollView` space on iOS 17), interpolated between measured title frames. Under a swipe it is a pure function of the scroll, coupled to the pages like a carriage on a rod: it follows them with no lag of its own, carries a flick's deceleration and lands where they land. While it travels it swells a couple of points, staying inside the track, and it settles flat on a title. A tap that jumps several pages keeps it swollen over the titles it passes, so it only settles where it lands. Coming in from a tap or an outside change it has a little give: it carries a couple of points past its title and snaps back while the page lands flat. - Each title is drawn twice, muted and ink, each cut by the indicator's outline, so the text turns ink exactly where the glass covers it and stays muted outside it. Optional `counts` show a monospaced count after each title and roll with `.numericText`. Every title and count is semibold. - States: resting, swiping (indicator between titles, swollen), pressed (the title sinks a couple of points; pressing the selected title sinks the indicator with it), and a `.selection` tick each time `selection` changes, from a tap or a swipe. - `Style` sets `indicator` (its glass tint), `title`, and `selectedTitle`; `.standard` is a signal indicator with house ink on it and muted titles on the clear track. `track` no longer does anything, since the track is clear glass; it stays so existing code compiles. - Titles are buttons with selected traits and their count as the value; 44 pt tall. Under Reduce Motion a tap lands the page and the indicator on a short spring with no overshoot, the indicator keeps its size while it travels, and a pressed title shades instead of sinking (darker in light mode, lighter in dark) while the indicator stays put. The indicator and the ink still follow your swipe, and the tick still plays. Before iOS 26 the track and indicator are frosted material with a light rim, and under Reduce Transparency they are solid. - A floating tab bar with a lens that stretches toward the finger ships in SwiftPieces Pro (`lens-tab-bar`). ### Parameters - `titles`: One title per page, in order. - `selection`: Index of the settled page. Tapping a title and swiping the pages both update it. - `counts`: Optional count per page shown after its title, such as open tasks. Pass an empty array to hide counts. Defaults to none. - `style`: Indicator and title colors. Defaults to `.standard`, the house palette with a signal indicator. - `page`: Builds the page for a given index. Pages fill the container width. ## Confirm Sheet (`ConfirmSheet`) URL: https://swiftpieces.com/docs/components/sheets/confirm-sheet Category: Sheets. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: ConfirmSheet.swift Install: npx swiftpieces add ConfirmSheet A confirmation card that presents as a height-fitted bottom sheet or, inline, as a floating card you can drag away. Its confirm and cancel actions are liquid glass capsules that split out of one shape as the card lands, the icon tile bounces, and a destructive choice plays a warning haptic. Confirming melts cancel back into confirm while its label morphs through Working to Done and its glass turns sage, then the card dismisses. Presents a confirmation card as a bottom sheet, or inline as an overlay in the current hierarchy. ### Notes - Sheet mode measures the card and uses `.presentationDetents([.height(...)])`, so the sheet is exactly as tall as the content at the current Dynamic Type size, with `Style.surface` as its background. The system sheet keeps its own presentation and drag physics. - Inline mode floats the card at the bottom of the view it is applied to, over a dimmed backdrop. Apply it near the root so the backdrop covers the screen. The solid card slides all the way in from below the screen on a calm spring that lands with a slight give, and leaves faster and firmer than it came; the backdrop fades with it. - Drag the inline card down and it stays under your finger while the backdrop brightens; pull it up and it gives with rubber-band resistance. A rigid impact ticks once the moment a flick would commit. Let go past the line and the card leaves at the speed of your flick; short of it, it settles home at the speed you let go, with a small give from below and a livelier one from above. Grab it again while it is settling and it stops right where it is, without a jump. A drag the system interrupts, such as an incoming call, brings the card home too. - The card stays solid; its two actions are liquid glass capsules that rest apart, in one glass group. The card presents with a single primary capsule, and as it lands the cancel capsule buds down out of it, pulling a liquid neck that thins and snaps, and settles below. Cancel keeps its place in the layout while it is inside the primary, so the card and a sheet's detent never change height. - `confirm` is async. Tapping the primary melts cancel back up into it, and the primary disables while a spinner sharpens in and its label morphs letter by letter to Working. When `confirm` returns the spinner gives way to a check, the label morphs to Done and the glass turns sage, with a `.success` haptic. The tile follows a beat later into a sage check with a small pop, and the card leaves about half a second after that, still showing its outcome. Working and Done go through `String(localized:)`. - Destructive cards use a signal tile and signal-tinted glass for the primary, and play `.warning` as the card lands, or on the tap if you confirm before it does. Other cards use a sky tile and glass tinted with the label color. The tile symbol bounces as the card lands on every presentation. Both capsules sink slightly while pressed. All text is semibold. - `Style` sets every color and the card radius (34pt by default). `quiet` colors only the grabber now; the cancel capsule is neutral glass. - On iOS 26 the capsules are Liquid Glass and melt into each other through the neck; before that they are frosted glass with a light rim, and with Reduce Transparency they are solid. - Under Reduce Motion the card and backdrop crossfade instead of sliding, a drag that falls short settles without overshoot, cancel fades in and out in place instead of budding, the labels crossfade instead of morphing, the capsules dim instead of sinking while pressed, the tile neither bounces nor pops (its color and symbol still change, by crossfade), and the success beat is shorter. The haptics are unchanged. - At accessibility text sizes the labels are plain text that can wrap, and change with a blur instead of the letter morph. - For a floating booking card with detents, see the Stay screen (glass sheet) in SwiftPieces Pro. ### Parameters - `isPresented`: Shows the card while `true`. Confirming, cancelling, and dragging away set it back to `false`. - `inline`: `true` overlays the card in place with a dimmed backdrop and a rubber-banded drag to dismiss; `false` uses `.sheet` sized to the card. - `systemImage`: SF Symbol in the icon tile. - `title`: Headline. - `message`: Supporting copy under the title. - `confirmTitle`: Primary button title. - `cancelTitle`: Secondary button title, on a neutral glass capsule that buds out of the primary as the card lands. - `isDestructive`: Uses the destructive tile and the signal-tinted primary button, and plays a `.warning` haptic on present. - `style`: Colors and corner radius. Defaults to the SwiftPieces house palette, adapting to light and dark. - `confirm`: Async handler for the primary button. Cancel melts back into the button, which spins and reads Working until it returns, then turns sage with a check and Done that the tile answers a beat later, then the card dismisses. ## Notice Stack (`NoticeStack`) URL: https://swiftpieces.com/docs/components/sheets/notice-stack Category: Sheets. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: NoticeStack.swift Install: npx swiftpieces add NoticeStack In-app notices grouped the way notification stacks work: the newest on top as a glass card with its tinted symbol tile, title, relative time and message, two narrower peeks of the rest melted into it as one liquid shape, new notices dropping in from above with a soft tick, a tap that spreads the pile into a list while Show less and a clear button bud out of the top card, cards that follow a sideways swipe and fly off at your speed while the next one rises out of its peek, and a clear button that stretches into a red Clear all capsule so clearing takes a second, confirming tap. A pile of in-app notices (alerts, reminders, delivery updates, system messages), newest first. Collapsed, the newest notice shows in full on a glass card, with up to two narrower peeks of the rest beneath it, melted into one liquid shape. Tap the pile and it spreads into a list while Show less and a clear button bud out of the top card. Swipe a card sideways (the top one while collapsed, any of them in the list): it follows the finger, and past 40% of the width, or with a flick, it flies off and leaves `notices`, while the rest close up. The clear button stretches into a red Clear all capsule; a second tap sends every card off. Insert a notice at the front of `notices` and it drops in from above. ```swift @State private var notices: [NoticeStack.Notice] = [] @State private var expanded = false NoticeStack(notices: $notices, expanded: $expanded) { notice in open(notice) } ``` ### Notes - `notices` is the stack, newest first, and the source of truth: a swipe or Clear all removes from it, and you add, update and remove notices the same way. Each `Notice` has a unique `id`, a title, an optional message, an SF Symbol on a solid tile (`tint: .sky`, `.butter`, `.sage`, `.lilac`, `.sand`, `.red` for the one that can't wait, `.neutral` for system messages, or `.init(color, ink:)`), and an optional `date`. - Collapsed, the newest notice shows in full and up to two peeks of the rest sit under it, each 10 points narrower per side and 10 points lower, cut to the front card's height, so the glass of the pile is one liquid shape. More notices still show two peeks. The peeks' words stay hidden so they never print over the front card. - Insert a notice at index 0 and it drops in from above as a drop of glass, the pile settling under it, with a soft tick and a VoiceOver announcement. Remove one yourself and it melts into the pile while the rest close up. Empty draws nothing, so your screen decides what to show instead. - Tap the pile and it spreads into a list, 12 points apart (`Style.spacing`), while Show less and a clear button bud out of the top card on liquid necks. With a single notice, a tap calls `onSelect` instead. In the list, a tap calls `onSelect` with that notice; leave `onSelect` out and cards only expand and collapse. - Swipe a card sideways (the front card while collapsed, any card in the list): it follows the finger, and the next card's words show in the columns it uncovers. Past 40% of the width, or with a flick that would carry it there, it flies off at the finger's speed on a spring with no bounce, leaves `notices` while still on its way out, and the rest close up; collapsed, the next card rises out of its peek. Short of that it springs back from where you let go. - The clear button stretches into a red Clear all capsule, its words arriving letter by letter. Tap it again and the cards leave one after another from the bottom while the header melts into the top card, which goes last. Tap anywhere else in the stack, or wait three seconds, and it folds back into the clear button. - Times read "now", "4m", "2h", "3d", then a date after a week, through `Duration` and date formatters, and stay current every minute. VoiceOver reads them as "4 minutes ago". - Swipes come from a UIKit pan that starts only for a mostly horizontal drag, so the stack sits in a `ScrollView`, a `List` or a sheet without stealing vertical scrolls or the sheet's pull. When the list can grow long, put the stack in a scroll view. - VoiceOver: the collapsed pile is one button ("3 notices. Gate changed. Boarding is now B12, ten minutes earlier.", hint "Shows all") with Dismiss and Clear all actions. In the list every card is its own element with `onSelect` as its activation and a Dismiss action, Show less and Clear all are buttons, focus moves to the first card when the list opens, and Escape (the two finger scrub) closes it. Arrivals, dismissals and Clear all are announced. - Reduce Motion: cards no longer fly or drop in. A dismissed card slides a short way while its glass closes in place, an arrival opens in place, the list opens and closes on short eases without overshoot, and the header's bubbles fade and close where they are. Letters crossfade. - Dynamic Type: cards grow, titles wrap to three lines and messages to eight, and at accessibility sizes the tile moves above the words so they keep the width. The tile, its symbol and the header scale with the text, and the header keeps 44 point targets. All text is one weight, semibold. Right to left mirrors the cards and the header, and swipes follow the finger. - Haptics: a soft tick when a notice arrives, a light tap when the list opens or closes, a firm tick when a swipe commits, a selection tick when the clear button arms, and success when Clear all runs. - The cards and header are liquid glass in one group, spaced below the list's gap, so separate cards never neck at rest and only melt while they spread, gather or leave. On iOS 26 this is Liquid Glass; before that a frosted material; under Reduce Transparency solid fills. Red appears only on Clear all, and on a tile whose tint is `.red`. - `Style` sets the title and secondary colors, the Clear all red and its ink, the cards' corner radius and the list's gap (at least 10 points). `Messages` replaces any copy, such as `showLess: "Collapse"`, and its `count`, `arrived` and `dismissed` closures shape the VoiceOver strings. ### Parameters - `notices`: The notices, newest first, each with a unique `id`. Swipes and Clear all remove from it; insert at index 0 to show a new one, which drops in with a soft tick. Remove one yourself and it melts into the pile. Empty draws nothing, so your screen decides what to show instead. - `expanded`: Whether the pile is spread into a list. Tapping the pile, Show less and Escape set it, and it returns to `false` when the last notice goes. - `style`: Colors, the cards' corner radius and the gap between cards in the list. Defaults to the SwiftPieces house palette, adapting to light and dark, with red for Clear all. - `messages`: The built-in copy, such as "Show less", "Clear all" and the VoiceOver announcements. Defaults are localizable through your String Catalog; replace any line. - `onSelect`: Called with the notice tapped in the list, or the only notice when there is one. While the pile holds several, a tap spreads it instead. `nil`, the default, leaves cards to expand and collapse only. ## Permission Sheet (`PermissionSheet`) URL: https://swiftpieces.com/docs/components/sheets/permission-sheet Category: Sheets. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: PermissionSheet.swift Install: npx swiftpieces add PermissionSheet A pre-permission sheet that previews what it brings as a fanned stack of notification banners, each a tinted app tile, a line and now, that drop in one after another and deal the next to the front while it waits. A centred title and message sit under it, the request is a signal glass capsule whose label morphs through Requesting to Allowed as the stack pops and Not now melts into it, and a denial greys the stack out, swaps the copy and routes the button to Settings. Without benefits it leads with an icon tile. Explains a permission, runs the real request, and reacts to the result. ### Notes - The sheet previews what the permission brings. Your `benefits` become a fanned stack of notification banners above the title: each is a solid card with its symbol on a tinted app tile (butter, sage and lilac in order by default), its line, and "now". The two behind the front banner sit a little smaller, lower and a shade darker, so their edges read as separate cards. Keep each line short, like a notification ("Design review in 30 minutes"). Without benefits the sheet leads with the icon tile instead. - Present it in a `.sheet` with `.presentationBackground(PermissionSheet.Style.standard.surface)`, sized to its content (the example measures it for a `.height` detent); it also works inline in any container. The title and message are centred under the stack. About 0.3 s in, once a sheet has settled, the banners drop in from above, the front one first and the rest 90ms apart behind it. - While the sheet waits for an answer, the stack deals its next banner to the front every 2.6 s on the settle spring, so each benefit gets read. It stops once the request is tapped, and never runs under Reduce Motion. VoiceOver reads the whole stack as one element listing every benefit. - `request` is your real permission call (for example `UNUserNotificationCenter.requestAuthorization`). The request is a signal glass capsule that sinks under the finger; a tap brings in a spinner and morphs its label letter by letter to Requesting until the call returns. Nothing waits on an animation: the button's action and enabled state change the moment the answer arrives. - `true` pops the stack and plays `.success`, morphs the button to Allowed with a check on sage glass while the Not now capsule melts up into it (its place keeps its size, so the button never shifts), then calls `onGranted` about a second later so you can dismiss. Without benefits the tile turns sage with a check instead. - A denial stays calm, with no shake: the stack greys out to half strength, which reads as notifications off, and `.warning` plays. The title and message lift away through a blur and the denied copy rises into their place, and the button morphs to Open Settings on glass the label's color and opens `UIApplication.openSettingsURLString`. Without benefits the tile turns sand with a gear. - Requesting, Allowed, Open Settings and "now" go through `String(localized:)`. - `Style` sets the surface, labels, the icon tile's colors, the banners' tile tints (`benefitTiles`, or `[.clear]` for neutral tiles), the action color and the tile radius. - On iOS 26 the request and Not now are Liquid Glass and melt into each other through the neck; before that they are frosted glass with a light rim, and with Reduce Transparency they are solid. - Under Reduce Motion nothing drops, blurs, bounces or pops: the banners fade in already stacked and are never dealt, the labels and the two copies crossfade in place, Not now fades where it rests, and the capsules dim while pressed instead of sinking. Haptics are unchanged. - At accessibility text sizes the button's label is plain text that can wrap, and changes with a blur instead of the letter morph. - **v3:** redesigned around a preview of the notifications themselves. The benefit rows became the banner stack, and the copy is centred under it. The public API is unchanged. - For a full multi-step onboarding flow, see the Onboarding screen in SwiftPieces Pro. ### Parameters - `systemImage`: SF Symbol shown in the icon tile. - `title`: Headline. - `message`: One or two sentences of context. - `benefits`: What the permission brings, previewed as a stack of notification banners above the title, each its symbol on a tinted tile and its line. Keep each line short, like a notification. Without benefits the sheet leads with the icon tile. - `allowTitle`: Primary button title. - `deniedTitle`: Headline shown after the request returns `false`. - `deniedMessage`: Copy shown after a denial, above the "Open Settings" button. - `style`: Colors and tile shape. Defaults to the SwiftPieces house palette, adapting to light and dark. - `request`: Async permission request; return `true` when granted. The button spins and reads Requesting until it returns. - `onGranted`: Called about a second after a grant, once the tile has turned into a check. Dismiss the sheet here. - `onSkip`: Secondary handler. Omit to hide the "Not now" capsule. ## Spotlight Tour (`SpotlightTour`) URL: https://swiftpieces.com/docs/components/sheets/spotlight-tour Category: Sheets. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: SpotlightTour.swift Install: npx swiftpieces add SpotlightTour A first-run feature tour for any screen: mark controls with .spotlightAnchor and .spotlightTour dims everything else through a cutout that springs from stop to stop as a red ring draws around it, and sets a liquid glass callout with step progress below or above the target inside the safe area, its glass nub pointing at it. Skip and a red Next are a liquid glass pair that bud out of the callout on the far side from the target; on the last stop Skip melts into Next as Next morphs to Done. Taps reach the real control (which can also advance the tour), stops that aren't on screen are skipped, the cutout follows its target through scrolling, rotation and the keyboard, the tour can show itself only once, and VoiceOver, hardware keyboards and Reduce Motion stay in step. Walks people through the controls on a screen, one stop at a time: everything but the current stop is dimmed, a red ring draws around it, and a liquid glass callout explains it, with Skip and Next as a liquid pair. Mark each control with `.spotlightAnchor("compose")`, then apply `.spotlightTour` once, high in the hierarchy: on the `NavigationStack` or `TabView`, so the scrim covers the bars too, or on a sheet's root. Anchors anywhere inside it are found, toolbar items included. Taps inside the cutout reach the real control. Steps whose anchor is not on screen are skipped; while none is on screen, the tour waits. ### Notes - Two modifiers. `.spotlightAnchor("compose")` marks a control as a stop; `.spotlightTour(isPresented:steps:)` goes once on the container that holds them, ideally the `NavigationStack` or `TabView` so the scrim covers the bars too, or a sheet's root. Each `SpotlightTour.Step` names its anchor, a title and a message, and optionally an SF Symbol, the cutout's shape (`.automatic`, `.capsule`, `.circle`, `.rectangle(cornerRadius:)`) and its padding. - Put the anchor right on the control, before modifiers that widen its frame: `.spotlightAnchor("add")` then `.frame(maxWidth: .infinity, alignment: .trailing)`, not the other way round. Anchors report their frame in window coordinates through the environment, so toolbar items, safe area insets and other hosted content work. Use one id per stop. - While a stop shows, everything else is dimmed and a 2 pt red ring draws itself around the cutout from the top once the cutout has landed. The cutout and the ring stay as they are; the callout is liquid glass. Next springs the cutout to the next stop without overshoot, morphing between rounded rectangles, capsules and circles so its corners never wobble, and the callout trails it like a cue card, landing a beat later on a softer spring with a little give while its text dips out and back so words never smear. On a long trip the give is held to a few points, so the nub never reaches the ring. The tour irises in calmly onto the first stop with the callout following out of the target, and leaves quicker than it came. `.automatic` picks a circle for small square targets, a capsule for short wide ones and a rounded rectangle for the rest, with corners running parallel to the target's. - The callout sits below its target when it fits, else above, else on the roomier side, always inside the safe area and clear of the keyboard, resting 16 pt in from the sides and 10 pt from the top and bottom, and at most 360 pt wide. It is a glass panel with the step's words and progress, and a glass nub that melts into the panel's edge and points at the target's centre; the nub hides when the callout had to overlap the target (very large text on a small screen). - Skip and the red Next are a liquid pair: two glass capsules that rest apart from each other and from the panel, on the panel's far side from the target, so they never come between the two. As the callout arrives they bud out of the panel, each pulling a neck that snaps. On the last stop Skip melts into Next while Next morphs letter by letter into Done, and going back it buds out of Next again. When the callout flips to the other side of its target on the way to a stop, the pair flows round to the panel's other side. All text is semibold. - Taps inside the cutout reach the real control, UIKit bar buttons included. With `advancesOnTargetTap` (the default) the tap also moves the tour on. Taps and drags on the scrim are absorbed, so nothing underneath changes by accident, and a tap there swells the callout for a beat with a light tap. Swipe the callout toward the end of the line for Next, back toward its start to go back. It stays close to the finger at first and gets firmer the further it goes, firmer still toward a step that isn't there, then carries the flick's speed on to the next stop or springs home from it. Next and Skip sink slightly while pressed. - Steps whose anchor isn't on screen (a lazy row scrolled away, a view that isn't there yet) are skipped, and the count shows only the steps on screen. While no anchor is on screen the tour waits without dimming anything, then starts when one appears. The tour gives an anchor a moment before moving on, so toolbar items that report late and the frame changes of a rotation never make it jump. The cutout follows its target through scrolling, rotation, size changes and the keyboard. - `isPresented` works with `@AppStorage`: `@AppStorage("inbox.showTour") var showTour = true` shows it until it is finished or skipped. Or pass `showOnceKey: "inbox.tour.v1"` and the tour presents itself the first time the view appears and records completion in `UserDefaults`; `.spotlightTour(steps, showOnceKey:)` does the same without a binding. `SpotlightTour.resetShowOnce(_:)` brings it back, for a "Show tips again" setting. - `step:` takes an optional binding to the current step, to resume a tour or move it on when someone completes the thing you pointed at. The tour writes it as people move through and sets it back to 0 when it ends. `onFinish` reports `.completed` or `.skipped`. - VoiceOver: while the tour shows it is modal and the screen under it is hidden; focus moves to the callout's words, which are read before Skip and Next wherever the pair sits, and read "Step 2 of 4. Find it fast. Narrow the inbox by sender, date, attachments or unread." Skip and Next are buttons, Previous step is a custom action, the escape gesture skips, and each step change is announced. - Hardware keyboard: Return is Next or Done, Escape skips, and the arrow keys move along the steps in reading order. The tour never takes focus, so a text field keeps its keyboard. - Dynamic Type: the callout grows and flips above its target when it no longer fits below. At accessibility sizes Next and Skip stack full width, the symbol tile steps aside and the callout may use the full width. RTL mirrors the callout; the cutout stays on the physical control. - Reduce Motion: the cutout, ring and callout crossfade from stop to stop instead of travelling, the ring appears without drawing, and nothing scales. Skip and Next are simply there instead of budding, Skip fades instead of melting into Next, and Next and Done crossfade. A scrim tap shades the callout for a beat instead of swelling it (darker in light mode, lighter in dark), and Next and Skip shade the same way instead of sinking. - Haptics: a selection tick when a step changes, a soft impact when the tour ends, a light tap for a nudge. Nothing when the tour appears on its own. - All copy goes through `String(localized:)` and can be replaced with `Messages` (Next, Done, Skip, Previous step and the "2 of 4" and "Step 2 of 4" formats). `Style` sets the scrim, the accent used for the ring, the current step and the Next bubble, its ink, the callout's glass tint (`.clear`, the default, leaves it neutral) and text colors, the track, the corner radius, the ring width and the callout's widest size. - On iOS 26 the callout, its nub and the pair are Liquid Glass in one group, so they melt into each other through the neck; before that they are frosted glass with a light rim, and with Reduce Transparency they are solid. ### Parameters - `isPresented`: Shows the tour while `true`. Done, Skip and Escape set it back to `false`. Works with `@AppStorage` too, for example `@AppStorage("inbox.showTour") var showTour = true`, which then stays off once the tour has been seen. - `step`: Optional binding to the current step's index in `steps`, to read it or drive it (move on when someone completes an action, resume a tour). The tour writes it as people move through and sets it back to 0 when the tour is finished or skipped. `nil` keeps it internal. - `steps`: The stops in order. Each names the anchor it points at, its title and message, and optionally a symbol, the cutout's shape and its padding. - `showOnceKey`: A `UserDefaults` key. When set, the tour presents itself (by setting `isPresented`) the first time the view appears, once the screen has settled, and records under this key when it is finished or skipped, so it never presents itself again. Setting `isPresented` yourself always presents it. Reset with `SpotlightTour.resetShowOnce(_:)`. - `advancesOnTargetTap`: Tapping the highlighted control also moves the tour on, as well as running the control's own action. Defaults to `true`. - `messages`: The built-in copy: Next, Done, Skip and the step count. Defaults are localizable through your String Catalog. - `style`: Colors and metrics. Defaults to the SwiftPieces house palette, adapting to light and dark, with the red ring, current step and Next bubble. - `onFinish`: Called when the tour ends with Done (`.completed`) or Skip (`.skipped`), for analytics or to save that it was seen. Not called when you set `isPresented` to `false` yourself. ## Toast (`Toast`) URL: https://swiftpieces.com/docs/components/sheets/toast Category: Sheets. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: Toast.swift Install: npx swiftpieces add Toast A single liquid glass toast. A glass card carries the message, an optional detail line; as it lands, a tile bubble tinted for its kind buds out of its leading end with a countdown ring that drains around its glyph and pauses while touched, and an action such as Undo buds out of its trailing end as a signal bubble, both joined to the card by liquid necks. Placed inside the safe area so it clears the Dynamic Island and rides above the keyboard. Presents one transient toast on the top or bottom edge. ### Notes - Single instance by design. Presenting again while visible does nothing. Every touch, release, action and new toast starts a fresh run of the countdown, and only the latest run can dismiss, so a timer left over from an earlier toast never closes a newer one. - The toast is liquid glass, in three parts that rest joined: a glass card with the message and the detail, a round tile bubble at its leading end, and the action bubble at its trailing end, each held to the card by a liquid neck. The tile is tinted for its kind: `.success` sage, `.warning` and `.error` butter, and `.info` clear glass. All text is semibold. - It arrives calmly from its edge, landing with a slight give, with the message already in place, so it can be read at once. As it settles the tile buds out of the card's leading end and then the action out of its trailing end, each pulling a neck as it springs to its place, with the kind's haptic (success, warning, error, or a soft tap for info). Both bubbles keep their place in the layout while they are inside the card, so the card never resizes. It leaves faster and firmer than it came, all in one piece, and slides all the way out past its edge. The presentation animation is scoped to the toast, so changes the host makes in the same transaction keep their own animation. - `detail` adds a quieter second line. The action is a signal bubble: tapping it swaps the title for a check, pops the bubble with a light haptic, runs the handler, then dismisses a beat later. It still dismisses if the handler takes the view away first (switching tabs, pushing a screen), so the action never comes back live. - The countdown is a `.task` on the toast and is cancelled the moment the toast leaves. Touching the toast pauses it: the thin bar freezes and the toast sinks toward your finger. Releasing resumes from where it stopped, and so does a touch the system interrupts, such as an incoming call. - Swipe toward the edge to dismiss. The toast stays under your finger that way and gives with rubber-band resistance the other way. A swipe that would carry past 40 points leaves at the toast's own speed; anything shorter springs back at the speed you let go, with a little give past its resting place. Grabbing it again while it springs back takes it over from where it is, without a jump. - On iOS 26 the parts are Liquid Glass and the necks between them are real; before that they are frosted glass with a light rim. With Reduce Transparency they are solid, so the toast reads the same over any content. - With Reduce Motion the toast fades in and out, the bubbles fade in where they rest instead of budding, the press dims instead of sinking, and the action's pop is off. Haptics, the tile's tint and the check still say what happened. - Placement reads the safe area of the view it is applied to: at the top it clears the Dynamic Island, at the bottom it rises with the keyboard. Apply the modifier to a view that does not ignore safe areas. - Colors live in `Toast.Appearance` (passed as `appearance:`), because `Toast.Style` already names the kind. `surface` and `info` default to `.clear`, which leaves them neutral glass; any other color tints that glass. - The message and detail are posted as a VoiceOver announcement on appear; the action button is a real button with its own label. - For stacked, queued alerts with a notifications feed, see the Notifications screen (toast stack) in SwiftPieces Pro. - **v2.3:** the countdown is a ring around the tile's glyph rather than a line under the card: it drains clockwise from the top on a faint track, holds while the toast is touched and once the action has run, and resumes with what is left. It is a clock, so it keeps running under Reduce Motion. ### Parameters - `isPresented`: Shows the toast while `true`; the timer, swipe, and action set it back to `false`. - `message`: Text to show. Keep it to one line. - `detail`: Optional quieter second line, such as where the item went. - `systemImage`: Optional SF Symbol; defaults to a symbol that matches `style`. - `style`: The kind of toast, which picks the tile bubble's tint and default symbol: `.info`, `.success`, `.warning`, or `.error`. - `duration`: Seconds before it dismisses on its own. The countdown pauses while the toast is touched. - `position`: `.top` or `.bottom` edge to slide in from. - `action`: Optional trailing button (for example Undo), a signal glass bubble joined to the card. Tapping it swaps its title for a check, runs the handler, then dismisses a beat later. - `appearance`: Colors and corner radius. Defaults to the SwiftPieces house palette, adapting to light and dark. ## Expandable Text (`ExpandableText`) URL: https://swiftpieces.com/docs/components/text/expandable-text Category: Text. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: ExpandableText.swift Install: npx swiftpieces add ExpandableText A paragraph clamped to a line limit that measures whether it is really truncated, and only then fades the end of its last line into a small liquid glass "more" pill; tapping grows the block smoothly to its full height with no reflow while the pill rides its bottom edge down and morphs to "less", links stay tappable, and VoiceOver always reads the whole text. A "Read more" paragraph that shows a `more` pill only when the text is actually truncated. SwiftUI cannot tell you whether a `Text` was truncated, so this measures three hidden copies at the real width: the full text, the text at `lineLimit` lines, and the text at one line fewer (which locates the last visible line). The copies re-measure on width, Dynamic Type and text changes. When the full height exceeds the limited height, the visible text is laid out in full and clipped to the limited height; the end of the last line fades out (a transparent mask, so it works over any background) and a small glass pill reading `more` sits in the faded space on the trailing edge. Expanding animates the clip to the full height, so lines never reflow or jump, and the pill rides the text's bottom edge down to sit under it, its label morphing to `less`. The toggle runs in one transaction, so a card or row around the piece grows and shrinks with the text. Font and color come from the environment like `Text`: apply `.font(...)`, `.foregroundStyle(...)` and `.multilineTextAlignment(...)` to the piece. VoiceOver reads the full text in both states and gets "Show more" / "Show less" actions; the visual pill is hidden from it. ### Notes - SwiftUI has no API that reports truncation. The piece lays out three hidden copies at the paragraph's real width: the full text, the text at `lineLimit` lines, and the text at one line fewer. The "more" pill appears only when the full height is taller than the clamped height, so a short text never shows it. - The copies re-measure whenever the width, Dynamic Type size, font or text changes (`onGeometryChange` on iOS 18, a `GeometryReader` on iOS 17). They never read the expanded state, so there is no layout loop. - Until the first measurement lands, a plain `lineLimit` Text shows the same first lines, so first appearance does not jump. - Collapsed, the full text is clipped to the clamped height. The end of the last line fades to transparent through a mask and the link sits in the faded space as a small liquid glass pill reading "more", centred on the line. The mask needs no background color, so it works over cards, images and materials. Set `Style.fade` to paint a gradient into a solid color instead. - Expanding animates the clip height to the full height. Lines are laid out once at full length, so nothing reflows or jumps. The text unfurls on a calm spring with no visible overshoot and tucks back a little quicker. The pill rides the text's bottom edge on the same curve: down from the last line to hang just under the text as it opens, its label morphing letter by letter from "more" to "less", and back up as it closes. The fade at the end of the last line fills back in as the pill leaves it and returns a beat into the collapse, once the fold has passed most of the lines below it. This holds whether the piece is pinned at the top, the bottom or the center. With `showsLess: false` the paragraph stays open, and the pill sinks away once it does. - The pill is neutral glass, so it stays quiet inside reading text; its label is `Style.link`. On iOS 26 it is Liquid Glass and swells under a press; before iOS 26 it is frosted; under Reduce Transparency it is an opaque fill. The labels are localized keys, so the morph is `Text`'s own letter-by-letter content transition. - Taps on the pill, the body and the VoiceOver actions toggle inside one animation, so a card or row around the piece grows and shrinks with the text. If you drive `isExpanded` yourself, change it inside `withAnimation` and the text folds on your animation together with its container; changed without one, the paragraph still folds on its own curve but its container jumps to the new height. The faded line end keeps its own timing either way, tuned to the piece's collapse. - Font, color and alignment come from the environment, like `Text`: `.font`, `.foregroundStyle`, `.multilineTextAlignment`. - `AttributedString` keeps bold, italics and links. With `togglesOnTap`, tapping the body toggles too; a tap that opens a link does not toggle, and lines hidden below the fold have no hit area. Links open through your `openURL` environment action. - `isExpanded` lets you drive or observe the state, for example to keep one review open at a time. - RTL: the fade and the pill sit on the trailing side, which is the left in right-to-left languages. - `Style` sets the pill's label color and weight (semibold, the house weight, by default), fade width (scaled with Dynamic Type) and the optional solid fade color. `.disabled(true)` dims the pill's label and stops toggling. All of the piece's type is semibold. - Reduce Motion: the text opens in a 0.2 second ease and closes in 0.18 seconds, with no spring. The pill still moves with the bottom edge of the text and its label cross-fades; the fade waits until the collapse is landing. - VoiceOver reads the full text in both states, with links in the rotor. "Show more" and "Show less" actions resize the paragraph for sighted VoiceOver users; the visual pill is hidden from VoiceOver. - The pill draws at one line tall but has a hit area of at least 44 by 44 points. There are no haptics: this is reading content. ### Parameters - `text`: The paragraph. A `String` is shown verbatim; an `AttributedString` keeps bold, italics and tappable links. - `lineLimit`: Lines shown while collapsed. Values below 1 are treated as 1. - `isExpanded`: Optional binding to drive or observe the state from outside. `nil` keeps the state internally. - `moreLabel`: The pill's label on the last collapsed line. Localized from your app's strings; defaults to "more". - `lessLabel`: The pill's label under the expanded text. Localized from your app's strings; defaults to "less". - `showsLess`: When false, an expanded paragraph stays expanded (the pill leaves once it opens, and tapping the body does nothing). - `togglesOnTap`: When true, tapping anywhere on the paragraph also expands or collapses it. Links inside the text still open. - `style`: Pill label color and weight, fade width, and an optional solid fade color. Defaults to the house palette. ## Glass Text (`GlassText`) URL: https://swiftpieces.com/docs/components/text/glass-text Category: Text. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: GlassText.swift Install: npx swiftpieces add GlassText Liquid Glass rendered on the actual glyph outlines of a string, one or several lines, set in the house semibold by default, with a specular frosted fallback below iOS 26, a solid fill under Reduce Transparency and Dynamic Type scaling. Liquid Glass applied to text outlines via Core Text glyph paths. The glyph path is built from the string and font when the view's body runs, then handed to `glassEffect(_:in:)` as a shape. Its subject is glass itself, so it draws its glass directly rather than on a liquid group's shapes. Split the string with `\n` for a stacked display title; lines use tight display leading. ### Notes - Glyph outlines come from Core Text (`CTFontCreatePathForGlyph`), so any `UIFont` works, including custom families. The default is the system font at the house weight, semibold, like all SwiftPieces type; the example sets its numerals at 120pt so the strokes stay wide enough to bend the light. The path is built in `body`, not on layout passes, so keep `GlassText` out of anything that re-renders every frame: put the clock on the backdrop, as the example does, and leave the glass outside it. - On iOS 26 the outline is passed to `glassEffect(_:in:)` as a shape. Glass is this piece's subject, so it draws its glass directly instead of through the liquid group the other pieces share; the glass call needs the iOS 26 SDK and is compiled only with it, so the piece still builds with Xcode 16. Below iOS 26 the outline is filled with `.ultraThinMaterial` and a hairline highlight; Reduce Transparency fills it with `.primary` instead. - The point size follows Dynamic Type via `@ScaledMetric(relativeTo: .largeTitle)`, capped at 1.6x so a display word never leaves the screen. Pass `scalesWithDynamicType: false` to pin it. - Glass reads best over a backdrop with soft tonal variation; a flat color gives it nothing to refract. - Split the string with `\n` for a stacked display title. `Style.alignment` and `Style.leading` (0.86 of the font's line height by default) lay out the lines. - States: Liquid Glass on iOS 26 with a soft lift shadow; below iOS 26 a layered fallback (Material, optional tint at `fallbackTintAmount`, a top light and a 1pt specular rim); under Reduce Transparency a solid `Style.solidFill` (`.primary` by default). - The letters have no motion of their own: no entrance, no idle loop, no press. A new string takes the old one's place at once, so letters that did not change never dim or double up mid-swap. For numerals that tick, such as a clock, use `UIFont.monospacedDigitSystemFont(ofSize:weight:)` so unchanged digits keep their place. The life comes from the backdrop moving behind the glass. - Reduce Motion changes nothing in the component, since nothing in it moves. The example's color blocks start from a rest pose when the view appears and ease into their drift over two seconds; under Reduce Motion they hold that rest pose, and turning it off starts the drift again from rest. - VoiceOver reads the lines as one static text. - For a full purchase screen built around glass display type, see the Pro Purchase screen. ### Parameters - `text`: The string to render. `\n` starts a new line. - `font`: The `UIFont` used to build glyph paths: system semibold, the house weight, by default. Its point size is scaled with Dynamic Type, relative to `.largeTitle`. - `tint`: Optional glass tint. The Material fallback blends it in at `style.fallbackTintAmount`. - `scalesWithDynamicType`: Set false to pin the point size. - `style`: Line alignment and leading, fallback specular strength, depth shadow and the Reduce Transparency fill. ## Picture Headline (`PictureHeadline`) URL: https://swiftpieces.com/docs/components/text/picture-headline Category: Text. iOS 17.0+. Files: PictureHeadline.swift Install: npx swiftpieces add PictureHeadline A semibold headline with living pictures stuck between its words like a hand-placed collage. Each word rises into place and each picture opens from a dot into a tilted sticker with a white rim, landing with a spring, a light tick and a flash of crop marks, then keeps drifting. Press a sticker to lift it. Use the built-in house-palette scenes (the sun is our red mascot), your own images or SF Symbols; it wraps like text at any width and Dynamic Type size. A headline with pictures stuck between its words like stickers, revealed in reading order. Write it as a list of segments: strings are words, pictures sit where you put them. ```swift PictureHeadline(["Slow", .art(.sun), "mornings, long", .art(.waves), "walks and early", .art(.moon), "nights."]) .font(.system(size: 40, weight: .semibold)) ``` Words are set in the house weight, semibold. Pictures are sized from the font, so they follow Dynamic Type and every font you set. Each lands as a sticker: a white rim, a soft shadow and a small tilt that alternates along the headline, with crop marks flashing at its corners. Press and hold one to lift it. Text wraps word by word and a picture never breaks from the row it lands on. ### Notes - Write the headline as a list: string literals are words and pictures go where you put them, for example `["Slow", .art(.sun), "mornings, long", .art(.waves), "walks."]`. A string can hold several words; each word rises on its own. - Pictures land as stickers: a white rim, a soft shadow and a small resting tilt that alternates along the headline (`Style.tilt`, 4 degrees at most), so a line reads as placed by hand. As each one lands, four hairline crop marks snap in at its corners, contracting onto them, and clear a moment later: the crosses SwiftPieces draws on its artboards. `Style.tilt = 0` lays them flat, `Style.rim = 0` drops the rim and `Style.cropMarks = false` skips the marks. - Press and hold a sticker to lift it: it peels up at once on a spring with no overshoot under the finger, straightening and scaling up with a deeper shadow above its neighbours. A light tick lands once the hold has lasted a moment, so a quick tap or a scroll that starts on a sticker stays silent. It stays up until you let go, then drops back to its tilt with a little give. The press uses `onLongPressGesture(onPressingChanged:)` with a small tolerance, so scrolling over the headline still scrolls. A hold claims the touch, so a headline inside a `Button` or a tappable card doesn't also trigger it; a quick tap still passes through. `Style.lifts = false` turns the lift off. - Words are set in the house weight, semibold, at the piece's root; size carries the hierarchy, as the example's `.font(.system(size: 40, weight: .semibold))` shows. - Pictures are sized from the font: `Style.pictureHeight` is a fraction of the line height (0.82 by default) and each picture's `width` is in picture heights (2.4 is a wide frame, 1 a square). Set the font with `.font(...)` as usual and the pictures follow it, Dynamic Type included. - Words and pictures wrap like text with a private `Layout`, so the headline breaks across rows at any width and a picture never splits from the row it lands on. `alignment` sets each row's alignment. - The reveal runs in reading order. Each word slides up from behind its own baseline on a calm spring that never overshoots. Each picture appears as a dot as tall as the frame and widens into the full sticker on the opening spring (`Style.openDuration` and `Style.openBounce`), swelling a little past full width, picture and rim together, before it settles; its tilt lands a beat behind the frame as follow-through, and its contents settle from a slight zoom just after the frame lands. The stagger is sized from the number of items, so a short and a long headline both land in about `duration` (1.2 seconds by default). `delay` holds the start; change `trigger` to replay. The old headline fades out with the words it was showing, and the new one rises a beat behind it, so changing the segments and the trigger together (a pager, say) moves from one headline to the next without a cut. - Three kinds of picture: `.art(_:)` draws one of five built-in scenes in the house palette (`.sun` is the red SwiftPieces mascot, bobbing and blinking over the sand; `.waves`, `.moon`, `.hills` and `.bloom`), so a headline looks finished before you have artwork; `.image(_:)` fills the frame with your own `Image`; `.symbol(_:fill:ink:)` sets an SF Symbol on a solid block. - After landing, pictures keep moving: each starts from a still rest pose as it lands and eases into its drift over about a second, each on its own clock. Scenes drift (the sun bobs and blinks, the sea slides, stars twinkle), images pan slowly and symbols bob. Set `Style.drift = false` for still pictures. Drawing uses `Canvas` driven by a `TimelineView` at up to 30 frames a second, paused until the picture lands. - `revealOnScroll` waits until the headline scrolls into view with `onScrollVisibilityChange` on iOS 18; on iOS 17 it reveals on appear. - Haptics: a light tick as each picture's frame lands, timed from the opening spring and on the beat of its crop marks, and another when a hold lifts one. Under Reduce Motion one tick marks the moment the headline has faded in. A headline fading out for a replay never ticks. Pass `haptics: false` to stay silent. - VoiceOver reads the headline as one header: the words in order, plus a picture's `label` where it sits, so `.art(.sun, label: "a sunrise")` is read and an unlabelled picture is skipped. - Reduce Motion keeps the feedback and drops the movement: words and pictures fade in together, and pictures sit flat and still on their rest pose (the sun with its eyes open), with no rise, zoom, tilt, crop marks or drift. A held sticker deepens its shadow and shifts its brightness slightly instead of lifting (darker in light mode, lighter in dark mode), one tick marks the landing, and a replay fades the old headline out just ahead of the new one fading in. - `Style` (`.standard` is the house look): `pictureHeight`, `cornerRadius` (a fraction of the picture height; 0.5 makes a capsule), `tilt`, `rim`, `rimColor`, `cropMarks`, `lifts`, `drift`, `openDuration` and `openBounce`. ### Parameters - `segments`: Words (string literals) and pictures (`.art`, `.image`, `.symbol`), in reading order. - `alignment`: Row alignment, like `multilineTextAlignment`. - `duration`: Target time for the whole reveal, in seconds. The stagger is sized from the number of words and pictures, so short and long headlines both land in about this long. - `delay`: Delay before the first word starts. - `revealOnScroll`: When true, the reveal waits until the headline scrolls into view (iOS 18). Below iOS 18 it reveals on appear. - `trigger`: Change this value to replay the reveal. - `haptics`: A light tick as each picture lands and when one is lifted. On by default. - `style`: Picture size, corner rounding, tilt, rim, crop marks, lifting, drift and the opening spring. ## Text Reveal (`TextReveal`) URL: https://swiftpieces.com/docs/components/text/text-reveal Category: Text. iOS 17.0+. Liquid Glass on iOS 26 with a Material fallback. Files: TextReveal.swift Install: npx swiftpieces add TextReveal Reveals a headline by characters, words or lines, rising from a baseline mask with a stagger sized to land in under a second, then grows a stroke of tinted liquid glass behind each phrase that matters, the text flipping to ink exactly under its edge. Staggered text reveal that wraps correctly at any width and Dynamic Type size. The stagger is derived from the unit count, so a 3-word title and a 60-character sentence both finish in about `duration`. Phrases listed in `highlights` get a stroke of tinted liquid glass that grows in behind them once they land, with the text flipping to the house ink under its edge. ### Notes - The stagger is computed from the unit count: each unit takes about half a second to land and the rest of `duration` is spread across the starts, so long sentences do not crawl. The starts are eased rather than even: the first units leave a little closer together and the last ones settle in with a little more room, and the total stays the same. Pass a larger `duration` for a slower reveal. - Words and characters are laid out by a private wrapping `Layout`, so the text breaks across rows like normal text and characters never split a word. `.lines` renders each `\n`-separated line as one wrapping `Text`. All motion is render-only (offset, blur, scale, a drawn path), so wrapping never shifts during a reveal. - Splitting text into `Text` views loses kerning and ligatures between units; `.words` keeps them inside each word and is the best default for body-sized type. - Every preset arrives on the same calm spring with no visible overshoot, so a word never bobs past its baseline. `.rise` slides each unit up from behind its own baseline (a mask at the unit's bottom edge); set `Style.masksRise = false` for a 10pt float instead. `.blur` resolves from `Style.blurRadius` and `.soften` scales up slightly from the baseline as it fades in. - `highlights` takes whole-word phrases. A matched phrase is kept on one row. Once its last unit has nearly landed, a stroke of liquid glass tinted `Style.highlight` (butter by default) grows in behind it from the leading edge like a highlighter, keeping its round caps the whole way, on one smooth pass of `Style.highlightDuration` with no overshoot. An ink copy of the phrase in `Style.highlightInk` (the house ink) is uncovered by the same stroke while the base text gives way under it, so the color flips exactly under its edge. The highlights share one liquid group: two highlighted phrases side by side flow into one stroke. Under Reduce Transparency the glass is a solid block of the tint; before iOS 26 it is frosted. - Type is semibold, the house weight, set at the piece's root; size and colour carry the hierarchy. - `revealOnScroll` uses `onScrollVisibilityChange` on iOS 18; on iOS 17 it reveals on appear. Change `trigger` to replay: the current text fades out as one piece where it stands, and once it has mostly cleared (after about 0.16 s, added to `delay`) the new reveal rises in its place. When `text` changes along with `trigger`, the old text is what fades; a new `text` without a new `trigger` swaps in place with no replay. The layout takes the new text's size at once, since a fading copy takes no space. - Reduce Motion keeps the reveal and drops the movement: every unit fades in together with no lift, blur or scale, a highlight then fades in behind its phrase at full length instead of growing across, and a replay fades the old text out, then the new one in. - VoiceOver reads the whole string as one element from the start, whatever the reveal is doing. - `Style` (`.standard` uses the house palette): `highlight`, `highlightInk`, `masksRise`, `blurRadius`, `highlightDuration`. - For a complete first-run flow with revealed headlines, see the Pro Onboarding screen. ### Parameters - `text`: The string to display. With `.lines`, split lines with `\n`. - `unit`: `.characters`, `.words` or `.lines`. Characters never break mid-word. - `preset`: `.rise` (lift from a baseline mask), `.blur` (resolve from a soft blur) or `.soften` (scale and fade). - `alignment`: Row alignment, like `multilineTextAlignment`. - `duration`: Target time for the whole reveal, in seconds. - `delay`: Delay before the first unit starts, useful for sequencing a subhead after a headline. - `revealOnScroll`: When true, the reveal waits until the view scrolls into view (iOS 18). Below iOS 18 it reveals on appear. - `trigger`: Change this value to replay the reveal. - `highlights`: Phrases (one or more whole words, matched case-insensitively, ignoring punctuation) that get a glass highlight after they land. A highlighted phrase never breaks across rows. Ignored with `.lines`. - `style`: Highlight tint and ink, rise mask and blur radius. Defaults to the house palette. # Guides ## SwiftUI Animations: A Practical Guide with Examples URL: https://swiftpieces.com/docs/guides/swiftui-animations SwiftUI animations work by interpolating between two states of your view: you change some state, and SwiftUI animates every property that depends on it from the old value to the new one. You choose *when* to animate (implicitly with `.animation(_:value:)` or explicitly with `withAnimation`) and *how* to animate (a spring, an easing curve, or a keyframe timeline). This guide covers the full toolkit on iOS 17: springs, transitions, `matchedGeometryEffect`, phase and keyframe animators, numeric text, scroll-driven effects, gesture-driven motion that keeps the finger's velocity, and Reduce Motion. Every snippet compiles against the iOS 17 SDK. ### Implicit vs explicit animation An **implicit** animation is attached to a view and fires whenever a specific value changes. ```swift struct ImplicitExample: View { @State private var isExpanded = false var body: some View { RoundedRectangle(cornerRadius: 20) .fill(.orange) .frame(width: isExpanded ? 280 : 120, height: 120) .animation(.spring(duration: 0.4, bounce: 0.2), value: isExpanded) .onTapGesture { isExpanded.toggle() } } } ``` Always pass `value:`. The old `.animation(_:)` without a value animates every change that reaches the view, including ones you did not mean to animate, and it is deprecated. An **explicit** animation wraps the state change itself. Everything that changes as a result of that mutation animates, anywhere in the hierarchy. ```swift struct ExplicitExample: View { @State private var isExpanded = false var body: some View { VStack(spacing: 16) { RoundedRectangle(cornerRadius: 20) .fill(.orange) .frame(width: isExpanded ? 280 : 120, height: 120) Button(isExpanded ? "Collapse" : "Expand") { withAnimation(.snappy) { isExpanded.toggle() } } } } } ``` A good rule: use `withAnimation` for user actions (a tap, a submit, a selection) and implicit animation for values that change on their own (a model update, a timer). On iOS 17, `withAnimation(_:completionCriteria:_:completion:)` also gives you a completion callback, which replaces most `DispatchQueue.main.asyncAfter` hacks. ### Spring animations Springs are the default in modern SwiftUI, and they should be your default too. A spring is described by two numbers: - `duration`: how long the motion feels, not a hard stop time. - `bounce`: 0 is critically damped (no overshoot), 0.1 to 0.3 is a gentle overshoot, higher values wobble. Negative values are overdamped. ```swift struct SpringExample: View { @State private var isOn = false var body: some View { Circle() .fill(.indigo) .frame(width: 60, height: 60) .offset(x: isOn ? 120 : -120) .onTapGesture { withAnimation(.spring(duration: 0.5, bounce: 0.3)) { isOn.toggle() } } } } ``` SwiftUI ships three presets that cover most interface work: | Preset | Feel | Good for | | --- | --- | --- | | `.smooth` | No bounce | Layout changes, sheets, large surfaces | | `.snappy` | Small bounce, quick | Selection, toggles, small controls | | `.bouncy` | Visible bounce | Playful confirmations, badges, icons | Each takes `duration:` and `extraBounce:` if you need to tune it, for example `.snappy(duration: 0.3, extraBounce: 0.1)`. #### Why springs beat easing curves Easing curves like `.easeInOut` have a fixed duration and assume the motion starts at rest. Real interfaces get interrupted: the user taps again mid-animation, or drags something that is still settling. When a spring is retargeted, SwiftUI carries the current velocity into the new motion, so the object bends toward the new target instead of stopping and restarting. That continuity is most of what makes an iOS interface feel physical. Keep `.linear` for things that really are linear, like a progress fill tied to a clock. ### Transitions Animations change a view that stays on screen. **Transitions** describe how a view enters or leaves the hierarchy, usually inside an `if`. A transition only plays when the insertion or removal happens inside an animated transaction. ```swift struct TransitionExample: View { @State private var showsBanner = false var body: some View { VStack { if showsBanner { Text("Saved") .font(.headline) .padding(.horizontal, 20) .padding(.vertical, 12) .background(.green.opacity(0.3), in: Capsule()) .transition( .asymmetric( insertion: .move(edge: .top).combined(with: .opacity), removal: .scale(scale: 0.9).combined(with: .opacity) ) ) } Spacer() Button("Toggle banner") { withAnimation(.bouncy) { showsBanner.toggle() } } } .padding() } } ``` - `.combined(with:)` runs two transitions together. - `.asymmetric(insertion:removal:)` uses different motion on the way in and out. Arrivals can be lively; departures should be quick and quiet. - iOS 17 adds `.blurReplace`, which works well when one piece of content swaps for another in place. `.push(from:)` slides content in from an edge. If a transition does not play, check two things: the change happened inside `withAnimation` (or under an `.animation(_:value:)` on a parent), and the view really left the hierarchy rather than just changing opacity. ### matchedGeometryEffect `matchedGeometryEffect` tells SwiftUI that two views in different places are the same thing, so it animates the frame from one to the other. The classic use is a selection indicator that glides between options. ```swift struct SegmentedExample: View { @Namespace private var namespace @State private var selection = "Week" private let options = ["Day", "Week", "Month"] var body: some View { HStack(spacing: 4) { ForEach(options, id: \.self) { option in Button { withAnimation(.snappy) { selection = option } } label: { Text(option) .font(.subheadline.weight(.semibold)) .padding(.horizontal, 16) .padding(.vertical, 8) .background { if selection == option { Capsule() .fill(.background) .matchedGeometryEffect(id: "indicator", in: namespace) } } } .buttonStyle(.plain) } } .padding(4) .background(.quaternary, in: Capsule()) } } ``` Only one view with a given `id` should be visible at a time (or mark one as `isSource: false`). For morphing a card into a detail screen, the same idea applies across a larger hierarchy. [Prompt Chips](/docs/components/ai/prompt-chips) takes another route for its hand-off: the other chips melt into the tapped one inside a glass group, with no matched geometry at all. ### phaseAnimator and keyframeAnimator Both are iOS 17 APIs for motion that has more than two states. #### phaseAnimator `phaseAnimator` steps through a sequence of values, animating between each one. Give it a `trigger` to run once per change, or leave the trigger out to loop forever. ```swift struct BellExample: View { @State private var rings = 0 var body: some View { Image(systemName: "bell.fill") .font(.largeTitle) .phaseAnimator([0.0, -14.0, 12.0, -6.0, 0.0], trigger: rings) { content, angle in content.rotationEffect(.degrees(angle), anchor: .top) } animation: { _ in .spring(duration: 0.15, bounce: 0.3) } .onTapGesture { rings += 1 } } } ``` #### keyframeAnimator `keyframeAnimator` gives each property its own timeline. Use it when scale, offset, and rotation should not move in lockstep. ```swift struct HopValues { var scale = 1.0 var offsetY = 0.0 } struct HeartHopExample: View { @State private var hops = 0 var body: some View { Image(systemName: "heart.fill") .font(.system(size: 44)) .foregroundStyle(.red) .keyframeAnimator(initialValue: HopValues(), trigger: hops) { content, value in content .scaleEffect(value.scale) .offset(y: value.offsetY) } keyframes: { _ in KeyframeTrack(\.scale) { SpringKeyframe(0.85, duration: 0.1) SpringKeyframe(1.2, duration: 0.2, spring: .bouncy) SpringKeyframe(1.0, spring: .smooth) } KeyframeTrack(\.offsetY) { CubicKeyframe(0, duration: 0.1) CubicKeyframe(-20, duration: 0.2) SpringKeyframe(0, spring: .bouncy) } } .onTapGesture { hops += 1 } } } ``` The press-in, pop, and settle pattern here is the same shape [Reaction Toggle](/docs/components/feedback/reaction-toggle) builds on, with a color flood and a halo on top. ### Animating numbers and text `contentTransition(.numericText(value:))` rolls digits instead of cross-fading the whole label. Add `.monospacedDigit()` so the width does not jump. ```swift struct CounterExample: View { @State private var count = 0 var body: some View { VStack(spacing: 12) { Text(count, format: .number) .font(.system(size: 56, weight: .semibold, design: .rounded)) .monospacedDigit() .contentTransition(.numericText(value: Double(count))) Button("Add") { withAnimation(.snappy) { count += 1 } } } } } ``` Passing `value:` tells SwiftUI the direction, so digits roll up when the number grows and down when it shrinks. For a mechanical counter where each digit rolls on its own slot and carries from the low digits up, see [Odometer](/docs/components/data/odometer). For headlines that rise in by character, word, or line with a stagger, see [Text Reveal](/docs/components/text/text-reveal). ### Scroll-driven animation #### scrollTransition `scrollTransition` gives each child a `phase` as it enters, sits in, and leaves the visible area. `phase.value` runs from -1 (leading edge) through 0 (fully visible) to 1 (trailing edge). ```swift struct CarouselExample: View { var body: some View { ScrollView(.horizontal) { LazyHStack(spacing: 16) { ForEach(0..<10, id: \.self) { index in RoundedRectangle(cornerRadius: 24) .fill(Color(hue: Double(index) / 10, saturation: 0.5, brightness: 0.9)) .containerRelativeFrame(.horizontal) .frame(height: 220) .scrollTransition { content, phase in content .scaleEffect(phase.isIdentity ? 1 : 0.88) .opacity(phase.isIdentity ? 1 : 0.6) .rotation3DEffect(.degrees(phase.value * -20), axis: (x: 0, y: 1, z: 0)) } } } .scrollTargetLayout() } .contentMargins(.horizontal, 40) .scrollTargetBehavior(.viewAligned) } } ``` [Depth Carousel](/docs/components/lists/depth-carousel) takes this further: pages recede in depth away from center, each page gets a phase value for inner parallax, and the page indicator is computed in absolute page coordinates so it never jumps. #### visualEffect `visualEffect` hands you a `GeometryProxy` without affecting layout, so you can drive effects from a view's position cheaply. ```swift struct ShrinkingRowsExample: View { var body: some View { ScrollView { VStack(spacing: 12) { ForEach(0..<30, id: \.self) { index in Text("Row \(index)") .frame(maxWidth: .infinity, minHeight: 56) .background(.quaternary, in: RoundedRectangle(cornerRadius: 14)) .visualEffect { content, proxy in let minY = proxy.frame(in: .scrollView).minY return content .scaleEffect(minY < 0 ? max(0.9, 1 + minY / 600) : 1) .opacity(minY < 0 ? max(0.4, 1 + minY / 300) : 1) } } } .padding() } } } ``` This replaces the old pattern of reading a `GeometryReader` into `@State` on every scroll frame, which re-evaluates your view body far more than it needs to. ### Gesture-driven motion that keeps velocity The difference between a drag that feels native and one that feels cheap is what happens on release. A good release decides based on where the finger was *heading* (`predictedEndTranslation`) and hands the finger's velocity to the spring, so there is no speed jump at the moment you let go. ```swift struct FlingCardExample: View { @State private var offset: CGSize = .zero @State private var isGone = false var body: some View { RoundedRectangle(cornerRadius: 28) .fill(.indigo) .frame(width: 280, height: 380) .offset(offset) .rotationEffect(.degrees(Double(offset.width / 20))) .opacity(isGone ? 0 : 1) .gesture( DragGesture() .onChanged { value in offset = value.translation } .onEnded { value in let projected = value.predictedEndTranslation.width let shouldThrow = abs(projected) > 200 let targetX: CGFloat = shouldThrow ? (projected > 0 ? 600 : -600) : 0 let velocity = relativeVelocity( value.velocity.width, from: offset.width, to: targetX ) withAnimation(.interpolatingSpring(duration: 0.45, bounce: shouldThrow ? 0 : 0.25, initialVelocity: velocity)) { offset = CGSize(width: targetX, height: shouldThrow ? offset.height : 0) isGone = shouldThrow } } ) } /// Spring velocity is expressed relative to the remaining distance. private func relativeVelocity(_ velocity: CGFloat, from current: CGFloat, to target: CGFloat) -> Double { let distance = target - current guard abs(distance) > 1 else { return 0 } return Double(velocity / distance) } } ``` Two details matter here: - **Decide on the projection, not the position.** A short, fast flick should throw the card even though the finger only moved 60 points. `predictedEndTranslation` captures that. - **Normalize the velocity.** `initialVelocity` is measured in "distances per second", where 1 means the full distance to the target. Dividing points per second by the remaining distance gets you there. This is the core of [Swipe Deck](/docs/components/cards/swipe-deck), which adds an outcome badge that locks at the threshold and cards beneath that rise as the top one leaves. [Drag to Dismiss](/docs/components/motion/drag-to-dismiss) applies it to photos and sheets on two axes, and [Flip Card](/docs/components/cards/flip-card) uses a drag to scrub a 3D rotation, then commits or snaps back by velocity. ### Respect Reduce Motion Some people get motion sick from large movement, parallax, and zooms. Read `accessibilityReduceMotion` and swap spatial motion for a short fade. Do not remove feedback entirely: state changes still need to be visible. ```swift struct ReduceMotionExample: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var isShowing = false var body: some View { VStack(spacing: 20) { if isShowing { Text("Welcome back") .font(.title.bold()) .transition(reduceMotion ? AnyTransition.opacity : .move(edge: .bottom).combined(with: .opacity)) } Button(isShowing ? "Hide" : "Show") { withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .bouncy) { isShowing.toggle() } } } } } ``` What to reduce: travel across the screen, parallax, 3D rotation, zooming, looping motion. What to keep: opacity changes, color changes, and small scale feedback on press. Every SwiftPieces component reads this setting. [Elastic Button](/docs/components/controls/elastic-button), for example, keeps its scale and darken under Reduce Motion but drops the lean and stretch toward the finger. ### Performance notes - **Prefer render effects over layout.** `scaleEffect`, `offset`, `opacity`, and `rotationEffect` do not trigger layout. Animating `frame` or padding on hundreds of views does. - **Scope animations tightly.** `.animation(_:value:)` on the smallest view that needs it, or the iOS 17 `.animation(_:body:)` form, avoids animating unrelated changes. - **Use `visualEffect` and `scrollTransition` instead of geometry in state.** Writing scroll offsets into `@State` every frame re-runs bodies for no visible gain. - **Flatten complex drawings.** `drawingGroup()` renders a subtree into one layer, which helps with many overlapping shapes and gradients. Test it, since it can hurt simple views. - **Stabilize identity.** Views in a `ForEach` need stable ids, or SwiftUI treats a moved row as a removal plus an insertion and plays transitions you did not ask for. - **Measure on a device.** The simulator hides frame drops. Profile with Instruments on real hardware, ideally a slower one. ### Where to go next The [animations hub](/components/animations) collects every SwiftPieces component built around motion, from gesture-driven cards to scroll effects. Buttons are where most small motion lives, so the [SwiftUI buttons guide](/docs/guides/swiftui-buttons) continues with press styles and loading states, and [SwiftUI haptics](/docs/guides/swiftui-haptics) covers pairing motion with feel. ### Install a piece Each piece is a single Swift file with no dependencies. Add one with the CLI: ```bash npx swiftpieces add SwipeDeck npx swiftpieces add Odometer ``` See [installation](/docs/installation) for manual setup and Xcode details. ## SwiftUI Motion: Springs, Rubber Banding and Gesture Momentum URL: https://swiftpieces.com/docs/guides/swiftui-motion Motion feels physical when it behaves like something with mass: it answers the finger at once, stays under the finger while it moves, keeps the finger's speed when it is let go, resists at its limits and comes to rest with a little give. Motion feels mechanical when one spring is pasted on every change, when everything moves at the same moment, and when a flick dies the instant the finger lifts. This guide is the motion language every SwiftPieces component shares, written so you can use it in your own views. Every snippet targets iOS 17 and uses only SwiftUI. ### Five spring tiers Most interfaces need far fewer springs than they use. SwiftPieces uses five, and each moment picks the tier that matches what just happened. Overshoot and timing are measured from rest with SwiftUI's `Spring`. | Tier | `Spring` | Overshoot | 90% of the way | Use it for | | --- | --- | --- | --- | --- | | Tight | `Spring(duration: 0.14, bounce: 0)` | none | 87ms | a press arriving under the finger | | Responsive | `Spring(duration: 0.32, bounce: 0.25)` | 2.8% | 143ms | snapping to a detent, segment or page | | Elastic | `Spring(duration: 0.42, bounce: 0.38)` | 8.4% | 161ms | a release, a return from past an edge, a drag settling home | | Expressive | `Spring(duration: 0.48, bounce: 0.48)` | 15% | 166ms | a resolved action landing, at most once per interaction | | Calm | `Spring(duration: 0.5, bounce: 0.2)` | 1.5% | 238ms | opening large surfaces, ambient change | Two rules keep the bounce feeling fluid rather than sloppy. Bounce belongs to releases, landings and outcomes, never to the press itself: a press that overshoots under the finger reads as a glitch. And values people read, a number, a chart, a progress figure, never overshoot at all, because for that instant they would show something untrue. ### Roles A tier says how something moves. A role says why, and carries the Reduce Motion substitute with it, so no call site has to remember one. | Role | Moment | Spring | Under Reduce Motion | | --- | --- | --- | --- | | `press` | touch-down | tight | tight | | `release` | the finger lifts off a pressed surface | elastic | short spring, no overshoot | | `settle` | a dragged thing comes to rest | elastic, carrying the finger's velocity | short spring, no overshoot | | `snap` | lands on a detent, segment or page | responsive | short spring, no overshoot | | `value` | a number or chart value moves to its new value | `Spring(duration: 0.35, bounce: 0)` | short spring, no overshoot | | `rebound` | returns from past a limit | elastic | short spring, no overshoot | | `morph` | size, corner radius or shape changes | `Spring(duration: 0.4, bounce: 0.2)` | short spring, no overshoot | | `reveal` | appears, opens, expands | calm | 0.2s ease-out fade | | `dismiss` | leaves, closes, collapses | `Spring(duration: 0.3, bounce: 0.08)` | 0.18s ease-in fade | | `success` | a resolved action lands | expressive | 0.24s ease-out | | `error` | a refused action | responsive, plus a shake | 0.2s ease-out, no shake | | `ambient` | slow background change | calm, and loops pause | loops stop on a rest pose | Opening is a little slower than closing on purpose: things arrive with care and get out of the way quickly. ### Track the finger, spring the release The surface must stay under the finger at every frame. Set gesture state directly in `onChanged` and never put an animation on it: an `.animation(_:value:)` or an interactive spring on a dragged offset makes the surface trail the finger, and that lag is what reads as mushy. The release is the opposite. When the finger lifts, the surface should leave at the finger's speed and then settle. SwiftUI sometimes carries a gesture's velocity into the next spring by itself, but not reliably, so pass it explicitly. `interpolatingSpring` takes an initial velocity measured in whole distances per second, so divide the finger's speed by the distance left to travel. A spring takes one velocity, and a `CGSize` offset is one animated value, so it would give both axes the same speed. Put each axis on its own offset and release each with its own velocity: ```swift struct DraggableTile: View { @State private var offset: CGSize = .zero var body: some View { RoundedRectangle(cornerRadius: 24, style: .continuous) .fill(.orange) .frame(width: 140, height: 140) // One offset per axis, so each can carry its own velocity. .offset(x: offset.width) .offset(y: offset.height) .gesture( DragGesture() .onChanged { offset = $0.translation } // no animation: stays under the finger .onEnded { drag in let spring = Spring(duration: 0.42, bounce: 0.38) withAnimation(.interpolatingSpring(spring, initialVelocity: relative(drag.velocity.width, from: offset.width, to: 0, spring: spring))) { offset.width = 0 } withAnimation(.interpolatingSpring(spring, initialVelocity: relative(drag.velocity.height, from: offset.height, to: 0, spring: spring))) { offset.height = 0 } } ) } /// Finger speed in distances per second, capped near the spring's own frequency so a hard flick /// adds a little overshoot rather than a slingshot. private func relative(_ velocity: CGFloat, from current: CGFloat, to target: CGFloat, spring: Spring) -> Double { let distance = target - current guard abs(distance) >= 1 else { return 0 } let cap = 2 * Double.pi / spring.duration * 1.5 return min(max(Double(velocity / distance), -cap), cap) } } ``` If something waits for the release to finish, attach the completion to the axis that travels further. An axis with nothing left to travel completes at once. ### Decide where a flick lands Choose the resting place from where the motion is heading, not from where the finger happened to lift. `DragGesture.Value.predictedEndTranslation` does this for you. For your own thresholds, project the release the way a scroll view decelerates: ```swift /// Where a flick at `velocity` (points per second) would coast to from `position`. /// 0.998 coasts like a scroll view; 0.99 is shorter and suits detents, so a moderate flick doesn't skip one. func project(_ position: CGFloat, velocity: CGFloat, decelerationRate: CGFloat = 0.99) -> CGFloat { position + velocity / 1000 * decelerationRate / (1 - decelerationRate) } func nearest(_ value: CGFloat, in candidates: [CGFloat]) -> CGFloat { candidates.min { abs($0 - value) < abs($1 - value) } ?? value } // let detent = nearest(project(sheetOffset, velocity: drag.velocity.height), in: [0, 320, 640]) ``` ### Rubber banding at the edges A limit should feel elastic, not broken. Past its bound, a dragged value keeps moving with growing resistance and never escapes, then springs back with `rebound`. This is the curve a scroll view uses at its edge: ```swift /// How far something travels when pulled `overshoot` points past its limit. It grows ever more /// slowly and never reaches `limit`. 0.55 matches a scroll view. func rubberBand(_ overshoot: CGFloat, limit: CGFloat, coefficient: CGFloat = 0.55) -> CGFloat { guard limit > 0, overshoot != 0 else { return 0 } let banded = (1 - 1 / (abs(overshoot) * coefficient / limit + 1)) * limit return overshoot < 0 ? -banded : banded } ``` Size the limit to the thing being pulled: 24 to 40 points for a slider thumb or a toggle, 60 to 120 for a card or a sheet. Band the total overshoot, never each event's delta, or the surface drifts away from the finger. For a control, only the visual overshoots: the committed value stays clamped. ### Press deformation A press should land within a frame or two, and never bounce on the way in. Two details make it feel like pressure rather than a resize: - **Size-aware depth.** Sink each edge by about the same few points instead of a fixed percentage. An icon button lands near 0.92, a pill near 0.95 and a card near 0.985. - **A lean toward the finger.** Anchor the scale partway from the center toward the touch, and keep that anchor through the release, so the surface re-expands from where it was pressed instead of sliding. ```swift struct PressStyle: ButtonStyle { @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var size: CGSize = .zero func makeBody(configuration: Configuration) -> some View { let pressed = configuration.isPressed configuration.label .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } // Reduce Motion: dim instead of moving, without turning the surface see-through. .brightness(pressed && reduceMotion ? -0.08 : 0) .scaleEffect(pressed && !reduceMotion ? scale(for: size) : 1) .animation(pressed ? .spring(duration: 0.14, bounce: 0) : .spring(duration: 0.42, bounce: reduceMotion ? 0 : 0.38), value: pressed) } /// About 2.5 points at each edge, between 0.92 and 0.985. private func scale(for size: CGSize) -> CGFloat { let side = (max(size.width, 1) * max(size.height, 1)).squareRoot() return min(max(1 - 5 / side, 0.92), 0.985) } } ``` Leave the touch-point lean out of anything inside a `ScrollView` or `List`: reading the touch location needs a zero-distance drag gesture, and that gesture competes with scrolling. ### Squash and stretch A surface in motion reads as soft when it stretches along its direction of travel and thins across it by the same factor, so its area never changes. Scaling one axis alone reads as growing. A `GeometryEffect` can stretch along any angle: ```swift /// Stretches along `vector`'s direction by its length (0.06 is 6%) and thins across it, keeping the area. struct Stretch: GeometryEffect { var vector: CGSize var animatableData: AnimatablePair { get { AnimatablePair(vector.width, vector.height) } set { vector = CGSize(width: newValue.first, height: newValue.second) } } func effectValue(size: CGSize) -> ProjectionTransform { let amount = min(hypot(vector.width, vector.height), 0.2) guard amount > 0.0005 else { return ProjectionTransform() } let c = vector.width / amount, s = vector.height / amount let along = 1 + amount, across = 1 / along, k = along - across let stretch = CGAffineTransform(a: across + k * c * c, b: k * c * s, c: k * c * s, d: across + k * s * s, tx: 0, ty: 0) let x = size.width / 2, y = size.height / 2 return ProjectionTransform(CGAffineTransform(translationX: -x, y: -y).concatenating(stretch).concatenating(CGAffineTransform(translationX: x, y: y))) } } ``` Animate the vector back to zero rather than the angle and the amount separately: the direction holds while it shrinks, so the shape never spins on release. Clamp hard. About 8 to 12% suits a card or a blob, 12 to 18% a small indicator, and no more than 3 to 5% on anything carrying text. A tethered pull, like a button dragged past its edge, should stretch from the pull distance; free flight can stretch from velocity, low-passed, and cleared when the finger rests, since a resting finger sends no events. ### Follow-through and stagger When everything moves at once, nothing leads. Let one element move first and let supporting elements answer a beat later. There are two ways, and they suit different jobs: - **A longer spring per rank** for anything that can reverse mid-flight. Each follower gets about 40ms more duration and a touch more bounce, so it arrives later and settles softer, and it still turns around at once if the state flips back. - **A delay per index** for one-shot entrances: about 30ms per item, capped around the seventh item so a long list never keeps the last row waiting. A delay holds a follower still when a toggle reverses, so don't use it for anything toggleable. Exits go together. ```swift struct Panel: View { @State private var open = false var body: some View { VStack(spacing: 12) { ForEach(0..<4) { rank in RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(.orange.opacity(1 - Double(rank) * 0.18)) .frame(height: 52) // The lead moves first; each rank after it follows a little softer. .animation(.spring(duration: 0.42 + 0.04 * Double(rank), bounce: 0.38 + 0.02 * Double(rank))) { $0.offset(y: open ? 0 : 24).opacity(open ? 1 : 0) } } } .onTapGesture { open.toggle() } } } ``` Never stagger data: numbers, chart values and text the user is reading arrive together. ### State morphs Loading, success and error are one control changing, not three controls crossfading. Keep a single container and its identity, and branch only the glyph or label inside it. Then sequence the change: 1. The container changes shape with `morph`, settling with a little give. 2. The content swaps inside it, the old blurring out as the new sharpens in. 3. A secondary response lands last: for success, a small dip, swell and settle; for a refusal, a short decaying shake, with the error haptic on the same beat. ```swift /// Anticipation, overshoot, settle, each time `trigger` changes. Apply with `.modifier(Pop(trigger: saves))`. struct Pop: ViewModifier { let trigger: Int func body(content: Content) -> some View { content.keyframeAnimator(initialValue: CGFloat(1), trigger: trigger) { view, scale in view.scaleEffect(scale) } keyframes: { _ in KeyframeTrack { CubicKeyframe(0.97, duration: 0.08) SpringKeyframe(1.08, duration: 0.14, spring: Spring(duration: 0.18, bounce: 0)) SpringKeyframe(1, duration: 0.42, spring: Spring(duration: 0.48, bounce: 0.48)) } } } } ``` Use a counter for the trigger, never a `Bool`: a Bool that flips back before the view updates never fires. ### Reduce Motion Reduce Motion asks for less motion, not less feedback. Substitute rather than switch off: - Releases, snaps and settles land on a short spring with no overshoot. - Moving transitions become fades. Content swaps fade without blur. - Presses dim instead of scaling. Stretch, lean, pop and shake turn off. - Ambient loops stop on a designed rest pose, not on whatever frame they were on. - Color, symbol, label and haptic feedback all stay, so every outcome still reads. - Gestures keep tracking the finger and keep their velocity; only the bounce goes. ### Haptics that land with the motion - Fire a haptic when the motion arrives, not when it starts: a check's success tap belongs to the moment the check lands. - One haptic per event. A threshold tick and a throw impact in the same frame read as a stutter. - Use counters or semantic values (a step index, a detent) as triggers. - `.increase`, `.decrease`, `.start`, `.stop` and `.levelChange` play nothing on iPhone. Use `.selection` or an `.impact` there. - Presses and continuous tracking get no haptic of their own. Steps, detents, thresholds and outcomes do. [SwiftUI haptics](/docs/guides/swiftui-haptics) covers the modifier in depth. ### Anti-patterns - One bouncy spring pasted on every change. - Press feedback that is only a uniform scale, or that overshoots on the way in. - Animating finger tracking, which adds lag. - Releases that start from rest after a flick. - Hard clamps at limits, or linear resistance that never stiffens. - Every sibling moving at the same moment, or a stagger on data. - Crossfading a whole control between states instead of morphing one container. - Overshoot on data values, or a bounce so large that a size change wobbles. - Loops that never rest, or that keep running under Reduce Motion. - Changing layout on every gesture frame. Move with offset, scale and geometry effects instead. ### How SwiftPieces ships it Every piece shares this language through `PieceMotion`: the tiers, the roles with their Reduce Motion substitutes, velocity-carrying settles, projection, rubber banding, press deformation, stretch, follow-through, pop and shake. A piece still stands alone. Each `.swift` file carries a private copy of just the parts of `PieceMotion` it uses, at the end of the file under `// MARK: - Piece motion`, so installing one piece is still one file and two pieces in the same app never collide. If you contribute a piece, use the roles instead of writing springs, edit `registry/foundation/PieceMotion.swift` rather than a copy, and run `npm run motion:sync`. `registry:build` refuses to run while a copy is out of date. ### Install a piece Each piece is one Swift file built on Apple frameworks only. Run the CLI from the folder that contains your `.xcodeproj`: ```bash npx swiftpieces add ElasticButton SwipeDeck GlassSegments ``` See [installation](/docs/installation) for copy and paste and the details of what the CLI adds. ## SwiftUI Buttons: Custom Styles, Animation, Loading URL: https://swiftpieces.com/docs/guides/swiftui-buttons A SwiftUI button is a `Button` view: an action closure plus a label, styled separately through `ButtonStyle`. Keep the action and label in the `Button`, and put the look and the press animation in a style you can reuse across the app. This guide goes from roles and built-in styles to a custom press-scale style with a spring, haptics, an async loading button with success and error states, hold to confirm for destructive actions, and the accessibility details that are easy to miss. Every snippet compiles against the iOS 17 SDK. ### Button basics and roles ```swift struct ButtonBasicsExample: View { var body: some View { VStack(spacing: 16) { Button("Save") { save() } Button("Delete", systemImage: "trash", role: .destructive) { delete() } Button { share() } label: { Label("Share", systemImage: "square.and.arrow.up") } Button("Cancel", role: .cancel) {} } } private func save() {} private func delete() {} private func share() {} } ``` A **role** describes what the button does, not how it looks. `.destructive` tints the label red in system styles, and in alerts, confirmation dialogs, and swipe actions it also changes placement. `.cancel` tells the system which button dismisses. Always set roles, because system containers rely on them and VoiceOver users benefit from the ordering they produce. Prefer the `Label` or `systemImage:` forms over hand-built `HStack`s. The label adapts to its context (icon only in toolbars, title and icon in menus) and reads correctly to VoiceOver. ### Built-in button styles ```swift struct BuiltInStylesExample: View { var body: some View { VStack(spacing: 16) { Button("Plain") {} .buttonStyle(.plain) Button("Bordered") {} .buttonStyle(.bordered) Button("Continue") {} .buttonStyle(.borderedProminent) .controlSize(.large) .tint(.indigo) Button("Capsule") {} .buttonStyle(.bordered) .buttonBorderShape(.capsule) if #available(iOS 26, *) { Button("Glass") {} .buttonStyle(.glass) Button("Prominent glass") {} .buttonStyle(.glassProminent) } } } } ``` - `.borderedProminent` is the filled primary action. Use one per screen. - `.bordered` is a tinted secondary action. - `.controlSize` and `.buttonBorderShape` adjust size and shape without a custom style. - `.glass` and `.glassProminent` are **iOS 26 only** and render Liquid Glass. Gate them with `#available` if you support iOS 17. The [Liquid Glass docs](/docs/liquid-glass) cover fallbacks. ### ButtonStyle vs PrimitiveButtonStyle Both let you restyle any `Button` with `.buttonStyle(_:)`. The difference is who owns the interaction. **`ButtonStyle`** keeps the system's tap handling. You get `configuration.label`, `configuration.role`, and `configuration.isPressed`, and you only decide how the button looks in each state. This is what you want almost every time. **`PrimitiveButtonStyle`** hands you `configuration.trigger()` and makes you define the gesture yourself. Use it when the activation itself changes, for example a button that fires on a long press: ```swift struct LongPressButtonStyle: PrimitiveButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .padding() .background(.quaternary, in: Capsule()) .onLongPressGesture(minimumDuration: 0.5) { configuration.trigger() } } } ``` With a primitive style you lose the system's pressed state, cancellation when the finger slides off, and some accessibility behavior, so you rebuild those yourself. ### A custom button style with a spring press A good press style does three things: it responds instantly on touch down, it springs back on release, and it still looks disabled when the button is disabled. ```swift struct PressScaleStyle: ButtonStyle { @Environment(\.isEnabled) private var isEnabled @Environment(\.accessibilityReduceMotion) private var reduceMotion func makeBody(configuration: Configuration) -> some View { let pressed = configuration.isPressed configuration.label .font(.body.weight(.semibold)) .foregroundStyle(isEnabled ? Color.white : Color.secondary) .padding(.horizontal, 24) .frame(minHeight: 52) .background(isEnabled ? Color.black : Color.gray.opacity(0.2), in: Capsule()) .opacity(pressed ? 0.85 : 1) .scaleEffect(pressed && !reduceMotion ? 0.96 : 1) .animation( pressed ? .spring(duration: 0.12, bounce: 0) : .spring(duration: 0.35, bounce: 0.35), value: pressed ) } } extension ButtonStyle where Self == PressScaleStyle { static var pressScale: PressScaleStyle { PressScaleStyle() } } ``` Use it like a system style: `Button("Continue") { }.buttonStyle(.pressScale)`. The two springs are the important part. Pressing in uses a short, unbouncy spring so the button reacts under the finger with no lag. Releasing uses a longer spring with bounce, so it settles with a little life. One spring for both directions either feels sluggish on press or twitchy on release. This is the simple version. [Elastic Button](/docs/components/controls/elastic-button) is the full one: a `ButtonStyle` that squashes toward the touch point, stretches with rubber-band resistance when dragged, deepens after a hold, and snaps back with a spring. You apply it the same way: ```swift struct ElasticUsageExample: View { var body: some View { Button("Continue") {} .buttonStyle(.elastic(.signal)) } } ``` ### Haptics on press `sensoryFeedback` (iOS 17) plays a haptic when a value changes. Attach it to the button and trigger it from the action: ```swift struct AddToCartButton: View { @State private var adds = 0 var body: some View { Button("Add to cart") { adds += 1 } .buttonStyle(.pressScale) .sensoryFeedback(.impact(weight: .light), trigger: adds) } } ``` For feedback on touch down rather than on release, trigger from `isPressed` inside the style and return `nil` when you want silence: ```swift struct HapticPressStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .padding() .scaleEffect(configuration.isPressed ? 0.96 : 1) .animation(.snappy(duration: 0.2), value: configuration.isPressed) .sensoryFeedback(trigger: configuration.isPressed) { _, isPressed in isPressed ? .impact(flexibility: .soft, intensity: 0.6) : nil } } } ``` Keep press haptics light and reserve `.success`, `.warning`, and `.error` for outcomes. The [SwiftUI haptics guide](/docs/guides/swiftui-haptics) goes deeper. ### A loading button with success and error states Async buttons are where most apps feel unfinished: a spinner appears, nothing tells you it worked, and a double tap sends the request twice. Model the button as a small state machine instead of a pile of booleans. ```swift enum SubmitPhase: Equatable { case idle case loading case success case failure(String) } struct AsyncSubmitButton: View { let title: String let action: () async throws -> Void @State private var phase: SubmitPhase = .idle var body: some View { VStack(spacing: 8) { Button { Task { await run() } } label: { ZStack { switch phase { case .idle: Text(title) case .loading: ProgressView().tint(.white) case .success: Image(systemName: "checkmark") case .failure: Text("Try again") } } .transition(.blurReplace) .frame(maxWidth: .infinity) } .buttonStyle(.pressScale) .disabled(phase == .loading) .accessibilityLabel(accessibilityText) if case .failure(let message) = phase { Text(message) .font(.footnote) .foregroundStyle(.red) } } .sensoryFeedback(trigger: phase) { _, newPhase in switch newPhase { case .success: .success case .failure: .error default: nil } } } private var accessibilityText: String { switch phase { case .idle, .failure: title case .loading: "\(title), in progress" case .success: "\(title), done" } } private func run() async { guard phase != .loading else { return } withAnimation(.snappy) { phase = .loading } do { try await action() withAnimation(.bouncy) { phase = .success } try? await Task.sleep(for: .seconds(1.2)) withAnimation(.snappy) { phase = .idle } } catch { withAnimation(.snappy) { phase = .failure(error.localizedDescription) } } } } ``` What this gets right: - **One source of truth.** The label, disabled state, haptic, and VoiceOver text all derive from `phase`. - **No double submits.** The button is disabled while loading, and `run()` guards against re-entry. - **Success is visible.** The check holds for a moment before returning to idle. - **Errors are recoverable.** The failed state becomes the retry, with the message shown below. [Commit Button](/docs/components/controls/commit-button) is the designed version of this state machine. It uses one idle, loading, success, error, and disabled phase: the capsule collapses to a spinning ring, blooms into a success block with a drawn check, or re-expands as an error block that shakes and doubles as retry. Its API mirrors the pattern above: ```swift struct CommitUsageExample: View { @State private var phase: CommitButton.Phase = .idle var body: some View { CommitButton("Save changes", phase: $phase, successTitle: "Saved") { phase = .loading Task { try? await Task.sleep(for: .seconds(1.4)) phase = .success } } } } ``` For whole-screen results after an action, see the [loading states guide](/docs/guides/swiftui-loading-states). ### Hold to confirm for destructive actions A confirmation alert interrupts every user to protect the few who tapped by mistake. For deletes, transfers, and other irreversible actions, a press-and-hold button is often better: it is fast when intended and nearly impossible to trigger by accident. ```swift struct HoldToDeleteButton: View { var duration: Double = 1.2 let onConfirm: () -> Void @State private var progress: CGFloat = 0 @State private var isConfirmed = false var body: some View { Text(isConfirmed ? "Deleted" : "Hold to delete") .font(.body.weight(.semibold)) .frame(maxWidth: .infinity, minHeight: 56) .background(alignment: .leading) { GeometryReader { proxy in Rectangle() .fill(.red.opacity(0.35)) .frame(width: proxy.size.width * progress) } } .background(.red.opacity(0.12)) .clipShape(Capsule()) .onLongPressGesture(minimumDuration: duration) { progress = 1 isConfirmed = true onConfirm() } onPressingChanged: { isPressing in guard !isConfirmed else { return } if isPressing { withAnimation(.linear(duration: duration)) { progress = 1 } } else { withAnimation(.spring(duration: 0.4, bounce: 0.2)) { progress = 0 } } } .sensoryFeedback(.success, trigger: isConfirmed) { _, confirmed in confirmed } .accessibilityAddTraits(.isButton) .accessibilityHint("Double tap and hold to delete") } } ``` The fill runs on a linear animation because it represents time, and it rewinds with a spring on early release so the user sees the action was cancelled. [Hold to Confirm](/docs/components/controls/hold-to-confirm) adds milestone dots with rising haptics along the way, inverts the label as the fill passes under it, and settles into a confirmed block: ```swift struct HoldUsageExample: View { var body: some View { HoldToConfirm("Hold to delete", systemImage: "trash", committedTitle: "Deleted") {} } } ``` ### Hit targets Apple's Human Interface Guidelines ask for touch targets of at least 44 by 44 points. Icon buttons are the usual offenders, because a 17-point glyph produces a 17-point target. ```swift struct CloseButton: View { let action: () -> Void var body: some View { Button(action: action) { Image(systemName: "xmark") .font(.body.weight(.semibold)) .frame(width: 44, height: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel("Close") } } ``` `contentShape` makes the whole frame tappable, including transparent areas. Small toggles like a like or save button need the same treatment. [Reaction Toggle](/docs/components/feedback/reaction-toggle) is a good reference for a compact control that still presses in, floods with color, and sends out a halo on toggle. ### Common button mistakes **Using `onTapGesture` instead of `Button`.** A tap gesture on a view gives you no pressed state, no disabled state, no keyboard or Switch Control support, and no button trait for VoiceOver. If it performs an action, make it a `Button` and style it. **Styling inside the label.** Padding, backgrounds, and press effects written directly into the label have to be copied to every button. Move them into a `ButtonStyle` once, and the whole app stays consistent when you change it. **Animating the press with `withAnimation` in the action.** The action runs on release, so the button looks dead while the finger is down. Press feedback belongs in the style, driven by `configuration.isPressed`, which updates on touch down. **Forgetting the cancel path.** Users often start a press, change their mind, and slide off. `ButtonStyle` handles this for free: `isPressed` goes false and the action never fires. Custom gestures in a `PrimitiveButtonStyle` or on a plain view have to handle it themselves. **Hiding a failure.** A loading button that silently returns to idle after an error is worse than no loading state at all. Show the error next to the button and make the next tap a retry. **One animation for everything.** A primary action, a destructive action, and a small toggle deserve different weights. Give the primary action the fuller press, keep secondary buttons quieter, and let only confirmations bounce. ### Accessibility checklist - **Labels.** Icon-only buttons need `.accessibilityLabel`. Use a verb or a clear noun: "Close", "Share photo", not "X". - **State.** Loading and success states should change the label or value so VoiceOver users know what happened. - **Dynamic Type.** Use `minHeight` rather than a fixed `height`, and let text wrap. For custom sizes, `@ScaledMetric` scales padding and icon sizes with the user's text size. - **Reduce Motion.** Keep pressed feedback (opacity, color, a small scale) but drop travel, stretch, and bounce. The `PressScaleStyle` above does this. - **Disabled is a design state.** Read `@Environment(\.isEnabled)` in custom styles and draw a distinct disabled look instead of relying on opacity alone. - **Destructive actions.** Set `role: .destructive`, and for hold gestures make sure the VoiceOver path is clear. ### Menus that grow from a button When one button needs to expose several actions, reach for `Menu` first. For a floating action button that fans out, [Glass Action Menu](/docs/components/glass/glass-action-menu) long-presses open into a line or arc of actions you can slide across and release to fire, and its glass morphs on iOS 26. The [controls hub](/components/controls) collects every button-like piece in SwiftPieces, and the [SwiftUI animations guide](/docs/guides/swiftui-animations) covers the springs and transitions used throughout this page. ### Install a piece Each piece is one Swift file with no third-party dependencies: ```bash npx swiftpieces add ElasticButton npx swiftpieces add CommitButton npx swiftpieces add HoldToConfirm ``` See [installation](/docs/installation) for manual setup. ## SwiftUI Cards: Card Views, Swipe Stacks and Flip Cards URL: https://swiftpieces.com/docs/guides/swiftui-cards A SwiftUI card is a view with a rounded, continuous-corner background, a soft shadow that separates it from the surface, and usually one interaction: tap, press, swipe or flip. There is no built-in `Card` type. You compose it from a shape, a background and modifiers, which means the quality is entirely in the details. This guide builds the common card patterns from scratch: a basic card, a pressable card, a Tinder-style swipe stack, a 3D flip card, a device-tilt card, and parallax in a scroll view. Everything targets iOS 17. ### A basic card view Three details separate a card that looks native from one that looks like a web port: continuous corners, a layered shadow, and a hairline edge. ```swift struct CardView: View { let title: String let subtitle: String var body: some View { VStack(alignment: .leading, spacing: 6) { Text(title).font(.title3.weight(.semibold)) Text(subtitle).font(.subheadline).foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) .background(.background, in: .rect(cornerRadius: 24, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 24, style: .continuous) .strokeBorder(.primary.opacity(0.06)) } .shadow(color: .black.opacity(0.06), radius: 1, y: 1) .shadow(color: .black.opacity(0.08), radius: 16, y: 8) } } ``` **Continuous corners.** `style: .continuous` gives the squircle curve Apple uses for app icons and system sheets. The default circular style has a visible kink where the straight edge meets the arc. On a large card you can see it. **Layered shadows.** One big blurry shadow looks like a glow. Real objects cast a tight contact shadow plus a wide ambient one. Stacking two `.shadow` modifiers, a small radius with low offset and a large radius with a larger offset, reads as depth without haze. **Hairline edge.** In dark mode shadows nearly vanish. A `strokeBorder` at very low opacity keeps the card's edge visible on any background. `strokeBorder` insets the stroke, so it never spills outside the shape. Apply `.shadow` after `.background`, and make sure the background is opaque. A shadow on a translucent view is cast by every child, text included. ### A tappable, pressable card Wrap the card in a `Button` so it gets accessibility traits, keyboard support and correct hit testing for free. Then give it a press state with a custom `ButtonStyle`. ```swift struct PressableCardStyle: ButtonStyle { @Environment(\.accessibilityReduceMotion) private var reduceMotion func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed && !reduceMotion ? 0.97 : 1) .brightness(configuration.isPressed ? -0.02 : 0) .animation(.spring(duration: 0.3, bounce: 0.25), value: configuration.isPressed) } } struct PressableCardDemo: View { var body: some View { Button { // open detail } label: { CardView(title: "Morning run", subtitle: "5.2 km, 27 min") } .buttonStyle(PressableCardStyle()) .padding() } } ``` Keep the scale small. At 0.97 a full-width card visibly responds; at 0.9 it looks like it is falling away. The [SwiftUI buttons guide](/docs/guides/swiftui-buttons) covers press states in more depth. ### A swipeable card stack The swipe stack has four parts: a `ZStack` of cards, a `DragGesture` on the top card only, a rotation that swings from the bottom edge, and a release rule that decides between throwing the card and snapping it back. ```swift struct Profile: Identifiable { let id = UUID() let name: String let color: Color } struct SwipeStack: View { @State private var profiles: [Profile] = [ Profile(name: "Ada", color: .orange), Profile(name: "Grace", color: .blue), Profile(name: "Linus", color: .green) ] @State private var drag: CGSize = .zero private let threshold: CGFloat = 120 var body: some View { ZStack { ForEach(Array(profiles.enumerated()), id: \.element.id) { index, profile in let isTop = index == profiles.count - 1 card(profile) .offset(isTop ? drag : .zero) .rotationEffect(.degrees(isTop ? Double(drag.width / 20) : 0), anchor: .bottom) .scaleEffect(isTop ? 1 : 0.95) .gesture(isTop ? swipe : nil) .accessibilityAction(named: "Like") { remove(towards: 1) } .accessibilityAction(named: "Pass") { remove(towards: -1) } } } .padding(24) } private func card(_ profile: Profile) -> some View { RoundedRectangle(cornerRadius: 28, style: .continuous) .fill(profile.color.gradient) .overlay(alignment: .bottomLeading) { Text(profile.name) .font(.largeTitle.bold()) .foregroundStyle(.white) .padding(24) } .frame(height: 440) } private var swipe: some Gesture { DragGesture() .onChanged { drag = $0.translation } .onEnded { value in let projected = value.predictedEndTranslation.width if abs(projected) > threshold { remove(towards: projected > 0 ? 1 : -1) } else { withAnimation(.spring(duration: 0.4, bounce: 0.3)) { drag = .zero } } } } private func remove(towards direction: CGFloat) { guard !profiles.isEmpty else { return } withAnimation(.easeOut(duration: 0.25)) { drag = CGSize(width: direction * 600, height: drag.height) } completion: { profiles.removeLast() drag = .zero } } } ``` The details that matter: - **The top card is the last element.** In a `ZStack` later views draw on top, so `removeLast()` removes the visible card. - **Rotate from `.bottom`.** Rotating around the center makes the card spin in place. Anchoring at the bottom makes it swing like something held at its base, which is how a hand moves it. - **Use `predictedEndTranslation`, not `translation`.** It includes the gesture's velocity, so a short fast flick counts as a swipe while a slow drag that stops short springs back. This is the single biggest difference between a stack that feels right and one that feels sticky. - **Remove after the throw.** `withAnimation(_:completion:)` (iOS 17) lets the card finish leaving the screen before it is removed from the array. Production stacks need more: a badge that fades in with drag progress and locks at the threshold, the next card rising as the top one leaves, an up-swipe, a haptic at the threshold, and a way to trigger swipes from buttons. [Swipe Deck](/docs/components/cards/swipe-deck) does all of that, including a threshold tick through `sensoryFeedback`. The [SwiftUI haptics guide](/docs/guides/swiftui-haptics) explains that pattern. #### Accessibility for swipes A swipe is invisible to VoiceOver. Every direction must also exist as a named action, which is what the `accessibilityAction(named:)` lines above do. VoiceOver users reach them through the actions rotor. Without them, the stack is unusable for anyone who cannot perform the gesture. ### A 3D flip card A flip card rotates around the y axis with `rotation3DEffect`. Two problems catch everyone the first time: the back face renders mirrored, and both faces show during the turn. The fix for the mirror is to pre-rotate the back face 180 degrees, so the container's rotation cancels it out. The fix for the overlap is to choose the visible face from the current angle, on every frame of the animation. A view that conforms to `Animatable` gets exactly that. ```swift struct FlipFaces: View, Animatable { var angle: Double let front: Front let back: Back var animatableData: Double { get { angle } set { angle = newValue } } var body: some View { let showsBack = angle > 90 ZStack { front.opacity(showsBack ? 0 : 1) back .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) .opacity(showsBack ? 1 : 0) } .rotation3DEffect(.degrees(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.5) } } struct TwoSidedCard: View { @Binding var isFlipped: Bool @ViewBuilder var front: Front @ViewBuilder var back: Back var body: some View { FlipFaces(angle: isFlipped ? 180 : 0, front: front, back: back) .animation(.spring(duration: 0.6, bounce: 0.15), value: isFlipped) .contentShape(.rect) .onTapGesture { isFlipped.toggle() } .accessibilityElement(children: .ignore) .accessibilityLabel(isFlipped ? "Card back" : "Card front") .accessibilityAddTraits(.isButton) .accessibilityAction { isFlipped.toggle() } } } ``` If you animated `opacity` directly with `isFlipped`, SwiftUI would cross-fade the faces over the whole turn, and at 90 degrees you would see half of each. Because `FlipFaces` is `Animatable`, its `body` runs with the interpolated angle, and the swap happens exactly when the card is edge-on. The `perspective` value controls how strong the depth looks; lower values flatten it. In a real app, replace the fixed accessibility label with a description of the visible face's content. [Flip Card](/docs/components/cards/flip-card) goes further: you can drag sideways to scrub the rotation, release commits or snaps back by velocity, the card lifts toward you mid-turn, and each face shades as it turns edge-on. ### Device-tilt cards with Core Motion A card that tilts as you tilt the phone uses `CMMotionManager` device motion. Two rules matter more than the visual: filter the signal, and stop updates whenever the card is not on screen or the app is not active. Motion updates run the sensors continuously and cost battery. ```swift import CoreMotion @Observable final class TiltModel { var roll: Double = 0 var pitch: Double = 0 private let manager = CMMotionManager() private var reference: CMAttitude? func start() { guard manager.isDeviceMotionAvailable, !manager.isDeviceMotionActive else { return } reference = nil manager.deviceMotionUpdateInterval = 1.0 / 60.0 manager.startDeviceMotionUpdates(to: .main) { [weak self] data, _ in guard let self, let attitude = data?.attitude else { return } // Measure tilt relative to how the phone was held when updates began. if let reference = self.reference { attitude.multiply(byInverseOf: reference) } else { self.reference = attitude.copy() as? CMAttitude } // Low-pass filter so the card drifts instead of jittering. self.roll += (attitude.roll - self.roll) * 0.15 self.pitch += (attitude.pitch - self.pitch) * 0.15 } } func stop() { manager.stopDeviceMotionUpdates() } } struct TiltCard: View { @State private var tilt = TiltModel() @Environment(\.scenePhase) private var scenePhase @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { CardView(title: "Tilt me", subtitle: "Core Motion, filtered") .rotation3DEffect(.degrees(tilt.pitch * 20), axis: (x: 1, y: 0, z: 0), perspective: 0.5) .rotation3DEffect(.degrees(tilt.roll * 20), axis: (x: 0, y: 1, z: 0), perspective: 0.5) .padding() .onAppear { if !reduceMotion { tilt.start() } } .onDisappear { tilt.stop() } .onChange(of: scenePhase) { _, phase in if phase == .active && !reduceMotion { tilt.start() } else { tilt.stop() } } } } ``` Attitude values are in radians, so a small multiplier gives a few degrees of tilt. Relative attitude matters: without the reference, a phone held upright reads as a large pitch and the card sits permanently tipped back. Stop motion updates in `onDisappear` and when `scenePhase` leaves `.active`. A card in a scrolled-away tab that keeps the sensors running is a quiet battery drain that is hard to trace later. Under Reduce Motion, do not start updates at all. [Motion Card](/docs/components/cards/motion-card) follows these rules: an exponential filter, a fixed-light sheen and layered shadow that slide as it turns, a press that settles it flat with a light impact, and Core Motion that stops whenever the scene is not active. ### Parallax cards in a scroll view iOS 17 added `visualEffect`, which gives you a `GeometryProxy` for a view without a `GeometryReader` changing its layout. Read the view's frame in the `.scrollView` coordinate space and offset the inner content by a fraction of it. ```swift struct ParallaxFeed: View { let colors: [Color] = [.orange, .blue, .green, .pink, .purple] var body: some View { ScrollView { LazyVStack(spacing: 20) { ForEach(colors.indices, id: \.self) { index in RoundedRectangle(cornerRadius: 24, style: .continuous) .fill(colors[index].gradient) .overlay { Image(systemName: "mountain.2.fill") .font(.system(size: 72)) .foregroundStyle(.white.opacity(0.9)) .visualEffect { content, proxy in let minY = proxy.frame(in: .scrollView).minY return content.offset(y: -minY * 0.15) } } .frame(height: 220) .clipShape(.rect(cornerRadius: 24, style: .continuous)) } } .padding() } } } ``` With a photo instead of a symbol, make the image taller than the card by the maximum drift, so its edges never show through. [Parallax Card](/docs/components/cards/parallax-card) oversizes the image that way and moves a caption block at a slower rate still, so the card reads in three layers. For horizontal carousels, `scrollTransition` is simpler. It hands you a phase (`-1` leaving on one side, `0` centered, `1` on the other) and lets you scale, fade or rotate each card from it. ```swift struct CarouselCards: View { var body: some View { ScrollView(.horizontal) { LazyHStack(spacing: 16) { ForEach(0..<8) { index in RoundedRectangle(cornerRadius: 24, style: .continuous) .fill(Color(hue: Double(index) / 8, saturation: 0.5, brightness: 0.9)) .containerRelativeFrame(.horizontal, count: 1, spacing: 16) .frame(height: 300) .scrollTransition { content, phase in content .scaleEffect(phase.isIdentity ? 1 : 0.9) .opacity(phase.isIdentity ? 1 : 0.6) .rotation3DEffect(.degrees(phase.value * -20), axis: (x: 0, y: 1, z: 0)) } } } .scrollTargetLayout() } .scrollTargetBehavior(.viewAligned) .contentMargins(.horizontal, 32, for: .scrollContent) } } ``` [Depth Carousel](/docs/components/lists/depth-carousel) builds on this idea: paged cards recede in depth as they leave center, each page gets a phase value for its own inner parallax, and a scrubbable pill indicator tracks the page position. ### Accessibility checklist for cards - Tappable cards are `Button`s, or at least carry `.isButton` and an action. - Every gesture (swipe, flip, drag) has an `accessibilityAction` equivalent. - Card content reads as one element where it makes sense (`.accessibilityElement(children: .combine)`), so VoiceOver does not stop on every label. - Tilt, parallax and 3D rotation are reduced or removed under Reduce Motion. - Text on image cards sits on an opaque block or a scrim with enough contrast. ### Install a piece Browse every card in the [cards hub](/components/cards). Each is one Swift file using Apple frameworks only. From the folder that contains your `.xcodeproj`: ```bash npx swiftpieces add SwipeDeck FlipCard MotionCard ``` See [installation](/docs/installation) for copy and paste and the details of what the CLI adds. For motion beyond cards, the [SwiftUI animations guide](/docs/guides/swiftui-animations) is the next stop. ## SwiftUI Haptics: sensoryFeedback and Haptic Feedback URL: https://swiftpieces.com/docs/guides/swiftui-haptics SwiftUI haptics are declarative: you attach `.sensoryFeedback(_:trigger:)` to a view, and SwiftUI plays the feedback every time the trigger value changes. It shipped in iOS 17, and in SwiftUI code it replaces most uses of `UIImpactFeedbackGenerator` in SwiftUI code because there is no generator to create, prepare or keep alive. The API is small. The hard part is taste: picking the right kind, firing it at the exact moment an animation crosses a meaningful line, and staying quiet the rest of the time. This guide covers all three. ### The basic modifier `sensoryFeedback` watches an `Equatable` value. When the value changes, the feedback plays. You do not call anything imperatively, you change state. ```swift struct SaveButton: View { @State private var saves = 0 var body: some View { Button("Save") { // persist, then bump the trigger saves += 1 } .buttonStyle(.borderedProminent) .sensoryFeedback(.success, trigger: saves) } } ``` A counter is the most reliable trigger for one-shot events. A `Bool` works too, but it only fires when it flips, so setting `true` twice in a row plays once. If you want a haptic every time an event happens, increment an `Int`. ### Three variants of sensoryFeedback There are three overloads, and each solves a different problem. #### 1. Fixed feedback `.sensoryFeedback(.selection, trigger: value)` plays the same feedback on every change. Use it when every change means the same thing. #### 2. Condition closure Pass a closure that receives the old and new values and returns `Bool`. The feedback plays only when it returns `true`. This lets you stack two modifiers on one trigger: a light tick for ordinary steps and a firmer bump at the bounds. ```swift struct VolumeStepper: View { @State private var level = 5 var body: some View { Stepper("Level \(level)", value: $level, in: 0...10) .sensoryFeedback(.selection, trigger: level) { oldValue, newValue in newValue != 0 && newValue != 10 } .sensoryFeedback(.impact(weight: .heavy), trigger: level) { _, newValue in newValue == 0 || newValue == 10 } } } ``` #### 3. Feedback-returning closure The third form drops the first argument. The closure returns a `SensoryFeedback?`, so it can choose the kind per change, or return `nil` to stay silent. ```swift struct CounterView: View { @State private var count = 0 var body: some View { HStack(spacing: 24) { Button("Minus", systemImage: "minus") { count -= 1 } Text("\(count)").monospacedDigit().font(.title) Button("Plus", systemImage: "plus") { count += 1 } } .labelStyle(.iconOnly) .sensoryFeedback(trigger: count) { oldValue, newValue in newValue > oldValue ? .increase : .decrease } } } ``` [Scrub Stepper](/docs/components/inputs/scrub-stepper) uses exactly this shape: a selection tick while you scrub across the numeral, `.increase` or `.decrease` on taps, and a separate rigid impact at the bounds. ### The feedback kinds `SensoryFeedback` has a fixed set of cases. They are semantic, so pick by meaning, not by how strong you want it to feel. | Kind | Use it for | | --- | --- | | `.selection` | A value moved one step: picker rows, slider steps, scrubbing across items. The lightest tick. | | `.impact(weight:intensity:)` | A physical collision. Weight is `.light`, `.medium` or `.heavy`, intensity is 0 to 1. | | `.impact(flexibility:intensity:)` | The same, described by material: `.rigid`, `.solid` or `.soft`. Rigid feels like a hard detent, soft like a cushion. | | `.success`, `.warning`, `.error` | The outcome of a task. Reserve these for results, not for taps. | | `.increase`, `.decrease` | A value went up or down, such as a stepper. | | `.alignment` | Something snapped into alignment, such as a dragged item meeting a guide. | | `.levelChange` | Moved between discrete levels, like pressure stages. | | `.start`, `.stop` | An activity began or ended, such as a recording or timer. | Plain `.impact` with no arguments also exists. The actual pattern each kind plays is up to the system and the hardware, and some kinds (`.alignment`, `.levelChange`, `.start`, `.stop`) were designed with other Apple platforms in mind, so test on a real iPhone before relying on one to feel distinct. The Simulator plays nothing. #### A quick decision guide - The user is **choosing** among values: `.selection`. - Something **hit a wall** or **locked into place**: `.impact`, rigid or heavy. - Something **committed**: a single `.impact` at the moment of commit, then `.success` when the work actually finishes. - Something **failed**: `.error`, once, alongside a visible error state. ### Haptics at animation thresholds The best haptics mark a moment the eye is already watching: a card crossing the point where release will throw it, a knob landing on a detent, a hold reaching its end. The pattern is always the same. Derive a discrete value from continuous gesture state, and put the haptic on the discrete value. #### Tick when a drag crosses a threshold Drag offset changes every frame, so it is a terrible trigger. A `Bool` that says "past the threshold" changes twice per gesture at most. ```swift struct PullToArchive: View { @State private var offset: CGFloat = 0 @State private var isArmed = false private let threshold: CGFloat = 120 var body: some View { RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(isArmed ? Color.orange : Color.gray.opacity(0.2)) .frame(height: 80) .offset(x: offset) .gesture( DragGesture() .onChanged { value in offset = value.translation.width let armed = abs(offset) > threshold if armed != isArmed { withAnimation(.spring(duration: 0.25, bounce: 0.3)) { isArmed = armed } } } .onEnded { _ in withAnimation(.spring(duration: 0.4, bounce: 0.2)) { offset = 0 isArmed = false } } ) .sensoryFeedback(.impact(weight: .medium), trigger: isArmed) { _, armed in armed } .padding() } } ``` The condition closure plays the impact only when the row arms, not when it disarms. The color change and the haptic land on the same frame, which is what makes it feel physical. [Swipe Deck](/docs/components/cards/swipe-deck) does the same thing with a computed boolean, `lift >= 1`, so the tick fires when the outcome badge locks at the threshold, and a solid impact fires when the card is actually thrown. #### Detents on a continuous control For dials and sliders, quantize the value into steps and tick on each step, with a heavier bump on major marks. ```swift struct DetentSlider: View { @State private var value = 50.0 @State private var step = 50 @State private var detentCount = 0 var body: some View { Slider(value: $value, in: 0...100) .onChange(of: value) { _, newValue in let newStep = Int(newValue.rounded()) guard newStep != step else { return } step = newStep if newStep.isMultiple(of: 25) { detentCount += 1 } } .sensoryFeedback(.selection, trigger: step) { _, newStep in !newStep.isMultiple(of: 25) } .sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.8), trigger: detentCount) .padding() } } ``` This is the structure behind [Timer Dial](/docs/components/controls/timer-dial): a selection tick per step, a rigid impact every 5 seconds, and `.success` when the countdown finishes. [Range Slider](/docs/components/inputs/range-slider) ticks every step and switches to a firmer impact when a thumb hits a bound or the minimum gap. [Rating Scrub](/docs/components/feedback/rating-scrub) ticks between stars and uses a rigid impact at zero and at the maximum. #### Escalating feedback over time Hold interactions benefit from feedback that builds. [Hold to Confirm](/docs/components/controls/hold-to-confirm) passes three milestone dots while you hold, each with a soft impact of rising intensity, then plays `.success` on commit. The rising intensity tells the user "keep going" without any text. [Fan Stack](/docs/components/controls/fan-stack) uses the other end of the range: a soft impact as the avatars fan out, a selection tick each time your finger crosses a new face, and a solid impact on select. Keep the haptic and the visual on the same state change. If the haptic fires from `onChanged` but the animation runs from a separate timer, they drift apart by a frame or two and the interaction feels loose. ### UIKit feedback generators `sensoryFeedback` needs iOS 17. For older targets, or for code outside a view (a view model, a game loop, a UIKit controller), use the generators directly. ```swift struct LegacyHapticButton: View { private let generator = UIImpactFeedbackGenerator(style: .medium) var body: some View { Button("Tap") { generator.impactOccurred() } .onAppear { generator.prepare() } } } final class Haptics { static let notification = UINotificationFeedbackGenerator() static let selection = UISelectionFeedbackGenerator() static func success() { notification.notificationOccurred(.success) } static func tick() { selection.selectionChanged() } } ``` There are three generator classes: `UIImpactFeedbackGenerator` (styles `.light`, `.medium`, `.heavy`, `.soft`, `.rigid`, plus `impactOccurred(intensity:)`), `UISelectionFeedbackGenerator`, and `UINotificationFeedbackGenerator` (`.success`, `.warning`, `.error`). Call `prepare()` shortly before you expect to fire, for example when a drag begins, so the Taptic Engine is awake and the first tick is not late. In SwiftUI on iOS 17 and later, prefer the modifier. It ties feedback to state, which keeps it in sync with animations and makes it easy to test by reading the state. ### Core Haptics, briefly Core Haptics (`CHHapticEngine`) is for custom patterns: a textured rumble, a heartbeat, a sequence synced to audio. You describe events with intensity and sharpness and play them through an engine you keep alive. ```swift import CoreHaptics final class TapPlayer { private var engine: CHHapticEngine? init() { guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return } engine = try? CHHapticEngine() try? engine?.start() } func play(intensity: Float = 0.8, sharpness: Float = 0.4) { guard let engine else { return } let event = CHHapticEvent( eventType: .hapticTransient, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity), CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness) ], relativeTime: 0 ) do { let pattern = try CHHapticPattern(events: [event], parameters: []) try engine.makePlayer(with: pattern).start(atTime: CHHapticTimeImmediate) } catch { // Haptics are a garnish. Never fail the interaction over one. } } } ``` Keep the engine as a stored property. A local engine is released when the function returns and the pattern stops. For production use you also handle `stoppedHandler` and `resetHandler` to restart the engine after an interruption. Most interface work never needs Core Haptics. Reach for it only when none of the semantic kinds say what you mean. ### Restraint and accessibility Haptics are a shared, physical channel. Overuse numbs people to the ones that matter. - **One haptic per meaningful moment.** Not on every tap of a normal button (the system already gives feedback where it should), not on scroll, not on appear. - **Never tie a haptic to a per-frame value.** Quantize first, as in the detent example. - **Match intensity to consequence.** A selection tick for browsing, an impact for committing, a notification kind for results. - **Pair every haptic with a visual.** Some devices have no Taptic Engine, and some people turn System Haptics off in Settings. The haptic confirms, it never carries information alone. - **Respect the rest of the system.** Under Reduce Motion, a threshold still deserves its tick, but the animation around it should shorten. Haptics are often more important for VoiceOver users, so actions exposed through `accessibilityAction` or `accessibilityAdjustableAction` should play the same feedback as the gesture path. The system switch is handled for you: `sensoryFeedback` and the UIKit generators stay silent when the user has disabled System Haptics. ### Where the hard version already exists The patterns above cover most apps. The hard part is tuning, and every piece in the [haptics hub](/components/haptics) ships with it done: thresholds that match the visuals, bound impacts that differ from step ticks, and escalation that reads as progress. If you are also working on the motion side, [SwiftUI animations](/docs/guides/swiftui-animations) covers springs and phase animators, and [SwiftUI buttons](/docs/guides/swiftui-buttons) covers press states that pair with a tap haptic. ### Install a piece Each piece is one Swift file built on Apple frameworks only. Run the CLI from the folder that contains your `.xcodeproj`: ```bash npx swiftpieces add TimerDial HoldToConfirm ScrubStepper ``` See [installation](/docs/installation) for copy and paste and the details of what the CLI adds. ## SwiftUI Skeleton Loading, Shimmer and Loading States URL: https://swiftpieces.com/docs/guides/swiftui-loading-states SwiftUI skeleton loading uses `.redacted(reason: .placeholder)` to draw your real layout as gray shapes while data loads, then swaps in content when it arrives. Add a shimmer (a soft highlight sweeping across the shapes) and the screen reads as "loading" without a spinner, and without the layout jumping when content lands. Skeletons are one loading state among several. This guide covers the skeleton itself, a shimmer you can reuse, `ProgressView`, the handoff from placeholder to content, an action that morphs through loading, success and failure, infinite-scroll footers, and an AI "thinking" placeholder. Everything targets iOS 17 and respects Reduce Motion. ### Skeletons with redacted(reason:) `redacted(reason: .placeholder)` replaces text with rounded bars the size of the text, and tints images and shapes. You feed it placeholder data of a realistic length, so the skeleton has the same shape as the loaded screen. ```swift struct Article: Identifiable { let id: Int let title: String let author: String static let placeholder = Article(id: -1, title: "A headline of typical length", author: "Author name") } struct ArticleRow: View { let article: Article var body: some View { HStack(spacing: 12) { RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(.quaternary) .frame(width: 56, height: 56) VStack(alignment: .leading, spacing: 4) { Text(article.title).font(.headline) Text(article.author).font(.subheadline).foregroundStyle(.secondary) } Spacer() Image(systemName: "bookmark") .unredacted() } } } struct ArticleList: View { @State private var articles: [Article] = [] @State private var isLoading = true var body: some View { List { if isLoading { ForEach(0..<6, id: \.self) { _ in ArticleRow(article: .placeholder) } .redacted(reason: .placeholder) } else { ForEach(articles) { ArticleRow(article: $0) } } } .task { // articles = try await api.articles() isLoading = false } } } ``` `unredacted()` opts a subview out. Use it for chrome that does not depend on data: an icon, a section label, a button. Keeping those visible makes the skeleton feel like the real screen arriving rather than a generic gray block. Two things to know: - **Redaction is an environment value.** Custom views can read `@Environment(\.redactionReasons)` and draw their own placeholder, for example hiding a chart that would otherwise render with fake numbers. - **Placeholder text length is the layout.** A one-word placeholder title makes a short bar, and the row jumps when a two-line headline arrives. Pick placeholder strings that match typical content. ### A shimmer modifier A shimmer is a light gradient that moves across the placeholder, masked to its shapes. The simplest version animates an offset with `repeatForever`. ```swift struct Shimmer: ViewModifier { @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var phase: CGFloat = -1 func body(content: Content) -> some View { content .overlay { if !reduceMotion { GeometryReader { proxy in let width = proxy.size.width LinearGradient( colors: [.clear, .white.opacity(0.5), .clear], startPoint: .leading, endPoint: .trailing ) .frame(width: width * 0.6) .offset(x: phase * width * 1.6) .blendMode(.plusLighter) } .mask(content) .allowsHitTesting(false) } } .onAppear { withAnimation(.linear(duration: 1.4).repeatForever(autoreverses: false)) { phase = 1 } } } } extension View { func shimmer() -> some View { modifier(Shimmer()) } } ``` `.mask(content)` is the key line. The gradient only paints where the redacted content has pixels, so it sweeps across the bars and blocks, never across the gaps. #### A clock-driven shimmer with TimelineView `repeatForever` has a weakness: every row starts its own animation when it appears, so rows that scroll in later are out of phase with the rest. Driving the shimmer from `TimelineView` puts every instance on the same wall clock, and it can be switched off cleanly. ```swift struct TimelineShimmer: ViewModifier { var isActive = true var duration = 1.6 @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content.overlay { if isActive && !reduceMotion { TimelineView(.animation) { context in let t = context.date.timeIntervalSinceReferenceDate let progress = t.truncatingRemainder(dividingBy: duration) / duration GeometryReader { proxy in LinearGradient( colors: [.clear, .white.opacity(0.45), .clear], startPoint: .leading, endPoint: .trailing ) .frame(width: proxy.size.width * 0.5) // Travels from fully off the leading edge to fully off the trailing edge. .offset(x: (progress * 1.5 - 0.5) * proxy.size.width) } } .mask(content) .allowsHitTesting(false) } } } } ``` Because the phase comes from the date, not from per-view state, ten rows read as one surface with one highlight passing over it. [Skeleton Loader](/docs/components/feedback/skeleton-loader) uses the same idea: a self-masking modifier that turns any layout, color blocks included, into one quiet shape sweeping a diagonal highlight on a shared clock. ### ProgressView styles Skeletons suit content. For actions and unknown waits in small spaces, `ProgressView` is still the right tool. ```swift struct ProgressExamples: View { @State private var fraction = 0.4 var body: some View { VStack(spacing: 24) { ProgressView() ProgressView("Syncing") .controlSize(.large) ProgressView(value: fraction) { Text("Uploading") } currentValueLabel: { Text(fraction, format: .percent.precision(.fractionLength(0))) } // On iOS this draws a spinner and ignores the value. ProgressView(value: fraction) .progressViewStyle(.circular) .tint(.orange) ProgressView(value: fraction) .progressViewStyle(RingProgressStyle()) } .padding() } } struct RingProgressStyle: ProgressViewStyle { var lineWidth: CGFloat = 6 func makeBody(configuration: Configuration) -> some View { let fraction = configuration.fractionCompleted ?? 0 ZStack { Circle().stroke(.quaternary, lineWidth: lineWidth) Circle() .trim(from: 0, to: fraction) .stroke(.tint, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) .rotationEffect(.degrees(-90)) .animation(.smooth, value: fraction) } .frame(width: 44, height: 44) } } ``` A `ProgressView` without a value is indeterminate. With `value:` it is determinate, and `fractionCompleted` in a custom style is non-nil. On iOS, `.circular` with a value still draws the indeterminate spinner, so a determinate ring needs a custom `ProgressViewStyle` like the one above. A rough rule: under about a second, show nothing. For a known layout, show a skeleton. For an action the user started, show progress on the control they touched. For a long task with a known size, show a determinate bar. ### Handing off from skeleton to content The handoff is where most loading states feel cheap: gray bars vanish and content pops in. Keep the same view, flip the redaction, and animate the change. ```swift struct ProfileHeader: View { @State private var name: String? var body: some View { let isLoading = name == nil VStack(alignment: .leading, spacing: 8) { Text(name ?? "Placeholder name") .font(.title.bold()) Text("Joined 2024") .foregroundStyle(.secondary) } .redacted(reason: isLoading ? .placeholder : []) .modifier(TimelineShimmer(isActive: isLoading)) .animation(.easeOut(duration: 0.3), value: isLoading) .task { try? await Task.sleep(for: .seconds(1)) name = "Ada Lovelace" } } } ``` Because the view identity never changes, the layout does not jump. For lists, stagger the reveal by index so rows settle top to bottom. Skeleton Loader hands off this way: content unblurs and rises row by row, and its `.skeleton(isLoading:staggerIndex:)` modifier takes the row index to compute the delay. It also takes an `isFailed` flag for the case where loading ends in an error. ### Loading, success and failure in one control When the user starts an action, the button they tapped should become the loading state, then the result. Model it as one phase enum rather than a pile of booleans. ```swift struct SaveStatus: View { enum Phase: Equatable { case idle, saving, saved, failed } @State private var phase: Phase = .idle var body: some View { Button(action: save) { HStack(spacing: 8) { Image(systemName: symbol) .contentTransition(.symbolEffect(.replace)) Text(title) .contentTransition(.opacity) } .frame(minWidth: 140) } .buttonStyle(.borderedProminent) .tint(tint) .disabled(phase == .saving) .animation(.spring(duration: 0.35, bounce: 0.2), value: phase) .sensoryFeedback(.success, trigger: phase) { _, new in new == .saved } .sensoryFeedback(.error, trigger: phase) { _, new in new == .failed } } private var tint: Color { switch phase { case .saved: .green case .failed: .red default: .accentColor } } private var symbol: String { switch phase { case .idle: "square.and.arrow.down" case .saving: "arrow.triangle.2.circlepath" case .saved: "checkmark" case .failed: "exclamationmark.triangle" } } private var title: String { switch phase { case .idle: "Save" case .saving: "Saving" case .saved: "Saved" case .failed: "Try again" } } private func save() { phase = .saving Task { do { try await Task.sleep(for: .seconds(1)) // your async work phase = .saved } catch { phase = .failed } } } } ``` `contentTransition(.symbolEffect(.replace))` animates the symbol swap, and the failure state doubles as retry. Haptics fire on the result, not on the tap; the [SwiftUI haptics guide](/docs/guides/swiftui-haptics) explains why. The polished versions of this pattern are a single continuous shape rather than swapped icons. [Status Morph](/docs/components/feedback/status-morph) is one stroke that spins as a loading arc, closes into a ring, floods into a solid block and draws a check on success, or a cross with a nudge on failure. [Commit Button](/docs/components/controls/commit-button) applies the same idea to a button driven by one idle, loading, success, error and disabled phase, where the error state shakes and doubles as retry. When the result deserves a full screen, [Outcome Screen](/docs/components/feedback/outcome-screen) covers success, failure and empty outcomes with async retry. ### Infinite scroll footer states A paged list has four footer states, and each needs a distinct view: idle (nothing), loading (a spinner), failed (a message and a retry that keeps loaded rows), and end (a quiet "all caught up"). ```swift struct Item: Identifiable, Hashable { let id: Int let title: String } @Observable @MainActor final class Feed { enum Footer: Equatable { case idle, loading, failed(String), end } private(set) var items: [Item] = [] private(set) var footer: Footer = .idle private var nextPage = 0 func loadMoreIfNeeded(current item: Item?) async { guard footer == .idle else { return } if let item, items.suffix(5).contains(item) == false { return } await loadMore() } func loadMore() async { footer = .loading do { let page = try await fetch(page: nextPage) let known = Set(items.map(\.id)) items += page.filter { !known.contains($0.id) } nextPage += 1 footer = page.isEmpty ? .end : .idle } catch { footer = .failed("Couldn't load more") } } private func fetch(page: Int) async throws -> [Item] { try await Task.sleep(for: .milliseconds(600)) guard page < 5 else { return [] } return (0..<20).map { Item(id: page * 20 + $0, title: "Item \(page * 20 + $0)") } } } struct FeedView: View { @State private var feed = Feed() var body: some View { List { ForEach(feed.items) { item in Text(item.title) .task { await feed.loadMoreIfNeeded(current: item) } } footer .frame(maxWidth: .infinity) .listRowSeparator(.hidden) } .task { await feed.loadMoreIfNeeded(current: nil) } } @ViewBuilder private var footer: some View { switch feed.footer { case .idle: EmptyView() case .loading: ProgressView() case .failed(let message): HStack { Text(message).foregroundStyle(.secondary) Button("Retry") { Task { await feed.loadMore() } } .buttonStyle(.bordered) } case .end: Text("You're all caught up") .font(.footnote) .foregroundStyle(.secondary) } } } ``` The guard on `footer == .idle` prevents two loads at once, loading starts five rows before the end so the spinner is rarely seen, and duplicate ids are dropped when pages overlap. What this sketch leaves out is refresh: a response that arrives after a pull to refresh must be ignored, not appended. [Paged List](/docs/components/lists/paged-list) handles that, along with skeleton rows for the first page and pull to refresh that replaces rows only on success. ### An AI "thinking" placeholder A chat reply that has not started streaming needs a placeholder too. Three dots on a shared clock are the classic version. ```swift struct ThinkingDots: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { TimelineView(.animation(paused: reduceMotion)) { context in let t = context.date.timeIntervalSinceReferenceDate HStack(spacing: 6) { ForEach(0..<3, id: \.self) { index in let wave = reduceMotion ? 0.5 : (sin((t - Double(index) * 0.18) * 2 * .pi / 1.2) + 1) / 2 Circle() .fill(.secondary) .frame(width: 8, height: 8) .opacity(0.35 + 0.65 * wave) .offset(y: -4 * wave) } } } .accessibilityElement() .accessibilityLabel("Thinking") } } ``` A better placeholder is shaped like the reply it becomes, so the answer replaces it without a jump. [Thinking State](/docs/components/ai/thinking-state) offers three presentations on one clock (rising dots, a sheen over reply-shaped bars, or a label whose glyphs carry the sheen), an elapsed-seconds label, and a `.thinkingState()` modifier that sweeps any view. ### Reduce Motion Loading animations loop, which makes them the most tiring motion on screen for people sensitive to it. Every example above reads `accessibilityReduceMotion`: - **Shimmer:** drop the sweep entirely. The static redacted shapes still say "loading". - **Spinners and dots:** pause the clock (`TimelineView(.animation(paused:))`) or show a static state. - **Pulses:** replace with a fixed mid opacity. - **Handoffs:** swap blur-and-rise for a short cross-fade. - **Result morphs:** keep the color and symbol change, drop the shake. Reduce Motion does not mean no feedback. It means the state still changes clearly, just without looping or travelling motion. ### Install a piece All of these live in the [feedback hub](/components/feedback) and related categories. Each piece is one Swift file on Apple frameworks only. From the folder that contains your `.xcodeproj`: ```bash npx swiftpieces add SkeletonLoader StatusMorph PagedList ThinkingState ``` See [installation](/docs/installation) for copy and paste and the details of what the CLI adds.