Skip to content

Animation

Animation helps people follow changes in your app. It can show where an item went, draw attention to a result, or keep a multi-step action understandable. Fleury provides a few APIs for these different jobs, all driven by the same animation system.

Start with this orbital courier:

Send a package into orbit
Live · interactive
click & type to interact ⓘ how this demo runs

Press Launch. The courier moves through ignition, ascent, transfer, and delivery over about five seconds. Press Reset mission at any point to interrupt the launch and return it to the depot.

One animation drives the route, progress bar, color, and status, keeping every part of the mission in sync from launch through delivery.

You want to…Use
Move toward a target stored in widget stateAnimationBuilder<T>
Own, retarget, await, or chain a value from codeAnimation<T>
Run visual effects on a widgetAnimate
Animate a widget in and out of the treeAnimatedVisibility
Advance authored pictures at a chosen intervalFrameBuilder
Update a simulation on every app-scheduler tickTicker

Most motion follows a value your widget already keeps in state: a panel is open, a package is delivered, a step is complete. Give AnimationBuilder<T> the target for that state. When the target changes, it animates from its current value to the new one, and it owns and disposes the animation for you.

This demo sends a package between two destinations. The explicit AnimationBuilder<double> type means the builder receives a double named progress, ranging from 0.0 at the depot to 1.0 at the station:

package_route.dart
You write · editable
class _PackageRouteState extends State<_PackageRoute> {
var _delivered = false;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text('PACKAGE ROUTE', style: CellStyle(bold: true)),
const Text('Change the target; the builder interpolates the value.'),
const SizedBox(height: 1),
Button(
text: _delivered ? 'Return to depot' : 'Send to station',
onPressed: () => setState(() => _delivered = !_delivered),
),
const SizedBox(height: 1),
AnimationBuilder<double>(
_delivered ? 1.0 : 0.0,
curve: Curves.easeInOut,
duration: const Duration(milliseconds: 1100),
builder: (context, double progress, _) {
final position = (progress * 24).round();
final route =
'${List<String>.filled(position, '─').join()}◆'
'${List<String>.filled(24 - position, '·').join()}';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('DEPOT $route STATION'),
Text('progress: ${progress.toStringAsFixed(2)} · double'),
Container(
height: 1,
child: progress > 0.995
? const Text(
'✦ PACKAGE DELIVERED ✦',
style: CellStyle(
foreground: RgbColor(70, 220, 145),
bold: true,
),
)
.animate(duration: const Duration(milliseconds: 650))
.flash(color: const RgbColor(120, 255, 190))
.slideIn(from: Edge.bottom)
: const Text(''),
),
],
);
},
),
],
);
}
Live preview
Move between two state-driven destinations
Live · interactive
click & type to interact ⓘ how this demo runs

Press the button before the package arrives: the builder retargets from where the package is, rather than jumping to either end. When the package reaches the station, the demo mounts a short completion effect. Fleury interpolates double, int, RgbColor, and CellOffset directly.

Without a curve, AnimationBuilder animates with a spring (Spring.smooth, or the one you pass as spring:), which keeps its velocity when the target changes mid-flight. For fixed timing, pass a curve, optionally with a duration, as this demo does. A duration without a curve throws an ArgumentError, because a spring sets its own timing.

When part of the builder’s output does not depend on the value, pass it as child:. The builder receives the same widget on every frame instead of building it again.

Share one value or time properties separately

Section titled “Share one value or time properties separately”

One animated value can drive several visual properties. Use that when size, color, and progress should stay synchronized. Give properties separate builders when they need different durations.

The two status rails below end in the same state. The first uses one double progress for width and color. In the second, width finishes in 180 ms while the color continues for 800 ms:

You write · editable
Widget _sharedTimingStatus(bool active) {
const inactive = RgbColor(110, 120, 135);
const activeColor = RgbColor(70, 220, 145);
return AnimationBuilder<double>(
active ? 1.0 : 0.0,
curve: Curves.easeOut,
duration: const Duration(milliseconds: 800),
builder: (context, double progress, _) {
final width = 22 + (20 * progress).round();
final accent = rgbColorLerp(inactive, activeColor, progress);
return Container(
width: width,
height: 3,
border: BoxBorder(
style: Theme.of(context).borderStyle,
cellStyle: CellStyle(foreground: accent),
),
padding: const EdgeInsets.symmetric(horizontal: 1),
child: Text('TOGETHER ${(progress * 100).round()}%'),
);
},
);
}
Live preview
Compare shared and independent timing
Live · interactive
click & type to interact ⓘ how this demo runs

Press Animate and compare the two rails. Shared progress keeps related properties synchronized. Separate builders let each property finish on its own schedule.

Own an Animation<T> when code, not a state value, decides the motion: a sequence of stages, a result you need to await, or one value used in several places.

This widget owns a double that runs from 0.0 at the depot to 1.0 at the station. Reading progress.value during build subscribes the widget to the animation, so it rebuilds as the value changes:

manual_route.dart
You write · editable
class _ManualRouteState extends State<_ManualRoute> {
final _progress = Animation<double>(0.0, debugLabel: 'manual package route');
var _running = false;
Future<void> _runRoute() async {
setState(() => _running = true);
try {
await _progress
.to(
1.0,
curve: Curves.easeInOut,
duration: const Duration(milliseconds: 700),
)
.delay(const Duration(milliseconds: 600))
.to(
0.0,
curve: Curves.easeInOut,
duration: const Duration(milliseconds: 700),
)
.orCancel;
} on TickerCanceled {
return;
}
if (mounted) setState(() => _running = false);
}
void _returnNow() {
setState(() => _running = false);
_progress.to(
0.0,
curve: Curves.easeOut,
duration: const Duration(milliseconds: 450),
);
}
@override
void dispose() {
_progress.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final progress = _progress.value;
final position = (progress * 24).round();
final route =
'${List<String>.filled(position, '─').join()}◆'
'${List<String>.filled(24 - position, '·').join()}';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text('RAW ANIMATION', style: CellStyle(bold: true)),
const Text('Own it to chain, await, and interrupt motion.'),
const SizedBox(height: 1),
Button(
text: _running ? 'Return now' : 'Run route',
onPressed: _running ? _returnNow : _runRoute,
),
const SizedBox(height: 1),
Text('DEPOT $route STATION'),
Text('progress.value: ${progress.toStringAsFixed(2)}'),
],
);
}
}
Live preview
Own, chain, and interrupt an Animation value
Live · interactive
click & type to interact ⓘ how this demo runs

Press Run route. The package moves to the station, waits there for 600 ms, then returns. Press Return now while it is moving or waiting. The orbital courier at the top of this page uses the same primitive for a longer route.

Animation.to follows the same timing rule as AnimationBuilder: a spring by default, or a curve with an optional duration.

The first .to() starts immediately. Calling .to() or .delay() on the future it returns appends another stage and returns that same future, so awaiting it waits for the whole chain. .delay() holds the current value for a clock-driven duration. A new call on the animation itself replaces the active chain and starts from the current value:

final run = progress.to(1.0); // starts now
run.to(0.5); // appends to this run
progress.to(0.0); // replaces and cancels the run

That is why Return now retargets smoothly. The replaced chain’s future completes with TickerCanceled when you await .orCancel, so _runRoute returns early instead of resetting _running. Dispose an animation you own with its owner, as dispose does above.

An Effect describes a visual change such as a fade, slide, flash, or shake. Animate is the widget that runs one or more effects on its child:

Animate(
duration: const Duration(milliseconds: 600),
curve: Curves.linear,
effects: [Effects.fadeIn(), Effects.slideIn(from: Edge.left)],
child: const Text('● Connected to relay'),
)

The .animate() extension is fluent shorthand for constructing Animate. This is equivalent to the code above:

const Text('● Connected to relay')
.animate(
duration: const Duration(milliseconds: 600),
curve: Curves.linear,
)
.fadeIn()
.slideIn(from: Edge.left)

Both forms run the same Effect values with Animation<double>; they are not separate animation systems. This example mounts a connection status and runs the fade and slide together:

connection_status.dart
You write · editable
class _ConnectionStatusState extends State<_ConnectionStatus> {
var _connected = false;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text('RELAY CONNECTION', style: CellStyle(bold: true)),
const Text('The effect runs when the status enters the tree.'),
const SizedBox(height: 1),
Button(
text: _connected ? 'Disconnect' : 'Connect',
onPressed: () => setState(() => _connected = !_connected),
),
const SizedBox(height: 1),
if (_connected)
Padding(
padding: const EdgeInsets.only(left: 4),
child:
const Text(
'● Connected to relay',
style: CellStyle(foreground: RgbColor(70, 220, 145)),
)
.animate(
duration: const Duration(milliseconds: 600),
curve: Curves.linear,
)
.fadeIn()
.slideIn(from: Edge.left),
)
else
const Text(
'○ Offline',
style: CellStyle(foreground: RgbColor(115, 125, 140)),
),
],
);
}
Live preview
Run effects on a mounted widget
Live · interactive
click & type to interact ⓘ how this demo runs

Press Connect to mount the status and run the effects. Animate handles the visual change; the surrounding if still decides whether the child is in the tree.

The built-in effects cover entrance and exit motion (fadeIn, fadeOut, slideIn, slideOut, wipeIn, wipeOut, expand, shrink), feedback (flash, shake), and looping motion (pulse, shimmer). A looping effect keeps the animation clock running, and the app drawing frames, for as long as it is mounted.

Unlike AnimationBuilder, Animate and AnimatedVisibility accept a duration on its own; their curve defaults to Curves.easeOut.

A slide travels one child width or height by default. Terminal motion lands on whole cells, so a linear curve and a duration that exposes several cell positions usually reads best. Pass distance: when you want a shorter, fixed-cell nudge instead of a full entrance or exit.

An effect normally runs once when Animate mounts. To replay it without remounting the widget, set trigger to a value that changes for each action. A counter works well for repeated submissions. With a trigger, the first build shows the finished state; the effect plays only when the value changes.

This form validates the pilot name with Form and FormField, as Forms & validation teaches, so the input shows its invalid state and carries the error in its semantics. It increments _submitCount after every submission and shows the outcome in one animated line, which is why the field sets showErrorMessage: false. Empty input shows red feedback; a valid pilot name shows green feedback:

pilot_validation.dart
You write · editable
class _PilotValidationState extends State<_PilotValidation> {
final _form = FormController();
final _name = TextEditingController();
var _submitCount = 0;
String? _clearedName;
Future<void> _submit() async {
final valid = await _form.submit();
if (!mounted) return;
setState(() {
_submitCount++;
_clearedName = valid ? _name.text.trim() : null;
});
}
Widget _feedback() {
final cleared = _clearedName;
final message = _submitCount == 0
? 'Enter a pilot name, then validate it.'
: cleared == null
? '✕ Enter any non-empty name'
: '✓ $cleared is cleared for launch';
final color = cleared == null
? const RgbColor(255, 90, 90)
: const RgbColor(70, 220, 145);
final feedback =
Text(
message,
style: CellStyle(foreground: _submitCount == 0 ? null : color),
).animate(
trigger: _submitCount,
curve: Curves.easeOut,
duration: const Duration(milliseconds: 650),
);
return feedback.wipeIn(from: Edge.left);
}
@override
void dispose() {
_form.dispose();
_name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Form(
controller: _form,
onSubmit: () {},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text('FORM VALIDATION', style: CellStyle(bold: true)),
const SizedBox(height: 1),
const Text('Pilot name'),
FormField(
validator: () =>
_name.text.trim().isEmpty ? 'Enter any non-empty name' : null,
// The animated line below shows the outcome instead.
showErrorMessage: false,
child: Container(
width: 32,
border: BoxBorder(style: Theme.of(context).borderStyle),
padding: const EdgeInsets.symmetric(horizontal: 1),
child: SizedBox(
width: 28,
child: TextInput(
controller: _name,
autofocus: true,
semanticLabel: 'Pilot name',
placeholder: 'Type any name',
onSubmit: (_) => _submit(),
),
),
),
),
const SizedBox(height: 1),
_feedback(),
const SizedBox(height: 1),
Button(text: 'Validate pilot', onPressed: _submit),
],
),
);
}
Live preview
Replay validation feedback
Live · interactive
click & type to interact ⓘ how this demo runs

Submit the empty field more than once to see the error replay. Then enter a non-empty name and submit again to see the success state. Both outcomes replay when _submitCount changes.

Animate only runs effects on the child it receives. When an exit must finish before the child is removed, use AnimatedVisibility. It owns that additional lifecycle: visible controls whether the child belongs in the tree, while enter and exit describe each side of the change.

The picker below maps each selection to an Effect, then passes the selected pair to AnimatedVisibility:

effect_picker.dart
You write · editable
class _EffectPickerState extends State<_EffectPicker> {
// Each choice pairs an effect with a duration that follows its distance:
// slides and wipes cross the 24-column sample, so they run longest.
static final _entrances = <String, (Effect, Duration)>{
'Fade in': (Effects.fadeIn(), const Duration(milliseconds: 400)),
'Slide in': (
Effects.slideIn(from: Edge.left),
const Duration(milliseconds: 800),
),
'Wipe in': (
Effects.wipeIn(from: Edge.left),
const Duration(milliseconds: 800),
),
'Expand': (Effects.expand(), const Duration(milliseconds: 300)),
};
static final _exits = <String, (Effect, Duration)>{
'Fade out': (Effects.fadeOut(), const Duration(milliseconds: 400)),
'Slide out': (
Effects.slideOut(to: Edge.right),
const Duration(milliseconds: 800),
),
'Wipe out': (
Effects.wipeOut(to: Edge.right),
const Duration(milliseconds: 800),
),
'Shrink': (Effects.shrink(), const Duration(milliseconds: 300)),
};
var _entry = 'Fade in';
var _exit = 'Fade out';
var _visible = true;
@override
Widget build(BuildContext context) {
final (enter, enterDuration) = _entrances[_entry]!;
final (exit, exitDuration) = _exits[_exit]!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text('ENTRANCE + EXIT LAB', style: CellStyle(bold: true)),
const Text('Choose a pair, then toggle the sample.'),
const SizedBox(height: 1),
Row(
children: <Widget>[
SizedBox(
width: 25,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text('ENTER'),
Select<String>(
semanticLabel: 'Entrance effect',
autofocus: true,
value: _entry,
options: [
for (final label in _entrances.keys)
SelectOption(value: label, label: label),
],
onChanged: (value) => setState(() => _entry = value),
),
],
),
),
SizedBox(
width: 25,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text('EXIT'),
Select<String>(
semanticLabel: 'Exit effect',
value: _exit,
options: [
for (final label in _exits.keys)
SelectOption(value: label, label: label),
],
onChanged: (value) => setState(() => _exit = value),
),
],
),
),
],
),
const SizedBox(height: 1),
Button(
text: _visible ? 'Hide sample' : 'Show sample',
onPressed: () => setState(() => _visible = !_visible),
),
const SizedBox(height: 1),
AnimatedVisibility(
visible: _visible,
enter: enter,
exit: exit,
// The transition that is starting decides the duration.
duration: _visible ? enterDuration : exitDuration,
curve: Curves.linear,
child: Container(
width: 24,
border: BoxBorder(style: Theme.of(context).borderStyle),
padding: const EdgeInsets.symmetric(horizontal: 1),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('DEPLOY PREVIEW', style: CellStyle(bold: true)),
Text('✓ Resolve'),
Text('✓ Analyze'),
Text('✓ Test'),
Text('✓ Package'),
Text('✓ Sign'),
Text('✓ Publish'),
],
),
),
),
],
);
}
}
Live preview
Compare entrance and exit effects
Live · interactive
click & type to interact ⓘ how this demo runs

Choose an entrance and exit, then hide and show the sample. Show it again before its exit finishes: the child stays mounted, and the entrance resumes from the exit’s current progress. With a matching pair, such as fade in and fade out or expand and shrink, that looks like a reversal. With the demo’s slide or wipe pair, which enters from the left and leaves to the right, the sample jumps back to the left side.

The picker offers only lifecycle effects; flash and shake are feedback, while pulse and shimmer loop. Its durations follow distance. The demo’s sample is 24 columns wide and 9 rows tall, so the horizontal effects run for 800 ms and the size effects for 300 ms. Each then moves about one cell per update of the animation clock, which keeps every step visible.

Some motion is a set of distinct pictures rather than values to interpolate: a typing indicator, cursor blink, progress sprite, or ASCII character cycle. FrameBuilder rebuilds at the interval you choose and supplies an increasing integer frame. It still shares Fleury’s app-level scheduler; the widget simply waits until its interval has elapsed before advancing.

This packet transfer has six authored frames:

packet_transfer.dart
You write · editable
class _PacketTransferState extends State<_PacketTransfer> {
static const _frames = <String>[
'●··········◇',
'──●········◇',
'────●······◇',
'──────●····◇',
'────────●··◇',
'──────────◆',
];
var _fast = true;
var _running = true;
Duration get _interval => Duration(milliseconds: _fast ? 180 : 650);
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text('PACKET TRANSFER', style: CellStyle(bold: true)),
const Text('Each step is an authored frame, not an interpolated value.'),
const SizedBox(height: 1),
Row(
children: <Widget>[
Button(
text: _fast ? 'Slow down' : 'Speed up',
onPressed: () => setState(() => _fast = !_fast),
),
const SizedBox(width: 1),
Button(
text: _running ? 'Pause' : 'Resume',
onPressed: () => setState(() => _running = !_running),
),
],
),
const SizedBox(height: 1),
FrameBuilder(
interval: _interval,
enabled: _running,
builder: (context, frame, _, delta) {
final index = frame % _frames.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('UPLINK ${_frames[index]} ARCHIVE'),
Text(
'authored frame ${index + 1}/${_frames.length} · '
'${delta.inMilliseconds} ms',
),
],
);
},
),
],
);
}
Live preview
Cycle through a packet transfer frame by frame
Live · interactive
click & type to interact ⓘ how this demo runs

Change the speed, then pause and resume it. frame starts at zero and counts up at the selected interval; modulo maps it onto the six pictures. Resuming or changing the interval starts frame at zero again. Use Fleury’s Spinner when its built-in frames are enough, and FrameBuilder when the frames belong to your app.

A Ticker runs a callback on Fleury’s animation clock and passes it the total time elapsed since the ticker started. By default, the clock wakes every 33 ms, about 30 times a second, while at least one ticker is active, and stops when the last one stops. This fits game loops, physics, particles, and other motion that advances continuously rather than toward a target.

The event loop can delay a callback, so compute the simulation from elapsed time rather than counting ticks:

ticker_simulation.dart
You write · editable
class _TickerSimulationState extends State<_TickerSimulation>
with SingleTickerProviderStateMixin {
static const _trackWidth = 28.0;
late final Ticker _ticker;
Duration _lastElapsed = Duration.zero;
var _position = 0.0;
var _velocity = 12.0;
@override
void initState() {
super.initState();
_ticker = createTicker(_onTick)..start();
}
void _onTick(Duration elapsed) {
final seconds = (elapsed - _lastElapsed).inMicroseconds / 1000000;
_lastElapsed = elapsed;
var next = _position + (_velocity * seconds);
if (next >= _trackWidth) {
next = _trackWidth - (next - _trackWidth);
_velocity = -_velocity.abs();
} else if (next <= 0) {
next = -next;
_velocity = _velocity.abs();
}
setState(() => _position = next.clamp(0.0, _trackWidth));
}
void _toggle() => setState(() {
if (_ticker.isActive) {
_ticker.stop();
} else {
_lastElapsed = Duration.zero;
_ticker.start();
}
});
@override
Widget build(BuildContext context) {
final position = _position.round();
final track =
'${List<String>.filled(position, '─').join()}●'
'${List<String>.filled(_trackWidth.round() - position, '·').join()}';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text('SIMULATION CLOCK', style: CellStyle(bold: true)),
const Text('Position advances from elapsed time on every tick.'),
const SizedBox(height: 1),
Text('|$track|'),
Text('position ${_position.toStringAsFixed(1)} cells'),
const SizedBox(height: 1),
Button(
text: _ticker.isActive ? 'Pause simulation' : 'Resume simulation',
onPressed: _toggle,
),
],
);
}
}
Live preview
Move a simulated object from elapsed time
Live · interactive
click & type to interact ⓘ how this demo runs

Create the ticker in initState, as this demo does. SingleTickerProviderStateMixin creates it from the scheduler the current Fleury app already owns, mutes it with TickerMode, and disposes it with the state. Application code creates and controls its ticker; it does not create another scheduler.

The clock’s interval is shared across an app, not set per ticker. A custom host can provide a TickerScheduler(frameInterval: …) through its TuiBinding. The separate runApp(frameInterval: …) option limits how often frames are presented; it does not change how often tickers run.