Skip to content

Voice Orb

A glowing orb for a voice assistant whose colors swirl and that swells with the voice while it listens or speaks.

Voice Orb, playing
Download .swift
Origin
Original work
Found by
voice orb · assistant · audio reactive · siri · listening · speaking · ai · orbe de voz · asistente · reactivo al audio · siri · escuchando · hablando · ia

A glowing orb for a voice assistant: colors swirl inside it, and it swells with the voice while it listens or speaks.

Give it the level of the voice from 0 to 1, from your own audio code: the orb asks for no microphone. With Reduce Motion on, the colors stay still and the orb brightens instead of swelling.

struct VoiceOrbDemo: View {
@State private var state = VoiceOrbState.idle
@State private var level = 0.0
var body: some View {
VStack(spacing: 36) {
Spacer()
VoiceOrb(state: state, level: level)
.frame(width: 220, height: 220)
Text(caption)
.font(.title3.weight(.medium))
.foregroundStyle(.white.opacity(0.85))
.contentTransition(.opacity)
.animation(.easeInOut(duration: 0.3), value: state)
Spacer()
Picker("State", selection: $state) {
ForEach(VoiceOrbState.allCases, id: \.self) { state in
Text(state.rawValue)
}
}
.pickerStyle(.segmented)
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black.ignoresSafeArea())
.preferredColorScheme(.dark)
.task(id: state) {
// Stands in for the level a microphone or a speech synthesizer would give.
guard state == .listening || state == .speaking else {
level = 0
return
}
while !Task.isCancelled {
level = Double.random(in: state == .speaking ? 0.3...1 : 0.1...0.9)
try? await Task.sleep(for: .milliseconds(160))
}
}
}
private var caption: String {
switch state {
case .idle: "Tap a state to try it"
case .listening: "I’m listening…"
case .thinking: "Let me think…"
case .speaking: "Here’s what I found"
}
}
}
ParameterTypeDefaultDescription
stateNot writtenVoiceOrbState.idle
levelDouble0.0How loud the voice is, from 0 to 1.
colors[Color][.blue, .purple, .pink, .cyan]
labelString"Assistant"

Copy this code into your project, or download the file.

VoiceOrb.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
nonisolated enum VoiceOrbState: String, CaseIterable, Sendable {
case idle = "Idle"
case listening = "Listening"
case thinking = "Thinking"
case speaking = "Speaking"
}
nonisolated enum VoiceOrbMath {
/// How lively the orb is, from 0 to 1. At rest it barely moves, thinking keeps it halfway,
/// and while it listens or speaks it follows the level of the voice.
static func energy(state: VoiceOrbState, level: Double) -> Double {
let level = level.isFinite ? min(max(level, 0), 1) : 0
switch state {
case .idle: return 0.1
case .thinking: return 0.5
case .listening, .speaking: return 0.25 + 0.75 * level
}
}
/// How big the orb is drawn for an energy: it swells with the voice.
static func scale(energy: Double) -> Double {
0.86 + 0.14 * min(max(energy, 0), 1)
}
/// Where a blob of color is inside the orb, with the orb as a circle of radius 1 around zero.
/// The blobs circle its center, every other one the opposite way, and reach further out the
/// livelier the orb is.
static func blobCenter(index: Int, count: Int, time: Double, energy: Double) -> CGPoint {
let phase = Double(index) / Double(max(count, 1))
let turns = time * (index.isMultiple(of: 2) ? 0.3 : -0.2) + phase
let reach = 0.2 + 0.3 * min(max(energy, 0), 1)
return CGPoint(x: cos(turns * 2 * .pi) * reach, y: sin(turns * 2 * .pi) * reach)
}
}
/// A glowing orb for a voice assistant: colors swirl inside it, and it swells with the voice
/// while it listens or speaks.
///
/// Give it the level of the voice from 0 to 1, from your own audio code: the orb asks for no
/// microphone. With Reduce Motion on, the colors stay still and the orb brightens instead of
/// swelling.
struct VoiceOrb: View {
var state = VoiceOrbState.idle
/// How loud the voice is, from 0 to 1.
var level = 0.0
var colors: [Color] = [.blue, .purple, .pink, .cyan]
var label = "Assistant"
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
let energy = VoiceOrbMath.energy(state: state, level: level)
VoiceOrbSurface(energy: energy, colors: colors, isStill: reduceMotion)
.scaleEffect(reduceMotion ? 1 : VoiceOrbMath.scale(energy: energy))
.brightness(reduceMotion ? energy * 0.2 : 0)
.saturation(state == .idle ? 0.6 : 1)
.hueRotation(.degrees(state == .speaking ? 40 : 0))
.animation(.spring(duration: 0.35), value: energy)
.animation(.easeInOut(duration: 0.5), value: state)
.accessibilityElement()
.accessibilityLabel(label)
.accessibilityValue(state.rawValue)
}
}
/// The inside of the orb. Its energy is animatable, so the blobs glide to their new reach when the
/// state changes instead of jumping.
private struct VoiceOrbSurface: View, Animatable {
var energy: Double
let colors: [Color]
let isStill: Bool
nonisolated var animatableData: Double {
get { energy }
set { energy = newValue }
}
var body: some View {
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: isStill)) { timeline in
let time = isStill ? 0 : timeline.date.timeIntervalSinceReferenceDate
Canvas { context, size in
let radius = min(size.width, size.height) / 2
let center = CGPoint(x: size.width / 2, y: size.height / 2)
let orb = CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2)
context.clip(to: Path(ellipseIn: orb))
context.fill(Path(ellipseIn: orb), with: .color(colors.first ?? .blue))
var blurred = context
blurred.addFilter(.blur(radius: radius * 0.28))
for (index, color) in colors.enumerated() {
let unit = VoiceOrbMath.blobCenter(index: index, count: colors.count, time: time, energy: energy)
let blob = CGRect(x: center.x + unit.x * radius - radius * 0.6, y: center.y + unit.y * radius - radius * 0.6, width: radius * 1.2, height: radius * 1.2)
blurred.fill(Path(ellipseIn: blob), with: .color(color))
}
// A soft light from the top, so the orb reads as a sphere.
context.fill(Path(ellipseIn: orb), with: .radialGradient(
Gradient(colors: [.white.opacity(0.45), .clear]),
center: CGPoint(x: center.x - radius * 0.25, y: center.y - radius * 0.4),
startRadius: 0,
endRadius: radius * 1.1
))
}
}
.aspectRatio(1, contentMode: .fit)
.shadow(color: (colors.first ?? .blue).opacity(0.25 + 0.45 * energy), radius: 30)
}
}
// MARK: - Demo
struct VoiceOrbDemo: View {
@State private var state = VoiceOrbState.idle
@State private var level = 0.0
var body: some View {
VStack(spacing: 36) {
Spacer()
VoiceOrb(state: state, level: level)
.frame(width: 220, height: 220)
Text(caption)
.font(.title3.weight(.medium))
.foregroundStyle(.white.opacity(0.85))
.contentTransition(.opacity)
.animation(.easeInOut(duration: 0.3), value: state)
Spacer()
Picker("State", selection: $state) {
ForEach(VoiceOrbState.allCases, id: \.self) { state in
Text(state.rawValue)
}
}
.pickerStyle(.segmented)
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black.ignoresSafeArea())
.preferredColorScheme(.dark)
.task(id: state) {
// Stands in for the level a microphone or a speech synthesizer would give.
guard state == .listening || state == .speaking else {
level = 0
return
}
while !Task.isCancelled {
level = Double.random(in: state == .speaking ? 0.3...1 : 0.1...0.9)
try? await Task.sleep(for: .milliseconds(160))
}
}
}
private var caption: String {
switch state {
case .idle: "Tap a state to try it"
case .listening: "I’m listening…"
case .thinking: "Let me think…"
case .speaking: "Here’s what I found"
}
}
}
#Preview {
VoiceOrbDemo()
}