Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Forked from callmephil/thanos_snap.dart
Last active November 5, 2025 20:41
Show Gist options
  • Select an option

  • Save slightfoot/5943f5a40b09f90f1ecce529832394ec to your computer and use it in GitHub Desktop.

Select an option

Save slightfoot/5943f5a40b09f90f1ecce529832394ec to your computer and use it in GitHub Desktop.
Thanos Disintegrate Animation - by Phil & Simon Lightfoot :: #HumpdayQandA on 5th November 2025 :: https://www.youtube.com/watch?v=uNb9L5JbfIc
// MIT License
//
// Copyright (c) 2025 Phil(@callmephil) & Simon Lightfoot
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
// The effect is like: https://elgoog.im/thanos/
void main() => runApp(const MaterialApp(home: ThanosSnapDemo()));
class ThanosSnapDemo extends StatefulWidget {
const ThanosSnapDemo({super.key});
@override
State<ThanosSnapDemo> createState() => _ThanosSnapDemoState();
}
class _ThanosSnapDemoState extends State<ThanosSnapDemo> {
bool snapped = false;
void _toggleSnap() {
setState(() => snapped = !snapped);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 20),
ElevatedButton(
onPressed: _toggleSnap,
child: const Text('Snap'),
),
const SizedBox(height: 20),
Expanded(
child: AnimatedThanosSnap(
text:
'Hmm. We\'re having trouble finding that site. '
'Please check the URL or try again later. '
'If the problem persists, contact support.',
snapped: snapped,
),
),
],
),
),
),
);
}
}
class AnimatedThanosSnap extends StatefulWidget {
const AnimatedThanosSnap({
super.key,
required this.text,
required this.snapped,
});
final String text;
final bool snapped;
@override
State<AnimatedThanosSnap> createState() => _AnimatedThanosSnapState();
}
class _AnimatedThanosSnapState extends State<AnimatedThanosSnap>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late List<CharacterAnimation> _charAnimations;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 2000),
vsync: this,
);
_initializeAnimations();
if(!widget.snapped) {
_controller.forward(from: 0);
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void didUpdateWidget(AnimatedThanosSnap oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.text != widget.text) {
_initializeAnimations();
}
if (oldWidget.snapped != widget.snapped) {
_controller.forward(from: 0);
}
}
void _initializeAnimations() {
final chars = widget.text.split('');
final random = Random(42);
final groups = List.generate(
chars.length,
(i) => random.nextInt(6),
);
_charAnimations = List.generate(chars.length, (i) {
final charRandom = Random(i);
return CharacterAnimation(
char: chars[i],
dx: (charRandom.nextDouble() - 0.5) * 200,
dy: charRandom.nextDouble() * 300 * (charRandom.nextBool() ? 1 : -1),
blurRadius: charRandom.nextDouble() * 4 + 2,
group: groups[i],
);
});
}
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: AnimatedTextCustomPainter(
animation: _controller,
text: widget.text,
charAnimations: _charAnimations,
snapped: widget.snapped,
),
size: Size.infinite,
);
}
}
class CharacterAnimation {
CharacterAnimation({
required this.char,
required this.dx,
required this.dy,
required this.blurRadius,
required this.group,
});
final String char;
final double dx;
final double dy;
final double blurRadius;
final int group;
}
class AnimatedTextCustomPainter extends CustomPainter {
AnimatedTextCustomPainter({
required this.animation,
required this.text,
required this.charAnimations,
required this.snapped,
}) : super(repaint: animation);
final Animation<double> animation;
final String text;
final List<CharacterAnimation> charAnimations;
final bool snapped;
@override
void paint(Canvas canvas, Size size) {
const textStyle = TextStyle(
fontSize: 36.0,
color: Colors.white,
fontWeight: FontWeight.bold,
);
// First, layout the text to get natural character positions
final textPainter = TextPainter(
text: TextSpan(text: text, style: textStyle),
textDirection: TextDirection.ltr,
textAlign: TextAlign.center,
);
textPainter.layout(maxWidth: size.width);
// Get the starting position to center the text
final startX = (size.width - textPainter.width) / 2;
final startY = (size.height - textPainter.height) / 2;
// Draw each character individually with its animation
for (int i = 0; i < charAnimations.length; i++) {
final charAnim = charAnimations[i];
// Calculate the natural position of this character
final charOffset = textPainter.getOffsetForCaret(
TextPosition(offset: i),
Rect.zero,
);
// Calculate animation progress for this character's group
const groupDelay = 0.06; // 120ms / 2000ms
const moveDuration = 0.4; // 800ms / 2000ms
const fadeDuration = 0.6; // 1200ms / 2000ms
const blurDuration = 0.2; // 400ms / 2000ms
const scaleDuration = 0.4; // 800ms / 2000ms
final groupStart = charAnim.group * groupDelay;
// Calculate individual animation values
final animationValue = animation.value;
final moveProgress = ((animationValue - groupStart) / moveDuration).clamp(0.0, 1.0);
final fadeProgress = ((animationValue - groupStart) / fadeDuration).clamp(0.0, 1.0);
final blurProgress = ((animationValue - groupStart) / blurDuration).clamp(0.0, 1.0);
final scaleProgress = ((animationValue - groupStart) / scaleDuration).clamp(0.0, 1.0);
// Apply easing curve to move
final easedMove = Curves.easeInOutCubic.transform(moveProgress);
// Calculate current offset based on snap direction using lerpDouble
final currentDx = snapped
? ui.lerpDouble(0, charAnim.dx, easedMove)!
: ui.lerpDouble(charAnim.dx, 0, easedMove)!;
final currentDy = snapped
? ui.lerpDouble(0, charAnim.dy, easedMove)!
: ui.lerpDouble(charAnim.dy, 0, easedMove)!;
// Calculate opacity using lerpDouble
final opacity = snapped
? ui.lerpDouble(1.0, 0.0, fadeProgress)!
: ui.lerpDouble(0.0, 1.0, fadeProgress)!;
// Calculate blur using lerpDouble
final currentBlur = snapped
? ui.lerpDouble(0, charAnim.blurRadius, blurProgress)!
: ui.lerpDouble(charAnim.blurRadius, 0, blurProgress)!;
// Calculate scale using lerpDouble
final currentScale = snapped
? ui.lerpDouble(1.0, 0.8, scaleProgress)!
: ui.lerpDouble(0.8, 1.0, scaleProgress)!;
if (opacity <= 0) continue;
// Save canvas state
canvas.save();
// Translate to character position
final finalX = startX + charOffset.dx + currentDx;
final finalY = startY + charOffset.dy + currentDy;
canvas.translate(finalX, finalY);
// Apply scale
canvas.scale(currentScale, currentScale);
final foreground =
Paint() //
..color = Colors.white.withValues(alpha: opacity);
// Apply blur if needed
if (currentBlur > 0) {
foreground.maskFilter = ui.MaskFilter.blur(
ui.BlurStyle.normal,
ui.Shadow.convertRadiusToSigma(currentBlur),
);
}
// Paint the character
final paragraphBuilder =
ui.ParagraphBuilder(
textStyle.getParagraphStyle(
textAlign: TextAlign.start,
textScaler: TextScaler.noScaling,
textDirection: TextDirection.ltr,
),
)
..pushStyle(ui.TextStyle(foreground: foreground))
..addText(charAnim.char);
final paragraph = paragraphBuilder.build()
..layout(ui.ParagraphConstraints(width: double.infinity));
canvas.drawParagraph(paragraph, Offset.zero);
canvas.restore();
}
}
@override
bool shouldRepaint(AnimatedTextCustomPainter oldDelegate) {
return oldDelegate.snapped != snapped || //
oldDelegate.charAnimations != charAnimations;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment