← Back
haplollc

haplollc/ThinkingOrbs

Dotted, honestly-3D loading indicators for AI and agent interfaces in SwiftUI. Nine hand-tuned designs, two tuned sizes.

View on GitHub ↗
aianimationiosloading-indicatorspinnerswiftswift-package-managerswiftui
Stars
243
Forks
19
Watchers
243
Open issues
0
Contributors
1
Language
Swift
License
MIT License
Default branch
main
Created Sep 19, 2026Updated Sep 19, 2026

Star growth

Today—
This week—
This month—

Star history will appear here once this repo has been tracked for a couple of days.

README

All nine ThinkingOrbs designs animating side by side

ThinkingOrbs

Dotted, genuinely 3D loading indicators for AI and agent interfaces in SwiftUI.
Nine hand-tuned designs, two purpose-tuned sizes, one line to drop in.

Swift 6 iOS 17+ macOS, tvOS, watchOS and visionOS Swift Package Manager MIT License


ThinkingOrb(.searching)

That is the whole integration. Eight of the nine designs are real 3D forms, rotated, depth-shaded and z-sorted, and the ninth is a morphing outline. All of them are drawn only in grayscale dots, so they sit quietly in any interface, light or dark. Every orb pauses itself offscreen, stays in phase with the others on screen, and respects Reduce Motion.

Download the 30-second demo video (MP4)

Installation

In Xcode: File > Add Package Dependencies and paste

https://github.com/haplollc/ThinkingOrbs

Or in Package.swift:

dependencies: [
    .package(url: "https://github.com/haplollc/ThinkingOrbs", from: "1.0.0")
]

Then add ThinkingOrbs to your target's dependencies.

Quick start

import SwiftUI
import ThinkingOrbs

struct AssistantStatus: View {
    var body: some View {
        ThinkingOrbLabel("Searching the web…", design: .searching)
    }
}

The designs

Every design is a case of OrbDesign. Pick the one that says what your agent is actually doing.

Regular Small Design In code Reach for it when
Working, regular Working, small Working
Particles on tilted orbits
.working General-purpose busy
Searching, regular Searching, small Searching
A scan meridian sweeps a dotted globe
.searching Web search, retrieval, lookups
Solving, regular Solving, small Solving
Bands scramble, then click back solved
.solving Reasoning, math, code
Listening, regular Listening, small Listening
A waveform rolls through the rings
.listening Voice input, transcription
Connecting, regular Connecting, small Connecting
A constellation wires itself
.connecting Tool calls, APIs, sync
Weaving, regular Weaving, small Weaving
Three strands plait around the sphere
.weaving Planning, multi-step agents
Composing, regular Composing, small Composing
An undulating multi-band sash
.composing Writing a reply
Breathing, regular Breathing, small Breathing
A ring slowly morphing
.breathing Idle thinking, waiting on a model
Shaping, regular Shaping, small Shaping
Circle → triangle → square
.shaping Design, image and layout work
ThinkingOrb(.connecting)

ForEach(OrbDesign.allCases) { design in      // all nine, with names
    Label { Text(design.title) } icon: { ThinkingOrb(design, size: .small) }
}

Sizes

Two tuned sizes, and they are separate designs, not one design scaled: the small one has fewer, bigger dots and its own tempo, so it stays legible next to text.

ThinkingOrb(.working)                  // .regular: 64 pt, for avatars, empty states, hero moments
ThinkingOrb(.working, size: .small)    // .small: 20 pt, for inline text, toolbars, list rows
ThinkingOrb(.solving, diameter: 56)    // the 64 pt design, drawn at 56 pt

Status labels

A Thinking pill and four status chips, their text shimmering

ThinkingOrbLabel puts an orb beside a shimmering status line. Style it like any other view:

ThinkingOrbLabel("Searching the web…", design: .searching)

ThinkingOrbLabel("Thinking…", design: .composing, size: .regular, diameter: 48)
    .font(.title3)
    .padding(8)
    .padding(.trailing, 20)
    .background(.thinMaterial, in: .capsule)

The shimmer is a modifier too, for any text that should read as live:

Text("Composing a reply…")
    .thinkingShimmer()

Titles localize the way Text does. A string literal is looked up in your strings, a String value is shown as-is, and you can pass your own Text:

ThinkingOrbLabel("Searching the web…", design: .searching)                   // localized, like Text
ThinkingOrbLabel("status.syncing", tableName: "Agent", design: .connecting)   // from your own table
ThinkingOrbLabel(modelOutput, design: .composing)                             // a String shows as-is

Recipes

Map your agent's state to a design. Wrap it in a ZStack so the old and new orb share one slot while they crossfade:

enum AgentPhase {
    case idle, searching, reasoning, callingTools, writing

    var orb: OrbDesign {
        switch self {
        case .idle: .breathing
        case .searching: .searching
        case .reasoning: .solving
        case .callingTools: .connecting
        case .writing: .composing
        }
    }
}

ZStack {
    ThinkingOrb(phase.orb)
        .id(phase.orb)
        .transition(.opacity.animation(.easeInOut(duration: 0.25)))
}

An "assistant is typing" bubble:

HStack(alignment: .bottom, spacing: 10) {
    ThinkingOrb(.composing, diameter: 36)
    Text("Writing a reply…")
        .thinkingShimmer()
        .padding(.horizontal, 14)
        .padding(.vertical, 10)
        .background(.fill.tertiary, in: .rect(cornerRadius: 18))
}

A status in the navigation bar:

.toolbar {
    ToolbarItem(placement: .principal) {
        ThinkingOrbLabel("Syncing…", design: .connecting)
            .font(.subheadline)
    }
}

A button that is busy:

Button {
    Task { await generate() }
} label: {
    if isGenerating {
        ThinkingOrbLabel("Generating…", design: .shaping)
    } else {
        Label("Generate", systemImage: "sparkles")
    }
}
.buttonStyle(.bordered)

An empty state while a model loads:

ContentUnavailableView {
    ThinkingOrb(.breathing)
} description: {
    Text("Warming up the model…")
}

A list row:

LabeledContent("Indexing photos") {
    ThinkingOrb(.searching, size: .small)
}

Appearance

The ink is strictly monochrome and follows the environment's color scheme: dark dots on light backgrounds, light dots on dark ones, with the depth shading mirrored so near dots always read strongest. To put an orb on a surface that doesn't match the app's scheme, pin it:

ThinkingOrb(.weaving)
    .environment(\.colorScheme, .dark)   // light dots, for a dark card in a light app

Speed and pausing

ThinkingOrb(.listening, speed: 1.5)                 // a multiplier on the tuned speed
ThinkingOrb(.listening, isPaused: !isRecording)     // freezes on the current frame

Accessibility

  • Each orb is an image with a per-design VoiceOver label ("Searching…"). The defaults are English, so in a localized app, or anywhere a more specific label helps, set your own as usual: .accessibilityLabel("Looking up flights…").
  • ThinkingOrbLabel reads as one element: its title.
  • With Reduce Motion on, orbs show a single representative frame and shimmering text holds still at full strength. With Increase Contrast on, shimmering text also stays at full strength.

Performance

One Canvas per orb inside one TimelineView, with no view per dot. Orbs park their timeline while scrolled out of view, on either axis, so a card in a carousel that has itself scrolled off the page stops too. That holds from the very first layout, so orbs that start below the fold never run. They also park while the app is in the background.

Measured on an iPhone 17 Pro Max, the heaviest design (.composing, 566 dots) takes 65 µs to compute a frame and 0.27 ms to rasterize it, against 8.3 ms per frame at 120 Hz. Most designs cost a fraction of that.

Draw it yourself

The geometry is public, for SpriteKit, Metal, a watch complication or a pen plotter. A frame is a finished draw list: every value is final and the array order is the draw order.

let frame = OrbDesign.connecting.frame(size: .regular, at: seconds)

for line in frame.lines {       // draw edges first
    // line.x1, line.y1, line.x2, line.y2, line.w in points
    // line.white and line.a are ink and opacity, as for dots
}
for dot in frame.dots {         // then dots, far to near
    // dot.x, dot.y, dot.r in points, in a 64 × 64 box
    // dot.white is ink (0 is darkest, mirror it on dark), dot.a is opacity
}

seconds is any running time in seconds at normal speed, such as a TimelineView date's timeIntervalSinceReferenceDate. The design's tuned speed is applied for you.

Faithful to the original

These are Jakub Antalik's designs, and the port keeps them exact:

  • Transcribed, not reinterpreted. The engine is a formula-by-formula port of the original TypeScript, down to evaluation order and JavaScript's rounding and loop semantics.
  • Golden vectors. The test suite checks every dot and line of all nine designs at both sizes, at four instants each, against the web library's published geometry, to 1e-4. It runs on iPhone.
  • A dense differential sweep. Scripts/differential-sweep.sh runs the original engine under Node and streams 725,149 frames (213 million dots) into the Swift engine on an iPhone simulator. Every frame matches, with a worst difference of 2e-9.
  • Pixels. Rendered side by side with Chrome's canvas, tiles differ by less than one shade in 255 on average, with the differences in the antialiased edges of the dots. The iPhone draws the very smallest dots at their true size, where Chrome draws them faintly.

Requirements

  • iOS 17+, macOS 14+, tvOS 17+, watchOS 10+, visionOS 1+
  • Swift 6, Xcode 16+

Development

xcodebuild test -scheme ThinkingOrbs -destination "platform=iOS Simulator,name=iPhone 17 Pro"
Scripts/differential-sweep.sh      # prove parity with the original web engine
Scripts/render-media.sh            # re-render every GIF in this README

Credits

The designs, their tuning and the original engine are thinking-orbs by Jakub Antalik, MIT licensed. See them on the web at orbs.jakubantalik.com. This package is a native Swift port.

License

ThinkingOrbs is available under the MIT license, which carries both the original copyright and ours.

Made by Haplo LLC.