Assistant Orb
A dark glass ball with a Siri-style voice wave inside, drawn by a Metal shader and locked to the thinking state.
Live preview
// Copyright (c) 2026 Saivion Hayes
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, and distribute the Software as part of
// an application, website, or product, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// Commons Clause Restriction
//
// You may use this Software, including for any commercial purpose, so long as
// you do not sell, sublicense, or redistribute the components themselves, whether
// alone, in a bundle, or as a ported version.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import SwiftUI
/// The assistant's mark while it thinks. Copy `AssistantOrb.metal` alongside this file; its `assistantOrbSiriWave` kernel compiles into your app's default Metal library.
///
/// - 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.
public struct AssistantOrb: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private let size: CGFloat
private let palette: Palette
public init(size: CGFloat = 160, palette: Palette = .siri) {
self.size = size
self.palette = palette
}
public var body: some View {
let motion = PieceMotion(reduceMotion: reduceMotion)
WaveSurface(palette: palette)
.frame(width: size, height: size)
// A spring, so a palette changed again mid-fade turns from the colours on screen. Under Reduce Motion it
// is a short colour fade, never a cut: colour is not movement.
.animation(motion.ambient, value: palette)
.allowsHitTesting(false)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Assistant")
.accessibilityValue("Thinking")
.accessibilityAddTraits(.updatesFrequently)
}
/// The orb's eight colours: four bands left to right in the stack, the ground wash inside the
/// glass, the crest and specular, and the cool and warm halves of the rim.
public struct Palette: Hashable, Sendable {
/// sRGB components 0…1, in kernel order: band0…3, ground, crest, rimCool, rimWarm.
public var rgb: [SIMD3<Float>]
public init(bands: [Color], ground: Color, crest: Color = .white, rimCool: Color, rimWarm: Color) {
let four = (0..<4).map { bands.isEmpty ? crest : bands[min($0, bands.count - 1)] }
rgb = (four + [ground, crest, rimCool, rimWarm]).map(Self.components)
}
/// Hex form, `0x2b5cff`.
public init(bands: [UInt32], ground: UInt32, crest: UInt32 = 0xffffff, rimCool: UInt32, rimWarm: UInt32) {
let four = (0..<4).map { bands.isEmpty ? crest : bands[min($0, bands.count - 1)] }
rgb = (four + [ground, crest, rimCool, rimWarm]).map(Self.components)
}
/// The Siri Wave colours: electric blue, hot pink, orange and red over deep violet.
public static let siri = Palette(bands: [0x2b5cff, 0xff4fa3, 0xff6a2b, 0xe3170a], ground: 0x12082e, rimCool: 0x4d8aff, rimWarm: 0xff4a1a)
private static func components(_ hex: UInt32) -> SIMD3<Float> {
SIMD3(Float((hex >> 16) & 0xff), Float((hex >> 8) & 0xff), Float(hex & 0xff)) / 255
}
private static func components(_ color: Color) -> SIMD3<Float> {
let r = color.resolve(in: EnvironmentValues())
return SIMD3(r.red, r.green, r.blue)
}
}
/// The 24 colour components as one animatable vector, so a palette change crossfades.
nonisolated private struct WaveColors: VectorArithmetic {
var v: [Double]
static var zero: Self { Self(v: Array(repeating: 0, count: 24)) }
init(v: [Double]) { self.v = v }
init(_ palette: Palette) { v = palette.rgb.flatMap { [Double($0.x), Double($0.y), Double($0.z)] } }
static func + (a: Self, b: Self) -> Self { Self(v: zip(a.v, b.v).map(+)) }
static func - (a: Self, b: Self) -> Self { Self(v: zip(a.v, b.v).map(-)) }
mutating func scale(by rhs: Double) { v = v.map { $0 * rhs } }
var magnitudeSquared: Double { v.reduce(0) { $0 + $1 * $1 } }
func argument(_ i: Int) -> Shader.Argument {
.float3(Float(v[i * 3]), Float(v[i * 3 + 1]), Float(v[i * 3 + 2]))
}
}
/// The shader surface. The clock is relative to appearance because
/// `timeIntervalSinceReferenceDate` overflows `Float` precision and freezes the kernel.
/// Only the wave moves: no scale, offset or pulse is ever applied to the sphere.
private struct WaveSurface: View, Animatable {
var colors: WaveColors
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var start = Date()
init(palette: Palette) { colors = WaveColors(palette) }
nonisolated var animatableData: WaveColors {
get { colors }
set { colors = newValue }
}
var body: some View {
let c = colors
let flows = PieceMotion(reduceMotion: reduceMotion).allowsAmbient
TimelineView(.animation(paused: !flows)) { context in
// Under Reduce Motion the wave rests on the pose it starts from: a fixed frame, so a palette fade
// recolours it without moving it.
let t = flows ? Float(context.date.timeIntervalSince(start)) : 0
Circle()
.fill(.black)
.colorEffect(ShaderLibrary.assistantOrbSiriWave(
.boundingRect, .float(t),
c.argument(0), c.argument(1), c.argument(2), c.argument(3),
c.argument(4), c.argument(5), c.argument(6), c.argument(7)
))
}
.aspectRatio(1, contentMode: .fit)
// The wave starts from its rest pose each time the orb appears, and turning Reduce Motion off flows on
// from that same pose, so it never jumps.
.onAppear { start = .now }
.onChange(of: reduceMotion) { start = .now }
}
}
}
#Preview {
VStack(spacing: 20) {
AssistantOrb(size: 180, palette: .siri)
Text("Thinking")
.font(.subheadline)
.foregroundStyle(.secondary)
}
// One weight for every string, as in the rest of the library.
.fontWeight(.semibold)
.padding(32)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.black)
}
// MARK: - Piece motion
//
// The SwiftPieces motion language: shared spring tokens and gesture physics, with the same values in every
// piece. Each piece carries a copy of only the parts it uses, so this file stands alone. Generated from
// registry/foundation/PieceMotion.swift in the SwiftPieces repo; edit it there, not here.
// swiftpieces-motion: 1.1.0 (core)
/// The SwiftPieces motion language: five spring tiers, and a named role for every moment a piece moves,
/// each with its Reduce Motion substitute.
///
/// Build one from the environment, `PieceMotion(reduceMotion: reduceMotion)`, and pick the role that names
/// what just happened. Never animate the tracking of a finger: set gesture state directly in `onChanged`, so the
/// surface stays under the finger, and spring only the release.
private struct PieceMotion {
var reduceMotion = false
// Tiers. Overshoot and settle times are measured from rest with SwiftUI's Spring.
/// No overshoot, 90% in about 90ms. A press arriving under the finger.
static var tight: Spring { Spring(duration: 0.14, bounce: 0) }
/// About 2.8% overshoot, 90% in about 140ms. Snapping to a detent, page or segment.
static var responsive: Spring { Spring(duration: 0.32, bounce: 0.25) }
/// About 8.4% overshoot. Visible give: a release, a return from past an edge, a drag settling home.
static var elastic: Spring { Spring(duration: 0.42, bounce: 0.38) }
/// About 15% overshoot. A resolved action landing. At most once per interaction.
static var expressive: Spring { Spring(duration: 0.48, bounce: 0.48) }
/// About 1.5% overshoot, unhurried. Opening large surfaces and ambient change.
static var calm: Spring { Spring(duration: 0.5, bounce: 0.2) }
/// Reduce Motion: settles become this short spring with no overshoot.
private static var still: Animation { .spring(duration: 0.25, bounce: 0) }
// Roles.
/// Touch-down. Starts on the same frame and never bounces under the finger.
var press: Animation { .spring(Self.tight) }
/// The finger lifts off a pressed surface, which springs back through rest.
var release: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
/// A dragged thing comes to rest. With a gesture's velocity, use `settle(velocity:from:to:)` instead.
var settle: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
/// Lands on a detent, page or segment.
var snap: Animation { reduceMotion ? Self.still : .spring(Self.responsive) }
/// A number, a chart value or anything else people read moves to its new value. Never overshoots, so it
/// never shows a value that isn't true.
var value: Animation { reduceMotion ? Self.still : .spring(duration: 0.35, bounce: 0) }
/// Comes back from past a limit: a pull beyond the edge, a value pushed against its bound.
var rebound: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
/// A shape or container changes size, corner radius or form, with a little give at the end.
var morph: Animation { reduceMotion ? Self.still : .spring(duration: 0.4, bounce: 0.2) }
/// Something appears, opens or expands. Opening is a little slower than closing.
var reveal: Animation { reduceMotion ? .easeOut(duration: 0.2) : .spring(Self.calm) }
/// Something leaves, closes or collapses. Quick and firm, out of the way.
var dismiss: Animation { reduceMotion ? .easeIn(duration: 0.18) : .spring(duration: 0.3, bounce: 0.08) }
/// A resolved action lands: a check, a sent state, a reaction.
var success: Animation { reduceMotion ? .easeOut(duration: 0.24) : .spring(Self.expressive) }
/// A refused action. Firm, no wobble; `pieceShake` adds the movement.
var error: Animation { reduceMotion ? .easeOut(duration: 0.2) : .spring(Self.responsive) }
/// Slow ambient change. Loops themselves stop under Reduce Motion: check `allowsAmbient`.
var ambient: Animation { reduceMotion ? .easeInOut(duration: 0.3) : .spring(Self.calm) }
/// Loops, drifts, idle breathing and parallax run only when this is true.
var allowsAmbient: Bool { !reduceMotion }
/// A moving transition, or a plain fade under Reduce Motion.
func transition(_ transition: AnyTransition) -> AnyTransition {
reduceMotion ? .opacity : transition
}
/// For content replaced inside a container that stays put (a label, a glyph, a count): the old content
/// blurs out as the new one sharpens in. A fade under Reduce Motion.
@MainActor var swap: AnyTransition {
reduceMotion ? .opacity : AnyTransition(.blurReplace)
}
}
// swiftpieces-motion: end- Type
- AssistantOrb
- Files
- AssistantOrb.swift, AssistantOrb.metal
- Depends on
- Nothing (Apple frameworks only)
- Version
- 1.2.0
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.metalin the same target. The[[ stitchable ]] assistantOrbSiriWavefunction is looked up throughShaderLibraryat runtime. Its helpers live in theassistantOrbSirinamespace, 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:
palettedefaults to.siri, andAssistantOrb.Palette(bands:ground:crest:rimCool:rimWarm:)takes hex values orColors 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.
sizesets 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
Floatprecision 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.
Usage
VStack(spacing: 20) {
AssistantOrb(size: 180, palette: .siri)
Text("Thinking")
.font(.subheadline)
.foregroundStyle(.secondary)
}
// One weight for every string, as in the rest of the library.
.fontWeight(.semibold)
.padding(32)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.black)Parameters
| Parameter | Description |
|---|---|
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. |
Install
Pick one. Run it from the folder that contains your .xcodeproj and the files land inside your app.
Terminal
Or ask your coding agent · Claude Code, Cursor or Xcode, once the MCP server is connected
Or copy the source above into your app.
New to SwiftUI?If you know React, CSS or Tailwind, SwiftUI is easier to pick up than it looks.Try real app patterns first
Building a whole app? See SwiftPieces Pro →
More SwiftUI ai
All ai →Build it yourself: SwiftUI loading states guide · SwiftUI animations guide