ai_wave_animations
ineelakandan.in · Source · Issues

The example app: every style driven by one WaveController — switch activity,
drag the level, hit pause.
Assistant-style wave animations for Flutter — the kind of motion you see in Gemini, Claude, Perplexity and ChatGPT voice UIs.
Fourteen ready-made looks, every knob exposed, one optional controller to
drive them all. Pure CustomPaint, no plugins, no native code: the same
widget runs on Android, iOS, web, Windows, macOS and Linux.
Assistant flavours
| Widget | Look | Best for |
|---|---|---|
GeminiWave |
Layered multi-hue sine ribbons with a bloom | Hero banners, thinking state |
ClaudeWave |
Slow warm overlapping surfaces | Calm idle, backgrounds |
PerplexityWave |
Rounded travelling equaliser bars | Live microphone level |
ChatGptWave |
Breathing orb with a rippling silhouette | Voice mode, compact avatars |
![]() |
![]() |
GeminiWave |
ClaudeWave |
![]() |
![]() |
PerplexityWave |
ChatGptWave |
Frequency visualisers
Ten more looks, each built around a different idea of what a "frequency" is — rings per second, cycles around a circle, twists across the width, revolutions of a radar sweep.
| Widget | Look | Frequency means |
|---|---|---|
PulseRingWave |
Rings expanding from a pulsing core | Rings emitted per second |
RadialBarsWave |
Circular equaliser around an open centre | Cycles around the circle |
OscilloscopeWave |
AM trace on a graticule with phosphor ghosts | Carrier cycles across width |
SpiralWave |
Wave-modulated vortex arms | Cycles along one arm |
LiquidWave |
Gooey metaball blob with a wet highlight | Attractor orbit rate |
HelixWave |
Rotating double helix with depth-sorted strands | Twists across the width |
ParticleWave |
Sparks riding a wave field, each with a trail | Field cycles across width |
GridWave |
Synthwave terrain rushing at the viewer | Terrain cycles across width |
AuroraWave |
Light curtains drifting past each other | Curtain folds across width |
SonarWave |
Radar sweep with contacts that flare and fade | Revolutions per second |
![]() |
![]() |
![]() |
PulseRingWave |
RadialBarsWave |
SpiralWave |
![]() |
![]() |
![]() |
LiquidWave |
SonarWave |
GridWave |
![]() |
![]() |
![]() |
OscilloscopeWave |
HelixWave |
ParticleWave |
![]() |
||
AuroraWave |
Still frames; regenerate them with
flutter test tool/render_previews.dart (and
flutter test tool/render_play_buttons.dart --update-goldens for the button
strip).
Install
dependencies:
ai_wave_animations: ^0.2.0
import 'package:ai_wave_animations/ai_wave_animations.dart';
Quick start
GeminiWave(height: 96)
That is the whole thing. Everything below is optional.
Driving the animation
Three ways, from simplest to most dynamic.
1. A static state. Amplitude and speed follow the conversation phase:
ClaudeWave(activity: WaveActivity.thinking)
WaveActivity |
Intensity | Speed | Reacts to level |
|---|---|---|---|
idle |
0.22 | 0.45× | no |
listening |
0.70 | 0.85× | yes |
thinking |
0.85 | 1.35× | no |
speaking |
1.00 | 1.00× | yes |
2. A fixed level. Useful for previews and tests:
PerplexityWave(activity: WaveActivity.listening, level: 0.8)
3. A controller. Feed it live audio and switch states at runtime. One controller can drive any number of widgets in sync:
final controller = WaveController(activity: WaveActivity.listening);
micStream.listen((rms) => controller.level = rms); // 0.0 .. 1.0
PerplexityWave(controller: controller);
ChatGptWave(controller: controller);
// later
controller.setActivity(WaveActivity.speaking);
controller.pause();
level is clamped, NaN-guarded and exponentially smoothed inside the widget,
so raw noisy microphone data is safe to push straight in at any rate.
Switching looks at runtime
AiWave(
style: AiWaveStyle.perplexity, // any of the fourteen
height: 72,
controller: controller,
)
AiWave exposes only the parameters every style shares; anything left null
keeps that style's own default. Use the dedicated widget when you need its
style-specific knobs.
Inside a play button
WavePlayButton wraps any style in a round, tappable button with a play
glyph that morphs into a pause glyph — and stops the wave with it.

WavePlayButton(
style: AiWaveStyle.radialBars,
size: 132,
controller: controller, // optional: tap toggles controller.isAnimating
)
| Parameter | Default | Meaning |
|---|---|---|
style |
AiWaveStyle.radialBars |
Which wave fills the button |
size |
128.0 |
Diameter in logical pixels |
controller |
null |
Tap toggles it; the glyph follows it |
isPlaying |
null |
Drives the state from outside instead |
initiallyPlaying |
true |
Starting state when the button owns it |
onPressed |
null |
Called on tap, after the state flips |
onPlayingChanged |
null |
Called with the new state on every tap |
iconColor |
colors.first |
Glyph colour |
iconSize |
0.3 |
Glyph size as a fraction of size |
borderWidth |
0.0 |
Rim width; 0 hides it |
wavePadding |
zero |
Inset between the rim and the wave |
morphDuration |
320ms |
Play → pause morph length |
pressScale |
0.93 |
How far it shrinks while held |
The circular styles (radialBars, pulseRing, sonar, spiral, liquid)
are the ones designed for this; the rest work too, just cropped to the circle.
Parameters
Shared by every widget
| Parameter | Type | Default | Meaning |
|---|---|---|---|
width / height |
double? |
per style | null stretches to the parent constraint |
colors |
List<Color> |
brand palette | Any length; sampled or swept as the style requires |
speed |
double |
1.0 |
Time multiplier; 0 freezes |
paused |
bool |
false |
Stops the ticker, keeps the last frame |
controller |
WaveController? |
null |
External driver; overrides activity and level |
activity |
WaveActivity? |
idle |
Conversation phase |
level |
double? |
null |
Static amplitude drive, 0..1 |
backgroundColor |
Color? |
null |
Solid backdrop |
backgroundGradient |
Gradient? |
null |
Gradient backdrop; wins over backgroundColor |
borderRadius |
BorderRadiusGeometry? |
null |
Rounds the backdrop |
padding |
EdgeInsetsGeometry |
zero |
Inset between box and wave |
clipBehavior |
Clip |
per style | Applied when a backdrop or radius is set |
respectReducedMotion |
bool |
true |
Freezes to a still frame when the OS asks |
semanticLabel |
String? |
'Animated wave' |
null hides it from screen readers |
child |
Widget? |
null |
Stacked on top of the wave |
GeminiWave
| Parameter | Default | Meaning |
|---|---|---|
waveCount |
4 |
Stacked ribbons |
amplitude |
0.55 |
Peak as a fraction of the available half-height |
frequency |
1.4 |
Sine cycles across the width |
strokeWidth |
3.0 |
Front ribbon width; back layers get thinner |
phaseShift |
0.12 |
Phase offset per layer, in turns |
taper |
1.0 |
Edge-pin exponent; 0 disables the taper |
glow |
0.45 |
Bloom strength 0..1; 0 skips the blurred pass |
sweepSpeed |
0.12 |
Gradient scroll, turns per second (negative reverses) |
harmonics |
3 |
Sine harmonics summed per ribbon; 1 is a pure sine |
turbulence |
0.06 |
Organic noise 0..1 |
minOpacity |
0.35 |
Opacity of the furthest back ribbon |
mirror |
true |
Flips every other layer for a woven look |
verticalAlignment |
0.0 |
-1 top, 0 centre, 1 bottom |
strokeCap |
StrokeCap.round |
Ribbon end cap |
blendMode |
BlendMode.srcOver |
BlendMode.plus gives a neon look on dark backgrounds |
resolution |
3.0 |
Logical pixels between samples; lower is smoother, costlier |
ClaudeWave
| Parameter | Default | Meaning |
|---|---|---|
waveCount |
3 |
Stacked surfaces |
amplitude |
0.12 |
Crest height as a fraction of the widget height |
frequency |
1.1 |
Sine cycles across the width |
waterLevel |
0.55 |
Fill height from the bottom, 0..1 |
style |
ClaudeWaveStyle.fill |
fill, stroke or fillAndStroke |
strokeWidth |
2.0 |
Crest line width |
phaseShift |
0.18 |
Phase offset per surface, in turns |
layerOffset |
0.045 |
Vertical stagger per surface, fraction of height |
minOpacity |
0.25 |
Back surface opacity |
maxOpacity |
0.9 |
Front surface opacity |
fadeToBottom |
true |
Fades each fill downwards instead of a flat colour |
alternateDirection |
true |
Sends every other surface the other way |
turbulence |
0.05 |
Organic noise 0..1 |
taper |
0.0 |
0 full width, 1 pinned to both edges |
resolution |
3.0 |
Logical pixels between samples |
PerplexityWave
| Parameter | Default | Meaning |
|---|---|---|
barCount |
24 |
Number of bars |
barWidth |
null |
Fixed width; null divides evenly |
barSpacing |
5.0 |
Gap between bars |
barRadius |
null |
Corner radius; null gives pills |
minBarHeight |
0.08 |
Shortest bar, fraction of height |
maxBarHeight |
1.0 |
Tallest bar, fraction of height |
frequency |
1.6 |
Wave cycles across the row |
alignment |
PerplexityBarAlignment.center |
center, bottom or top |
gradientAcrossBars |
true |
One scrolling gradient vs per-bar colours |
sweepSpeed |
0.1 |
Gradient scroll, turns per second |
turbulence |
0.12 |
Per-bar noise 0..1 |
taperEdges |
true |
Shrinks bars near both ends |
glow |
0.0 |
Bloom strength 0..1 |
opacity |
1.0 |
Overall opacity multiplier |
ChatGptWave
| Parameter | Default | Meaning |
|---|---|---|
radiusFactor |
0.72 |
Radius as a fraction of half the shortest side |
amplitude |
0.10 |
Silhouette ripple depth, fraction of the radius |
lobes |
3 |
Bumps around the circumference |
layers |
3 |
Concentric blobs |
rotationSpeed |
0.25 |
Ripple drift, turns per second (negative flips) |
breathing |
0.05 |
Depth of the slow scale pulse |
turbulence |
0.18 |
Organic noise 0..1 |
glow |
0.5 |
Bloom strength 0..1 |
ringWidth |
0.0 |
Halo ring width; 0 hides it |
ringColor |
null |
Defaults to colors.first |
ringGap |
10.0 |
Distance from the orb edge to the ring |
opacity |
1.0 |
Overall opacity multiplier |
segments |
96 |
Points around the circumference |
center |
Alignment.center |
Where the orb sits in the box |
PulseRingWave
| Parameter | Default | Meaning |
|---|---|---|
ringCount |
4 |
Rings in flight at once |
frequency |
1.0 |
Rings emitted per second (2.0 is 120 bpm) |
coreRadius |
0.28 |
Pulsing core radius, fraction of the half-size |
startRadius |
0.18 |
Radius a ring is born at |
strokeWidth |
2.5 |
Width of a fresh ring; rings thin as they travel |
wobble |
0.05 |
How far rings deform from a circle |
lobes |
3 |
Lobes in that deformation |
easing |
0.75 |
>1 shoots out and coasts, <1 accelerates |
glow |
0.5 |
Bloom strength 0..1 |
turbulence |
0.08 |
Organic noise 0..1 |
center |
Alignment.center |
Where rings are emitted from |
segments |
72 |
Samples per ring when wobble > 0 |
RadialBarsWave
| Parameter | Default | Meaning |
|---|---|---|
barCount |
56 |
Bars around the full circle |
innerRadius |
0.52 |
Radius the bars stand on; the centre stays free |
barLength |
1.0 |
Length of a peaking bar |
minBarLength |
0.12 |
Length of a resting bar |
barWidth |
3.0 |
Bar thickness |
frequency |
3.0 |
Cycles around the circle; integers meet cleanly |
harmonics |
3 |
Sines summed per bar; 1 is pure |
rotationSpeed |
0.04 |
Ring rotation, turns per second |
mirror |
true |
Folds the pattern like a real analyser |
startAngle |
-π/2 |
Angle of the first bar (12 o'clock) |
ringWidth |
1.0 |
Ring the bars stand on; 0 hides it |
glow |
0.4 |
Bloom strength 0..1 |
turbulence |
0.1 |
Organic noise 0..1 |
OscilloscopeWave
| Parameter | Default | Meaning |
|---|---|---|
frequency |
3.5 |
Carrier cycles across the width |
amplitude |
0.7 |
Peak height, fraction of the half-height |
modulation |
0.55 |
AM depth; 0 is a plain carrier |
modulationFrequency |
0.75 |
Envelope cycles across the width |
strokeWidth |
2.0 |
Live trace width |
persistence |
4 |
Phosphor ghosts behind the trace |
persistenceGap |
0.055 |
Seconds of wave time between ghosts |
gridDivisions |
10 |
Vertical graticule divisions; 0 hides it |
gridOpacity |
0.18 |
Graticule opacity |
beamDotRadius |
2.5 |
Dot riding the beam; 0 hides it |
taper |
0.0 |
0 lets the trace run off both edges |
glow |
0.5 |
Bloom strength 0..1 |
resolution |
2.5 |
Logical pixels between samples |
SpiralWave
| Parameter | Default | Meaning |
|---|---|---|
armCount |
3 |
Arms spaced evenly around the circle |
turns |
2.2 |
Revolutions from centre to edge |
frequency |
5.0 |
Wave cycles along one arm |
amplitude |
0.06 |
Radial wave depth, fraction of the half-size |
rotationSpeed |
0.09 |
Vortex spin, turns per second |
waveSpeed |
1.4 |
How fast the wave travels outwards |
strokeWidth |
5.0 |
Width at the centre |
tipWidth |
0.6 |
Width at the tip |
innerRadius |
0.08 |
Radius an arm starts at |
turbulence |
0.08 |
Organic noise 0..1 |
glow |
0.45 |
Bloom strength 0..1 |
blendMode |
BlendMode.srcOver |
plus for neon on dark |
segments |
160 |
Segments per arm |
LiquidWave
| Parameter | Default | Meaning |
|---|---|---|
blobCount |
5 |
Attractors deforming the surface |
radiusFactor |
0.62 |
Resting radius, fraction of the half-size |
frequency |
0.16 |
Attractor orbit rate, turns per second |
pull |
0.34 |
How hard an attractor pushes the surface out |
spread |
0.07 |
Bump width in turns: small gives distinct fingers |
orbit |
0.5 |
Rate at which attractor strength breathes |
highlight |
0.35 |
Specular highlight strength; 0 hides it |
rimWidth |
0.0 |
Rim light around the silhouette |
turbulence |
0.04 |
Organic noise 0..1 |
glow |
0.5 |
Bloom strength 0..1 |
segments |
128 |
Samples around the silhouette |
HelixWave
| Parameter | Default | Meaning |
|---|---|---|
strandCount |
2 |
2 is the classic double helix; 3+ braids |
frequency |
2.0 |
Full twists across the width |
amplitude |
0.62 |
Peak height, fraction of the half-height |
strokeWidth |
3.5 |
Strand width at its nearest point |
rungCount |
24 |
Rungs joining the strands; 0 hides them |
rungWidth |
1.6 |
Rung stroke width |
rungOpacity |
0.55 |
Opacity of a fully open rung |
nodeRadius |
2.6 |
Dots where rungs meet a strand; 0 hides them |
depthContrast |
0.6 |
How much dimmer and thinner the far side gets |
taper |
0.0 |
0 lets the helix run off both edges |
glow |
0.35 |
Bloom strength 0..1 |
ParticleWave
| Parameter | Default | Meaning |
|---|---|---|
particleCount |
70 |
Sparks alive at once |
frequency |
1.6 |
Field cycles across the width |
amplitude |
0.5 |
Peak height, fraction of the half-height |
flowSpeed |
1.0 |
How fast sparks drift across |
spread |
0.18 |
Vertical scatter around the wave |
particleRadius |
2.2 |
Radius of an average spark |
trailLength |
0.35 |
Trail length as a fraction of the width |
trailWidth |
0.8 |
Trail thickness relative to the radius |
turbulence |
0.12 |
Organic noise 0..1 |
taper |
0.6 |
Also fades sparks in and out at the edges |
glow |
0.55 |
Bloom strength 0..1 |
blendMode |
BlendMode.srcOver |
plus makes overlaps burn |
seed |
7 |
Re-rolls the particle layout |
GridWave
| Parameter | Default | Meaning |
|---|---|---|
rows |
18 |
Horizontal rows in flight |
columns |
16 |
Rails converging on the vanishing point |
horizon |
0.42 |
Horizon height, 0 at the top |
perspective |
2.6 |
Depth curve; higher bunches rows at the horizon |
frequency |
1.5 |
Terrain cycles across the width |
amplitude |
0.16 |
Terrain height, fraction of the ground area |
scrollSpeed |
0.12 |
Depth units per second towards the viewer |
lineWidth |
1.2 |
Width at the horizon; near rows get thicker |
horizonGlow |
0.5 |
Light band on the horizon; 0 hides it |
glow |
0.45 |
Bloom strength 0..1 |
blendMode |
BlendMode.srcOver |
plus for the neon look |
resolution |
6.0 |
Logical pixels between samples along a row |
AuroraWave
| Parameter | Default | Meaning |
|---|---|---|
bandCount |
4 |
Curtains stacked front to back |
frequency |
0.9 |
Curtain folds across the width |
amplitude |
0.35 |
Fold depth, fraction of the height |
bandHeight |
0.55 |
How far a curtain rises from its hem |
drift |
0.35 |
How fast the folds travel sideways |
turbulence |
0.28 |
Raggedness of the hem 0..1 |
softness |
0.45 |
Edge blur; 0 gives hard-edged ribbons |
baseline |
0.45 |
Vertical anchor, 0 at the top |
opacity |
0.85 |
Below 1 keeps additive stacking from blowing out |
blendMode |
BlendMode.plus |
Overlaps brighten |
SonarWave
| Parameter | Default | Meaning |
|---|---|---|
frequency |
0.4 |
Sweep rate in revolutions per second |
blips |
6 contacts | SonarBlip(angle, distance, size, drift) |
ringCount |
4 |
Range rings |
ringWidth |
1.0 |
Ring and crosshair stroke width |
sweepWidth |
2.0 |
Sweep arm width; 0 hides it |
trailTurns |
0.3 |
Afterglow wedge length, in turns |
crosshair |
true |
Draws the horizontal and vertical crosshair |
gridOpacity |
0.35 |
Rings and crosshair opacity; 0 hides them |
blipRadius |
3.0 |
Radius of a resting contact |
afterglow |
0.28 |
Wedge brightness; 0 hides it |
glow |
0.45 |
Bloom strength 0..1 |
center |
Alignment.center |
Where the scope sits in the box |
Palettes
WavePalette ships the brand defaults, but colors accepts any list:
GeminiWave(colors: const [Color(0xFF00E5FF), Color(0xFF7C4DFF)]);
ClaudeWave(colors: Theme.of(context).colorScheme.primary.let(...));
Available: WavePalette.gemini, geminiClassic, claude, claudePaper,
claudeInk, perplexity, perplexityInk, chatGpt, chatGptGreen,
chatGptInk, pulse, spectrum, oscilloscope, scopeInk, spiral,
helix, particles, liquid, grid, gridInk, aurora, auroraInk,
sonar.
Performance notes
- Nothing repaints while
pausedis true, while a controller is paused, or while the OS requests reduced motion — the ticker is stopped, not throttled. glowis the only expensive parameter (it adds a blurred pass). Set it to0on low-end devices and on web if you see frame drops.resolutiontrades smoothness for cost on the path-based styles.3.0is a good default;6.0roughly halves the sample count.segmentsaround 96 is plenty for the closed-shape styles (ChatGptWave,LiquidWave,PulseRingWave); higher values are rarely distinguishable.particleCount,barCount,rowsandpersistenceare the count knobs on the visualisers; halving one halves that style's draw calls.
Accessibility
Every widget carries a semanticLabel (pass null to hide it) and honours
MediaQuery.disableAnimations, rendering a single still frame instead of
animating. Set respectReducedMotion: false to opt out.
Building your own style
WaveCanvas is exported. It owns the ticker, level smoothing, activity
envelope, sizing, backdrop and accessibility, and hands your painter a
WaveFrame each tick:
WaveCanvas(
height: 100,
controller: controller,
painterBuilder: (context, frame) => MyPainter(frame: frame),
);
WaveMath (sinTurns, edgeTaper, smoothTowards, smoothPath,
smoothClosedPath) and ValueNoise are exported too.
Example app
cd example
flutter run # or: -d chrome, -d windows, -d macos, -d linux
The gallery has a page per assistant style with every parameter on a live slider, a Visualisers page with all ten new looks and the play buttons, a shared activity bar and a simulated microphone feed.
Author
Built and maintained by Neelakandan.
- Website: ineelakandan.in/
- Source: github.com/neelakandanz/ai-wave-animation-package
- Issues: github.com/neelakandanz/ai-wave-animation-package/issues
License
MIT — see LICENSE. © Neelakandan.
Libraries
- ai_wave_animations
- Assistant-style wave animations for Flutter.













