Skip to Content
DocsGuidesAnimations

Animations

Prerequisites: This guide assumes familiarity with Styling and Dynamic Styling (variants). Animations build on both concepts.

Flutter gives you AnimatedContainer, TweenAnimationBuilder, and explicit AnimationController — but wiring them into your styling logic takes boilerplate. Mix lets you attach animations directly to your styles, so motion stays where your design decisions live.

Mix offers three animation types, each suited to a different level of control.

Choosing the right type

ImplicitPhaseKeyframe
Best forSimple A→B transitionsMulti-step sequencesFull timelines with parallel tracks
ComplexityLowMediumHigh
ControlAutomatic interpolationOrdered steps with per-step configPer-property keyframes with custom easing
TriggerState change or variantValueNotifier (or auto-loop)ValueNotifier (or auto-loop)
ExampleColor fade on hoverTap → squash → bounce → restScale + opacity + color changing in sync

Rule of thumb: start with implicit. Move to phase when you need ordered steps. Reach for keyframe when you need parallel tracks with independent timing.


Animation configs

Every animation type uses an AnimationConfig to control timing and easing. You create one with a named constructor — either curve-based (fixed duration) or spring-based (physics-driven).

Curve-based configs

All curve configs take a duration and an optional delay:

// Standard easing — good default for most transitions .ease(300.ms) // Starts fast, decelerates to a stop — natural for elements settling into place .decelerate(200.ms) // Smooth sinusoidal ease-out .easeOutSine(400.ms) // With a delay before starting .easeInOut(500.ms, delay: 200.ms)

Available curves include: linear, ease, easeIn, easeOut, easeInOut, decelerate, bounceIn, bounceOut, elasticOut, and many more — each matching a Flutter Curves constant.

Spring-based configs

Springs produce natural, physics-driven motion. Mix offers three ways to configure them:

// Duration-based spring — set duration and bounciness (0 = no bounce, 1 = very bouncy) .spring(800.ms, bounce: 0.6) // Damping-ratio spring — ratio of 1.0 is critically damped (no overshoot), // below 1.0 oscillates, above 1.0 is overdamped .springWithDampingRatio(800.ms, ratio: 0.3) // Raw physics spring — direct control over mass, stiffness, and damping force .springDescription(mass: 1.0, stiffness: 180.0, damping: 12.0)

Implicit Animations

When something in your UI changes — a color on hover, a size on tap, or switching to dark mode — you often want that change to feel smooth rather than instant. Implicit animations do exactly that: whenever a value changes from A to B, Mix interpolates between them so the transition feels natural.

How to use: Add .animate(AnimationConfig) to any Style or Styler. It works with state changes (e.g. setState) and with variants like onHovered, onPressed, and onDark.

Case 1: State-triggered

In this example a square grows each time you tap it. The .spring(1.s, bounce: 0.6) config creates a bouncy overshoot effect on each size change.

int _counter = 2; final box = BoxStyler() .color(Colors.deepPurple) .size(_counter * 10, _counter * 10) .animate(.spring(1.s, bounce: 0.6)); return Pressable( onPress: () => setState(() => _counter += 3), child: box(), );
Resolving preview metadata...

Case 2: Variant-triggered

Animations also work with variants. When the widget enters a variant (e.g. hovered), the style animates toward the variant’s target style; .animate(...) controls how that transition runs.

final box = BoxStyler() .color(Colors.black) .size(100, 100) .borderRounded(10) .scale(1) .onHovered(.color(Colors.blue).scale(1.5)) .animate(.spring(800.ms)); return box();
Resolving preview metadata...

Phase Animations

Sometimes you need more than a simple A→B transition: a button that squashes on press then bounces back, or a flow that goes through several distinct steps and returns to the start. Phase animations are built for that. You define a set of phases, and for each one you choose the style and how the transition into that phase should animate.

How to use: Use .phaseAnimation(...) to define your phases and what style and animation config each one uses. Optionally pass a trigger — with it, the animation runs only when that value changes; without it, it loops on its own.

Case: Tap → compress → expand → initial

Below, a square reacts to tap: it compresses, then expands, then returns to its original size, moving through each phase in order. Each phase has its own animation config — the compress uses .decelerate(200.ms) for a quick settle, while the return to initial uses a low damping ratio spring for a bouncy finish.

enum AnimationPhases { initial, compress, expanded } final _isExpanded = ValueNotifier(false); final box = BoxStyler() .color(Colors.deepPurple) .height(100) .width(100) .borderRounded(40) .phaseAnimation( trigger: _isExpanded, phases: AnimationPhases.values, styleBuilder: (phase, style) => switch (phase) { .initial => style.scale(1), .compress => style.scale(0.75).color(Colors.red.shade800), .expanded => style.scale(1.25).borderRounded(20).color(Colors.yellow.shade300), }, configBuilder: (phase) => switch (phase) { .initial => .springWithDampingRatio(800.ms, ratio: 0.3), .compress => .decelerate(200.ms), .expanded => .decelerate(100.ms), }, ); return Pressable( onPress: () => _isExpanded.value = !_isExpanded.value, child: box(), );
Resolving preview metadata...

Keyframe Animations

When you want full control — a thumb that scales and slides, a pop-in that combines scale, opacity, and offset, or any timeline where several values change in sync — keyframe animations are the tool. You build a timeline with tracks and keyframes, and Mix drives your style from those values.

How to use: With .keyframeAnimation(...) you define a timeline of tracks. Each track has keyframes that run in sequence; tracks run in parallel. A style builder then maps the current track values onto your style. Optionally pass a trigger so the animation runs only when that value changes; without it, the animation loops on its own.

Case 1: Simple toggle (scale + width)

A single trigger drives two tracks (scale and width). The style builder reads both values and applies them together. Each track has its own keyframes with independent values and durations — the scale track uses two keyframes while the width track uses three.

final _trigger = ValueNotifier(false); final box = BoxStyler() .height(30) .width(40) .color(Colors.deepPurpleAccent) .shapeStadium() .keyframeAnimation( trigger: _trigger, timeline: [ KeyframeTrack('scale', [ .easeOutSine(1.25, 200.ms), .elasticOut(0.85, 500.ms), ], initial: 0.85), KeyframeTrack<double>('width', [ .decelerate(50, 100.ms), .ease(80, 300.ms), .elasticOut(40, 500.ms), ], initial: 40), ], styleBuilder: (values, style) => style.scale(values.get('scale')).width(values.get('width')), ); return Center( child: Pressable( onPress: () { setState(() { _trigger.value = !_trigger.value; }); }, child: box(), ), );
Resolving preview metadata...

Case 2: Loop animation (scale + color + opacity)

Several tracks run in parallel to create one looping animation. Since no trigger is passed, the animation repeats continuously.

This example shows two advanced patterns:

  • Custom tweens: The color track passes tweenBuilder: ColorTween.new so Mix can interpolate between Color values correctly. You can use any Tween subclass this way.
  • .wrap() for widget-level effects: Opacity cannot be interpolated as a style property, so .wrap(WidgetModifierConfig.opacity(...)) wraps the widget in Flutter’s Opacity widget. The .transform() call similarly applies a Matrix4 scale — useful when you need to animate a raw transform matrix rather than using the .scale() shorthand.
final box = BoxStyler() .size(60, 60) .alignment(.centerLeft) .keyframeAnimation( timeline: [ KeyframeTrack('scale', [ .springWithBounce(1.0, 2000.ms, bounce: 0.5), ], initial: 0.0), KeyframeTrack<Color>( 'color', [.easeInOut(Colors.deepPurpleAccent, 2000.ms)], initial: Colors.grey.shade300, tweenBuilder: ColorTween.new, ), KeyframeTrack('opacity', [.easeIn(1.0, 500.ms)], initial: 0.0), ], styleBuilder: (values, style) { final scale = values.get('scale'); final opacity = values.get('opacity'); return style .transform(Matrix4.diagonal3Values(scale, scale, 1.0)) .color(values.get('color')) .wrap(WidgetModifierConfig.opacity(opacity)); }, ); return box();
Resolving preview metadata...

Common pitfalls

Animation not replaying on tap. Phase and keyframe animations use a ValueNotifier as a trigger. The animation runs when the value changes, not when it’s set. If you set it to the same value, nothing happens. Toggle it (!value) or increment a counter.

Spring animations that never settle. A low damping ratio (e.g. ratio: 0.1) or high bounce (e.g. bounce: 0.9) can cause long oscillations. Start with bounce: 0.3 or ratio: 0.8 and adjust from there.

Forgetting tweenBuilder for non-numeric types. Tracks that animate Color, Offset, or other non-numeric types need a tweenBuilder (e.g. ColorTween.new). Without it, the interpolation will fail at runtime.

Using .animate() with .phaseAnimation() or .keyframeAnimation(). Implicit animations (.animate()) and explicit animations (phase/keyframe) are separate systems. Don’t combine them on the same styler — pick one.

See Also

  • Dynamic Styling — variants that animations often drive (hover, press, dark mode)
  • Widget Modifiers — animate modifier properties like opacity and transforms
  • Styling — core Styler pattern and fluent chaining