animated_particles
A fast, smooth steering-behavior particle field for Flutter. Particles are
sampled from text, icons, or images, then arrive into the shape, morph
between shapes, flee from your touch, and escape off-screen - all driven
by one drawRawPoints call and a tight structure-of-arrays engine.
▶️ The GIF is a 2× sped-up preview — watch the full-quality recording (mp4).
Features
- 🅰️ Sample anything - build a target from
text, anicon, anassetimage, a decodedui.Image, an explicit list of points, or a custom sampler. - 🔀 Morph between shapes - particles flee to a chosen edge, then reform into the next target after a short delay.
- ⚡ Built for 60 fps - a fixed 30 Hz simulation, flat
Float32Listarrays, off-thread image sampling, and a singledrawRawPointsper frame inside aRepaintBoundary. - 🎛️ Tunable - speed, steering force, arrival/flee radii, particle size & shape, fill, alignment, margin, and more.
- 📦 Pure Dart/Flutter - no native code; runs on iOS, Android, macOS, Windows, Linux, and Web.
Install
Add it to your pubspec.yaml:
dependencies:
animated_particles: ^0.1.0
…or from the command line:
flutter pub add animated_particles
Then import it:
import 'package:animated_particles/animated_particles.dart';
Quick start
Create a ParticleFieldController, drop a ParticleField into your tree,
register some targets, and call changeTarget once the field has a size.
import 'package:flutter/material.dart';
import 'package:animated_particles/animated_particles.dart';
class Demo extends StatefulWidget {
const Demo({super.key});
@override
State<Demo> createState() => _DemoState();
}
class _DemoState extends State<Demo> {
late final ParticleFieldController _controller;
@override
void initState() {
super.initState();
_controller = ParticleFieldController(
config: const ParticleFieldConfig(
color: Color(0xFFEAF2FF),
particleSize: 1.6,
shape: ParticleShape.circle,
antiAlias: true,
),
);
_load();
}
Future<void> _load() async {
// Resolving samples text/icons off the UI thread.
await _controller.addTargets({
'hello': ParticleTarget.text(
'Hello', style: const TextStyle(fontSize: 120, fontWeight: FontWeight.w800),
),
'heart': ParticleTarget.icon(Icons.favorite, size: 240),
});
// changeTarget only forms a shape once the field has been laid out
// (has a non-zero size), so wait for the first frame.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _controller.changeTarget('hello');
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF07080B),
body: ParticleField(
controller: _controller,
backgroundColor: const Color(0xFF07080B),
),
floatingActionButton: FloatingActionButton(
// Tap to morph: flee to the escape edge, then reform.
onPressed: () => _controller.changeTarget('heart'),
child: const Icon(Icons.favorite),
),
);
}
}
Why
addPostFrameCallback?changeTargetplaces points in the field's coordinate space, which only exists after layout. Calling it before the field has a size sets the active target but won't move particles until a size is known. Calls triggered by user input (a button tap) are always safe.
Targets
A ParticleTarget is a recipe for a cloud of points. Build one with a factory,
register it under a key, then switch to it with changeTarget. Sampling walks
the source on a grid: smaller stride = denser (more particles), threshold
is the alpha/brightness cutoff, and jitter nudges points off the grid so
the result doesn't look mechanical.
| Factory | Builds points from | Key options (defaults) |
|---|---|---|
ParticleTarget.text(text, …) |
rendered text (alpha) | style, stride: 2, threshold: 24, jitter: 1.0 |
ParticleTarget.icon(icon, …) |
a rendered icon glyph (alpha) | size: 220, stride: 2, threshold: 24, jitter: 1.0 |
ParticleTarget.asset(path, …) |
a bundled asset image | mode: brightness, stride: 2, threshold: 20, jitter: 1.0 |
ParticleTarget.image(uiImage, …) |
an already-decoded ui.Image |
mode, stride, threshold, jitter |
ParticleTarget.points(points, …) |
an explicit List<Offset> |
sourceSize |
ParticleTarget.sampler(sampler) |
your own PointSampler |
- |
await controller.addTargets({
'logo': ParticleTarget.asset('assets/logo.png', stride: 3),
'flutter': ParticleTarget.text('Flutter',
style: const TextStyle(fontSize: 130, fontWeight: FontWeight.w800)),
'dash': ParticleTarget.icon(Icons.flutter_dash, size: 260),
'ring': ParticleTarget.points(myCirclePoints),
});
controller.changeTarget('flutter'); // morph!
SampleMode.alpha keys off the alpha channel (used automatically for text &
icons); SampleMode.brightness keys off pixel luminance (the default for
images).
Configuration
Everything about motion and appearance lives in the immutable
ParticleFieldConfig. Pass one to the controller, or swap it live with
controller.config = controller.config.copyWith(...).
| Field | Default | Description |
|---|---|---|
maxSpeed |
20.0 |
Top speed, in pixels per step. |
maxForce |
3.0 |
Max steering force per step (turn sharpness). |
arrivalRadius |
100.0 |
Distance at which particles start easing in. |
fleeRadius |
40.0 |
Particles within this distance of the touch are pushed away. |
escapePosition |
EscapePosition.bottom |
Edge particles flee to before reforming. |
reformDelay |
100 ms |
Pause between escaping and heading to the new target. |
color |
0xFFFFFFFF |
Particle color. |
particleSize |
2.0 |
Particle diameter / side length, in pixels. |
shape |
ParticleShape.square |
square or circle. |
antiAlias |
false |
Smoother edges, slightly slower. |
particleCount |
null |
Fixed count; when null, follows the largest target. |
placement |
PlacementMode.fit |
fit (scale to field) or raw (use sampled coords). |
fillFraction |
0.78 |
Fraction of the available space the shape fills (0–1). |
alignment |
Alignment(0, -0.35) |
Where the shape sits within the field. |
margin |
16.0 |
Empty space kept around the shape, in pixels. |
// Live-tweak the flee direction, then preview it immediately.
controller.config = controller.config.copyWith(
escapePosition: EscapePosition.right,
);
controller.escape();
Controller API
ParticleFieldController extends ChangeNotifier.
Targets & morphing
| Member | What it does |
|---|---|
addTarget(key, target) → Future |
Resolve & register a single target. |
addTargets(map) → Future |
Resolve & register several at once. |
removeTarget(key) |
Unregister a target. |
changeTarget(key) |
Flee to the escape edge, then reform into key. Throws if key isn't registered. |
escape() |
Send particles fleeing to the escape edge (cancels a pending reform). |
setDragPoint(offset?) |
Set / clear the point particles flee from. |
start() / stop() |
Start / stop the simulation ticker. |
State (getters)
| Member | Returns |
|---|---|
config (get/set) |
The current ParticleFieldConfig. Setting it re-spawns particles if the count changed. |
particleCount |
Number of live particles. |
activeKey |
Key of the current target (or null). |
keys |
Names of all registered targets. |
isRunning |
Whether the ticker is active. |
repaint |
A Listenable that fires once per simulated frame. |
The controller notifies its ChangeNotifier listeners when targets or the
config change - handy for driving a live particle-count readout:
ListenableBuilder(
listenable: controller,
builder: (context, _) => Text('${controller.particleCount} particles'),
);
Call controller.dispose() when you're done.
The widget
ParticleField(
controller: controller,
handleDrag: true,
backgroundColor: const Color(0xFF07080B),
)
ParticleField reports its size to the controller and - when handleDrag is
true - forwards pan gestures so particles flee from your finger within
fleeRadius. Set handleDrag: false if the field sits behind other interactive
UI (as the example does, with its own control deck on top).
How it works
- Sample - text, icons, and images are rasterized to an offscreen image;
pixels are scanned on a
stridegrid and those abovethresholdbecome target points. The scan runs in a background isolate viacompute, so big images never jank the UI thread. - Place - the sampled cloud is scaled to fit the field (
fillFraction,margin,alignment), or usedraw. - Simulate - a fixed 30 Hz timestep (with accumulator + up to 5 catch-up sub-steps) advances every particle using Reynolds-style arrive steering toward its target, plus a flee force from the touch point.
- Render - all particles are drawn in a single
canvas.drawRawPointscall. Particle size maps to stroke width andcircleto a round stroke cap; aRepaintBoundarykeeps the rest of your tree off the repaint path.
State lives in flat Float32List arrays (structure-of-arrays) for
cache-friendly stepping, which is what keeps large fields smooth.
Performance tips
- Fewer particles = faster: raise
stride, or pin aparticleCount. - Leave
antiAliasoff for the largest fields;squareis marginally cheaper thancircle. - One
ParticleFieldalready isolates its own repaints - keep heavy widgets out from under it.
Example
A full, polished demo - a gallery of text/icon targets, escape-edge control, and
a live particle counter - lives in example/. Run it
with:
cd example
flutter run
License
MIT © Kawaljeet Singh — see the LICENSE file.
Libraries
- animated_particles
- Public entry point for the animated_particles package. Re-exports everything needed to use a ParticleField.
