Animations
Mix provides three main ways to animate your widgets, each suited to different levels of control and complexity:
- Implicit: Simple property transitions that animate automatically when values change (state or variants).
- Phase: Multi-step sequences that progress through defined phases, great for state machines.
- Keyframe: Timeline-based animations with precise control over multiple properties and easing.
Implicit Animations
Implicit animations are the easiest way to animate style changes—Mix interpolates between old and new values automatically.
- When to use: Simple hover/press effect or any other variant/state transition.
- How it works: Call
.animate(...)
on anyStyle
/Styler
. Works with state and variants (e.g. hover, press, dark mode).
Example: State-triggered
This example demonstrates an implicit animation in Mix. When the appear
state changes, the box smoothly transitions its scale between 0.1 and 1 using the .animate(...)
modifier. The AnimationConfig.easeInOut(1.s)
sets the animation to use an ease-in-out curve over 1 second.
class ScaleAnimation extends StatefulWidget {
const ScaleAnimation({super.key});
@override
State<ScaleAnimation> createState() => _ScaleAnimationState();
}
class _ScaleAnimationState extends State<ScaleAnimation> {
bool appear = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
appear = true;
});
});
}
@override
Widget build(BuildContext context) {
final style = BoxStyler()
.color(Colors.black)
.height(100)
.width(100)
.borderRounded(10)
.scale(appear ? 1 : 0.1) // state-based
.animate(AnimationConfig.easeInOut(1.s));
return Box(style: style);
}
}
Example: Variant-triggered
In the following example, the box smoothly grows in size and shifts its color from black to blue when you hover over it. Rather than toggling a state variable yourself, you can use variants like onHovered or onPressed to trigger these implicit animations directly in your style definitions.
class HoverAnimation extends StatelessWidget {
const HoverAnimation({super.key});
@override
Widget build(BuildContext context) {
final style = BoxStyler()
.color(Colors.black)
.height(100)
.width(100)
.borderRounded(10)
.scale(1)
.onHovered(
BoxStyler()
.color(Colors.blue)
.scale(1.5),
)
.animate(AnimationConfig.spring(800.ms));
return Box(style: style);
}
}
Phase Animations
Phase animations let you define a small set of named phases, each with its own style and config. The sequence plays forward to completion, then resolves back to the initial phase.
- When to use: State machines, progress indicators, or sequential micro-interactions that start with an initial value and, in the end, return to that same initial value.
- How it works: Use
.phaseAnimation(...)
with:trigger
: aListenable
(e.g.,ValueNotifier
,ChangeNotifier
).phases
: a list of values (usually an enum).styleBuilder
: returns the style for each phase.configBuilder
: returns the animation config for each phase.
Example: Compress/Expand
enum AnimationPhases { initial, compress, expanded }
class CompressExpandAnimation extends StatefulWidget {
const CompressExpandAnimation({super.key});
@override
State<CompressExpandAnimation> createState() =>
_CompressExpandAnimationState();
}
class _CompressExpandAnimationState extends State<CompressExpandAnimation> {
final _isExpanded = ValueNotifier(false);
@override
void dispose() {
_isExpanded.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final style = BoxStyler()
.color(Colors.deepPurple)
.height(100)
.width(100)
.borderRounded(40)
.phaseAnimation(
trigger: _isExpanded,
phases: AnimationPhases.values,
styleBuilder: (phase, style) => switch (phase) {
AnimationPhases.initial => style.scale(1),
AnimationPhases.compress =>
style.scale(0.75).color(Colors.red.shade800),
AnimationPhases.expanded =>
style.scale(1.25).borderRounded(20).color(Colors.yellow.shade300),
},
configBuilder: (phase) => switch (phase) {
AnimationPhases.initial =>
CurveAnimationConfig.springWithDampingRatio(800.ms, ratio: 0.3),
AnimationPhases.compress => CurveAnimationConfig.decelerate(200.ms),
AnimationPhases.expanded => CurveAnimationConfig.decelerate(100.ms),
},
);
return Pressable(
onPress: () {
_isExpanded.value = !_isExpanded.value;
},
child: Box(style: style),
);
}
}
Keyframe Animations
Keyframes give you full control by mapping moments in time to values across multiple tracks (like CSS keyframes).
- When to use: Choreographed sequences, multi-property transitions.
- How it works: Use
.keyframeAnimation(...)
with a list ofKeyframeTrack
s. Each track represents a property to animate and runs in parallel with the others, while the keyframes within a track are executed in sequence. Each keyframe defines a value, duration, and easing, and you can optionally provide atweenBuilder
for custom interpolation.
Example: Heart
class HeartKeyframeAnimation extends StatefulWidget {
const HeartKeyframeAnimation({super.key});
@override
State<HeartKeyframeAnimation> createState() => _HeartKeyframeAnimationState();
}
class _HeartKeyframeAnimationState extends State<HeartKeyframeAnimation> {
final _trigger = ValueNotifier(0);
@override
Widget build(BuildContext context) {
final style = IconStyler()
.color(Colors.red)
.size(80)
.keyframeAnimation(
trigger: _trigger,
timeline: [
KeyframeTrack<Color>(
'color',
[
Keyframe.linear(Colors.blue.shade100, 100.ms),
Keyframe.elasticOut(Colors.blue.shade400, 800.ms),
Keyframe.elasticOut(Colors.green.shade100, 800.ms),
],
initial: Colors.red.shade100,
tweenBuilder: ColorTween.new,
),
KeyframeTrack<double>('scale', [
Keyframe.linear(1.0, 360.ms),
Keyframe.elasticOut(1.5, 800.ms),
Keyframe.elasticOut(1.0, 800.ms),
], initial: 1.0),
KeyframeTrack<double>('verticalOffset', [
Keyframe.linear(0.0, 100.ms),
Keyframe.easeIn(20.0, 150.ms),
Keyframe.elasticOut(-60.0, 1000.ms),
Keyframe.elasticOut(0.0, 800.ms),
], initial: 0.0),
KeyframeTrack<double>('verticalStretch', [
Keyframe.ease(1.0, 100.ms),
Keyframe.ease(0.6, 150.ms),
Keyframe.ease(1.5, 100.ms),
Keyframe.ease(1.05, 150.ms),
Keyframe.ease(1.0, 880.ms),
Keyframe.ease(0.8, 100.ms),
Keyframe.ease(1.04, 400.ms),
Keyframe.ease(1.0, 220.ms),
], initial: 1.0),
KeyframeTrack<double>('angle', [
Keyframe.easeIn(0.0, 580.ms),
Keyframe.easeIn(16.0 * (pi / 180), 125.ms),
Keyframe.easeIn(-16.0 * (pi / 180), 125.ms),
Keyframe.easeIn(16.0 * (pi / 180), 125.ms),
Keyframe.easeIn(0.0, 125.ms),
], initial: 0.0),
],
styleBuilder: (values, style) {
final scale = values.get('scale');
final verticalOffset = values.get('verticalOffset');
final verticalStretch = values.get('verticalStretch');
final angle = values.get('angle');
return style.wrapTransform(
Matrix4.identity()
..scaleByDouble(scale, scale, scale, 1.0)
..translateByDouble(0, verticalOffset, 0, 1)
..scaleByDouble(1, verticalStretch, 1, 1)
..rotateZ(angle),
);
},
);
return Pressable(
onPress: () {
_trigger.value++;
},
child: StyledIcon(icon: CupertinoIcons.heart_fill, style: style),
);
}
}
Comparison
Type | Use Case | Complexity | Control |
---|---|---|---|
Implicit | Simple state transitions | Low | Basic |
Phase | Multi-step/state machine | Medium | Moderate |
Keyframe | Choreographed, multi-track | High | Full |
Notes
- Animation triggers: Phase and keyframe animations use a
Listenable
to start/advance animations. - Configs: Use
AnimationConfig
for timing, easing and delay. - Works with context: Variants like hover/press/dark mode combine well with animations.