Shockwave Animation in Pure Dart
— Flutter, UI Gems — 8 min read
Have you seen the shockwave demo made in SwiftUI by Mykola Harmash on his YouTube channel? If not, go check it out. The tutorial is presented very nicely, demonstrating step by step how he achieved this beautiful animation, progressing from animating a single cell to creating a ripple effect on the entire cluster. Everything fits into approximately 100 lines of SwiftUI code.

I wondered if I could copy this same effect in Flutter. After playing around with AnimationController
, I came up with something very similar. It's not even close to 100 lines, but the final effect is there ✨.
Let me explain how I built it.
Step 1: The Idea
The goal is to create a grid of cells that, when tapped, trigger a ripple effect or a "shockwave", spreading outwards. Cells compress, then expand, and settle back, with the effect diminishing and delaying based on distance from the tap.
Step 2: Project Setup
Before we animate, we need a Flutter project and some core definitions.
void main() { runApp(const App());}
class App extends StatelessWidget { const App({super.key});
@override Widget build(BuildContext context) { return const MaterialApp( title: 'Shockwave Animation Demo', home: Scaffold( body: Center(child: ShockwaveGrid()), ), ); }}
Step 3: Constants, Enums, and Helper Functions
Grid will be adjustable through several parameters defined in the constants.dart
.
const int kColumnCount = 11;const int kRowCount = 9;const double kCellSize = 18;const double kCellSpacing = 6;
const Color kBaseColor = Colors.blue;
The values are copied from the SwiftUI source code to match the animation as much as possible.
enum CellAnimationPhase { identity, compress, expand;
double get scaleAdjustment => switch (this) { CellAnimationPhase.identity => 0, CellAnimationPhase.compress => -0.25, CellAnimationPhase.expand => 0.2, };
double get brightnessAdjustment => switch (this) { CellAnimationPhase.identity => 0, CellAnimationPhase.compress => 0, CellAnimationPhase.expand => -0.2, };}
This enum is crucial. It directly mirrors the SwiftUI version and defines the distinct stages of our animation.
The scaleAdjustment
and brightnessAdjustment
getters provide the modification values for each phase.
On to the helper functions.
calculateDistance
calculates the Euclidean distance between two Offset
points (Flutter's equivalent of CGPoint
).
double calculateDistance( Offset point1, Offset point2,) { return math.sqrt( math.pow(point2.dx - point1.dx, 2) + math.pow(point2.dy - point1.dy, 2), );}
adjustBrightness
changes a color's brightness.
Color adjustBrightness( Color color, double adjustment,) { final hslColor = HSLColor.fromColor(color);
/// Adjust lightness: positive makes it lighter, negative makes it darker. /// Clamp between 0.0 (black) and 1.0 (white). final newLightness = (hslColor.lightness + adjustment).clamp(0.0, 1.0);
return hslColor.withLightness(newLightness).toColor();}
Step 4: Single Cell Animation
Now we can focus on making one cell (CellWidget
) animate through its phases.
CellWidget
is a StatefulWidget
because its appearance (scale, color) changes over time due to animation.
The heart of explicit animations in Flutter is the AnimationController
.
A special animation object generates a new value on every frame while an animation is active.
_controller = AnimationController( vsync: this, duration: const Duration( milliseconds: _compressDurationMs + _expandDurationMs + _settleDurationMs, ),);
_controller.addStatusListener((status) { if (status == AnimationStatus.completed) { widget.onAnimationComplete(); }});
The controller's total duration depends on _waveImpact
.
This means cells less impacted by the wave might have a slightly shorter settling phase.
void _calculateAnimationParameters() { _originDistance = calculateDistance(widget.waveOrigin, _cellCoords); final normalizedDistance = (maxGridDistance > 0.001) ? (_originDistance / maxGridDistance) : 0.0;
// Subtracting the normalized distance from one for the fading effect _waveImpact = (1.0 - normalizedDistance).clamp(0.0, 1.0);
// More efficient delay calculation _delayDuration = Duration( milliseconds: (_originDistance * 70).round(), // 0.07 * 1000 );}
An AnimationController
produces values from 0.0 to 1.0.
We'll use Tweens
to animate specific properties like scale or color.
For a sequence of animations, TweenSequence
is perfect.
void _setupAnimations() { // Update controller duration just in case _controller.duration = Duration( milliseconds: (_compressDurationMs + _expandDurationMs + _settleDurationMs * (1.0 - _waveImpact * 0.5)) .round(), );
final identityScale = 1.0 + CellAnimationPhase.identity.scaleAdjustment * _waveImpact; // Should be 1.0 final compressScaleTarget = 1.0 + CellAnimationPhase.compress.scaleAdjustment * _waveImpact; final expandScaleTarget = 1.0 + CellAnimationPhase.expand.scaleAdjustment * _waveImpact;
_scaleAnimation = TweenSequence<double>([ TweenSequenceItem( tween: Tween<double>( begin: identityScale, end: compressScaleTarget, ).chain(CurveTween(curve: Curves.easeInOut)), weight: _compressDurationMs.toDouble(), ), TweenSequenceItem( tween: Tween<double>( begin: compressScaleTarget, end: expandScaleTarget, ).chain(CurveTween(curve: Curves.easeInOut)), weight: _expandDurationMs.toDouble(), ), TweenSequenceItem( tween: Tween<double>( begin: expandScaleTarget, end: identityScale, ).chain(CurveTween(curve: Curves.easeOutBack)), weight: _settleDurationMs.toDouble(), ), ]).animate(_controller);
final identityColor = adjustBrightness( kBaseColor, CellAnimationPhase.identity.brightnessAdjustment * _waveImpact, ); final compressColorTarget = adjustBrightness( kBaseColor, CellAnimationPhase.compress.brightnessAdjustment * _waveImpact, ); final expandColorTarget = adjustBrightness( kBaseColor, CellAnimationPhase.expand.brightnessAdjustment * _waveImpact, );
_colorAnimation = TweenSequence<Color?>([ TweenSequenceItem( tween: ColorTween(begin: identityColor, end: compressColorTarget), weight: _compressDurationMs.toDouble(), ), TweenSequenceItem( tween: ColorTween(begin: compressColorTarget, end: expandColorTarget), weight: _expandDurationMs.toDouble(), ), TweenSequenceItem( tween: ColorTween(begin: expandColorTarget, end: identityColor), weight: _settleDurationMs.toDouble(), ), ]).animate(_controller);}
Each item (TweenSequenceItem
) defines a part of the animation sequence: its tween
(what values to animate between and with what Curve
) and its weight
(how much of the total controller duration this part takes).
Curves.easeInOut
provides a smooth acceleration and deceleration.
Curves.easeOutBack
is used for the settle, giving it a slight overshoot before settling.
AnimatedBuilder
is an efficient widget for rebuilding part of the UI when an animation's value changes.
It scales (_scaleAnimation.value
) and changes the color (_colorAnimation.value
) of a DecoratedBox
child based on an animation sequence.
@overrideWidget build(BuildContext context) { return GestureDetector( onTap: _controller.isAnimating ? null : () => widget.onTap(_cellCoords), child: AnimatedBuilder( animation: _controller, child: const SizedBox( width: kCellSize, height: kCellSize, ), builder: (context, staticChild) { return Transform.scale( scale: _scaleAnimation.value, // The DecoratedBox applies the changing decoration // to the staticChild. child: DecoratedBox( decoration: BoxDecoration( color: _colorAnimation.value ?? kBaseColor, borderRadius: BorderRadius.circular(4), ), child: staticChild, // The SizedBox ), ); }, ), );}
The animation starts when _controller.forward()
is called.
This will be managed by didUpdateWidget
based on the parent's trigger
.
@overridevoid didUpdateWidget(CellWidget oldWidget) { super.didUpdateWidget(oldWidget); if (widget.trigger != oldWidget.trigger || widget.waveOrigin != oldWidget.waveOrigin) { _calculateAnimationParameters(); // Re-configure tweens because waveImpact might have changed _setupAnimations(); _controller.reset(); if (mounted) { Future.delayed(_delayDuration, () { if (mounted) { _controller.forward(); } }); } }}
Step 5: The Wave
Now, let's arrange the cells and make the animation spread like a wave from the tapped cell.
This involves ShockwaveGrid
and how CellWidget
reacts to changes.
ShockwaveGrid
is a StatefulWidget
holding the _waveOrigin
(where the tap occurred) and _trigger
(an integer that increments to signal a new animation).
int _trigger = 0;bool _isAnimating = false;
/// Default originOffset _waveOrigin = Offset.zero;
void _handleCellTap(Offset cellCoords) { if (_isAnimating) return;
setState(() { _isAnimating = true; _waveOrigin = cellCoords; _trigger++; });}
When a cell is tapped, _handleCellTap
updates the state, causing ShockwaveGrid
to rebuild and pass new waveOrigin
and trigger
values to its CellWidget
children.
The didUpdateWidget
lifecycle method is called when the widget receives new properties from its parent.
We use it to detect if a new shockwave has been triggered.
_calculateAnimationParameters
calculates animation parameters based on the cell's position relative to the wave origin.
It determines:
_originDistance
: The Euclidean distance from thewidget.waveOrigin
to the center of this cell (_cellCoords
).normalizedDistance
: The_originDistance
normalized with respect tomaxGridDistance
. IfmaxGridDistance
is very small (close to zero), this is treated as 0.0 to prevent division by zero or excessively large values._waveImpact
: A value between 0.0 and 1.0 representing the intensity of the wave's effect on this cell. It's calculated as(1.0 - normalizedDistance)
, meaning cells closer to the origin experience a stronger impact (closer to 1.0), and cells further away experience a weaker impact (closer to 0.0). The result is clamped to ensure it stays within the [0.0, 1.0] range._delayDuration
: The animation delay for this cell. It's calculated by multiplying the_originDistance
by 70 milliseconds. This creates a ripple effect where cells further from the origin start their animation later.
Step 6: The Full Grid
ShockwaveGrid
lays out all the CellWidget
s in a 2D grid.
@overrideWidget build(BuildContext context) { /// Define EdgeInsets objects once if kCellSpacing > 0, /// or use EdgeInsets.zero const rowPadding = (kCellSpacing > 0) ? EdgeInsets.only(bottom: kCellSpacing) : EdgeInsets.zero; const columnPadding = (kCellSpacing > 0) ? EdgeInsets.only(right: kCellSpacing) : EdgeInsets.zero; const noPadding = EdgeInsets.zero;
return Column( mainAxisAlignment: MainAxisAlignment.center, children: List.generate(kRowCount, (rowIndex) { final isLastRow = rowIndex == kRowCount - 1; return Padding( padding: isLastRow ? noPadding : rowPadding, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate(kColumnCount, (columnIndex) { final isLastColumn = columnIndex == kColumnCount - 1; return Padding( padding: isLastColumn ? noPadding : columnPadding, child: CellWidget( key: ValueKey('$columnIndex-$rowIndex'), columnIndex: columnIndex, rowIndex: rowIndex, waveOrigin: _waveOrigin, trigger: _trigger, onTap: _handleCellTap, onAnimationComplete: _onAnimationComplete, ), ); }), ), ); }), );}
The brightness adjustment is already integrated into our _colorAnimation
within _CellWidgetState
's _setupAnimations
.
It uses CellAnimationPhase.expand.brightnessAdjustment * _waveImpact
and the adjustBrightness
helper function, perfectly mirroring the scale adjustment logic for a cohesive effect.
Final Result
And there you go: shockwave/ripple effect in pure Dart that works on all platforms!

Challenge: Further Improvements
Now, for a fun challenge to all of you Flutter enthusiasts out there (and perhaps for my future self included), where could we squeeze out even more performance, reduce overhead, or refine the architecture?
Here are a few areas to explore:
- Currently, each cell is its own
CellWidget
. For an 11x9 grid, that's 99 CellWidget instances, each with its own State object,AnimationController
,AnimatedBuilder
,GestureDetector
,Transform
, andDecoratedBox
. While Flutter is efficient, this overhead can add up for significantly larger grids. Can we refactor the grid to use a singleCustomPaint
widget and a singleAnimationController
? - In
_CellWidgetState
,_setupAnimations
is called indidUpdateWidget
every time thetrigger
orwaveOrigin
changes. This recreatesTweenSequence
objects along with theirTweenSequenceItems
and ColorTween/CurveTween chains for each of the 99 cells. While these are lightweight objects, their frequent creation could be an area for micro-optimization.
Good luck to anyone who accepts the challenge!