morph_orbs

pub package CI license: MIT

Dotted 3D activity orbs for AI chat interfaces. Seven lifecycle states, seventeen geometries, and smooth dot-for-dot morphs between any of them. Pure Canvas circles: no shaders, no blur, no layers, no dependencies beyond Flutter.

All seventeen variants

MorphOrb(state: MorphOrbState.thinking, size: 48)

Why

  • Morphs, not swaps. Every geometry is a list of dots, so a state change lerps dot-for-dot into the next shape. Interrupt it mid-way and it morphs from wherever the dots are.
  • Lifecycle, not just "busy". idle → thinking → processing → generating and then success, error or stopped, each with its own settle.
  • Any size, any ink. Tuned at 20 / 32 / 64 px and interpolated between; follows the ambient text colour or takes any colour you give it; works over glass and gradients.
  • Cheap. One Ticker per orb, no per-frame widget rebuilds, reusable typed buffers, a few hundred drawCircle calls. The ticker stops once a terminal state has settled.

idle → thinking → processing → generating → success

Installation

dependencies:
  morph_orbs: ^0.2.0
import 'package:morph_orbs/morph_orbs.dart';

Basic usage

MorphOrb(
  state: MorphOrbState.thinking,
  size: 48,
)

Change state on rebuild and the orb morphs into the next geometry. The widget keeps one controller and one ticker for its whole life; nothing is torn down between states.

MorphOrb(
  state: isStreaming ? MorphOrbState.generating : MorphOrbState.thinking,
  size: 40,
)

States

State Motion Fits
idle A face-on dotted ring, breathing very slowly Ready, waiting for input
thinking Particles on tilted orbits over faint ghost paths; slow spin Reasoning, "Thinking…"
processing Latitude bands twist in quarter turns, scramble, click back Tool calls, lookups, code running
generating An undulating multi-band sash Streaming a reply
success Dots gather into a check and bloom once, then hold Reply finished
error A decaying shudder and scatter, then dots snap onto a cross Request failed
stopped Frozen, dimmed, slightly smaller ring User cancelled

success, error and stopped are terminal: they settle and then the ticker stops. The continuous states loop.

All seven states

thinking → error, thinking → stopped

Variants

A continuous state renders with its default geometry (above). Pass variant: to pick another; terminal states keep their glyphs.

MorphOrb(state: MorphOrbState.thinking, variant: MorphOrbVariant.swarm)
Variant Motion
orbits particles on tilted orbits over ghost paths
ring face-on ring, radius undulating
ribbon undulating multi-band sash
rubik bands twist in quarter turns, scramble, click back
globe scan meridian sweeps a dotted globe
wave waveform rolls through latitude rings
web constellation wires itself, packets on the edges
braid three strands plait around the sphere
morph dotted outline: circle → triangle → square
pulse concentric rings radiate from a beating core
swarm a flock skims an invisible sphere
helix double helix turns, rungs flicker
nebula soft noise cloud, dots breathing
vortex spiral arms sink into a funnel
echo sonar ring lights the dots it passes
constellate stars join into a figure, hold, let go
bloom dotted rose opens and closes its petals

Changing variant morphs exactly like a state change.

Morphing between variants

Variants grid

Transitions

Every state produces a list of dots. On a state change the controller builds both the outgoing and the incoming geometry each frame and lerps them dot-for-dot (position, depth, radius, ink), staggered so the change reads as a flow. Changing state again mid-transition morphs from where the dots are now. The default is 650 ms on Curves.easeInOutCubic.

idle → thinking → processing → generating → success
                  thinking → error
                  thinking → stopped

Animated logos and splash screens

Want an SVG logo to assemble itself out of dots on your splash screen? See morph_logo, built on this package's engine.

Building your own dotted animation? package:morph_orbs/engine.dart exposes the dot buffer and its morph (OrbFrame), the painter (paintOrbFrame), every orb geometry resolved for a size (resolveOrbState) and the math kit.

Customization

MorphOrb(
  state: state,
  size: 64,
  color: theme.textPrimary,       // ink; defaults to the ambient text colour
  speed: 1.0,                     // tempo multiplier
  intensity: 1.0,                 // deformation amplitude; 0 is a still shape
  semanticsLabel: 'Drafting…',    // replaces the per-state default
  style: MorphOrbStyle(
    successColor: theme.statusSuccess,
    errorColor: theme.statusError,
    backgroundColor: theme.bgSurface, // far dots fade toward this, not alpha
    transitionDuration: Duration(milliseconds: 650),
    transitionCurve: Curves.easeInOutCubic,
    density: 1.0,                   // dot count multiplier
    dotScale: 1.0,                  // dot radius multiplier
  ),
)
  • Ink: color wins over style.color, which wins over the ambient DefaultTextStyle colour. Depth shading fades the ink toward transparent, so the orb works over any surface, including glass. Set style.backgroundColor on a known solid surface for opaque occlusion.
  • Sizes: 20, 32 and 64 px are tuned points (count, dot radius, tempo); anything between is log-interpolated, anything beyond keeps the nearest tuning and scales the mark sub-linearly. 24 / 40 / 64 all read well.
  • Reduced motion: with MediaQuery.disableAnimations the orb draws one static representative frame, switches states instantly and never ticks.
  • Semantics: Semantics(image: true, label: …) with a per-state default; pass excludeFromSemantics: true when adjacent text already says it.

Example

example/ is a gallery: every state and variant, an interactive stage with a scripted reply flow, and the size row.

cd example && flutter run

Architecture

lib/
├── morph_orbs.dart                 public exports
└── src/
    ├── models/
    │   ├── morph_orb_state.dart    the seven states
    │   ├── morph_orb_style.dart    colours, transition, density, dot scale
    │   └── orb_frame.dart             reusable Float64List dot buffer, z-sort, blend
    ├── animations/
    │   ├── orb_geometry.dart          build(out, size, t, profile, intensity)
    │   ├── orb_profile.dart           typed density / radius tuning + scalers
    │   ├── orb_presets.dart           state → geometry, tuned sizes, resolver
    │   └── geometries/                orbits · rubik · ribbon (+ ring) · glyph
    ├── controllers/
    │   └── morph_orb_controller.dart  Ticker, clock, state machine, morph
    ├── painters/
    │   └── morph_orb_painter.dart  CustomPainter, repaint: controller
    ├── widgets/
    │   └── morph_orb.dart             the public widget
    └── utils/
        └── orb_math.dart              hash, noise, Fibonacci sphere, projector

Geometry is pure: (size, t, profile) → dots, no colour and no canvas. The controller owns time and transitions; the painter only draws what the controller hands it. The widget is a thin StatefulWidget that maps props onto the controller.

Performance

  • One Ticker per orb, created through the widget's TickerProvider, so TickerMode mutes it on inactive routes. It stops entirely once a terminal state has settled and never runs under reduced motion.
  • No setState per frame: the ticker notifies the CustomPainter through repaint: and only the orb's own RepaintBoundary repaints.
  • Dots live in reusable Float64List buffers (6 doubles per dot); a frame allocates nothing beyond the per-dot Color. Sorting reuses an index buffer.
  • Drawing is drawCircle (and drawLine for the few edge-based variants) with one reused Paint each: 100–600 marks per frame depending on size, trivial for Skia and Impeller. No saveLayer, no blur, no shaders, so there is no shader-compilation jank on first frame.
  • Transitions cost two geometry builds per frame for their duration only.

A FragmentShader was considered and rejected: the signature of the look is discrete depth-shaded dots, which drawCircle renders exactly; a shader would add compile cost and a platform-specific asset for no visual gain.

Testing

flutter test

Tests cover the dot buffer and morph, every state at every size, preset interpolation, the controller's state machine (transitions, interruption, ticker lifecycle, reduced motion, disposal) and the widget (rendering, semantics, ambient colour, controller reuse, disposal). The optional test/preview/render_preview_test.dart dumps PNG frames when ORB_PREVIEW_DIR is set, for eyeballing changes; tool/render_media.sh regenerates the GIFs and stills in doc/media/ (needs ffmpeg and Pillow).

Credits

The visual concept and several tuned geometries follow thinking-orbs by Jakub Antalik (MIT), re-implemented for Flutter and extended with lifecycle states, morphing transitions and new geometries. See LICENSE.

Libraries

engine
The dot engine behind morph_orbs, for packages that build their own dotted animations on top of it: the reusable OrbFrame buffer and its index-mapped morph, the shared painter, every orb geometry resolved for a size, and the small math kit the geometries use.
morph_orbs
Thinking Orbs: dotted 3D activity orbs for AI chat interfaces, with state-aware motion and smooth morphs between states.