Short, springy, about the thing touched: RetroReady's motion rules
The four animation pieces behind RetroReady, with their real numbers in Flutter and CSS, and the rule that decided all of them.
- Motion
- Flutter
- Design
RetroReady's design document gives motion one sentence: short and springy, always about the thing touched or found. Every animation in the app had to pass that sentence, and most of the ones we tried did not. Here are the four that did, with the numbers we shipped.
You can try any of these curves, and copy them for your own framework, in Easing Studio.
1. The press: feedback on touch-down, not on release
Every button and choice in the app is a coloured face sitting on a darker lip, like a console button. While a finger is down, the face sinks onto the lip by its depth (4 px), over 160 ms with an ease-out curve, and the phone gives a selection haptic when the tap completes.
The detail that matters is when: the press state starts on tap down. Feedback that waits for the finger to lift feels laggy even when it is fast, because the user's brain already registered the touch.
GestureDetector(
onTapDown: (_) => setState(() => down = true),
onTapUp: (_) => setState(() => down = false),
onTapCancel: () => setState(() => down = false),
onTap: () { HapticFeedback.selectionClick(); onTap(); },
child: Padding(
padding: EdgeInsets.only(top: down ? depth : 0),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
padding: EdgeInsets.only(bottom: down ? 0 : depth), // the lip shows below the face
decoration: BoxDecoration(color: lip, borderRadius: BorderRadius.circular(18)),
child: face,
),
),
)
On the web the same idea is two rules, with the lip drawn as a hard shadow:
.btn { box-shadow: 0 4px 0 var(--lip); transition: transform 160ms ease-out, box-shadow 160ms ease-out; }
.btn:active { transform: translateY(4px); box-shadow: 0 0 0 var(--lip); }
2. The stagger: lists arrive, they do not appear
Result cards fade and rise into place one after another: each waits 90 ms + 70 ms × its index, then runs for 420 ms on an ease-out cubic, rising from 18% of its own height below.
Timer(Duration(milliseconds: 90 + index * 70), () => controller.forward());
// ...
final a = CurvedAnimation(parent: controller, curve: Curves.easeOutCubic);
return FadeTransition(
opacity: a,
child: SlideTransition(position: a.drive(Tween(begin: const Offset(0, 0.18), end: Offset.zero)), child: child),
);
.card { animation: rise 420ms cubic-bezier(0.215, 0.61, 0.355, 1) both; animation-delay: calc(90ms + var(--i) * 70ms); }
@keyframes rise { from { opacity: 0; transform: translateY(18%); } }
Two rules keep a stagger from becoming a wait: keep the step short (70 ms) so the eye reads one motion, not five, and stagger only the first handful of items; past that, content should simply be there.
3. The shake: "not yet", without a word
When the user taps a step that is not ready (onboarding's Continue before a choice is made), the button shakes sideways: a damped sine, three full swings in 420 ms, starting at 9 px and dying out linearly.
// t runs 0 → 1 over 420 ms
final dx = sin(t * pi * 6) * 9 * (1 - t);
return Transform.translate(offset: Offset(dx, 0), child: child);
Then the mascot says what is missing. The shake alone tells you that something is wrong; the words tell you what. Neither is enough on its own.
4. The pop: something good happened
When a tile is selected it pops: over 520 ms it swells by about 12% and settles back, with a small wiggle (at most 0.08 rad, under 5°) that fades out as it goes.
final s = 1 + 0.16 * sin(t * pi) * (1 - t * 0.5);
final r = sin(t * pi * 2) * 0.08 * (1 - t);
return Transform.rotate(angle: r, child: Transform.scale(scale: s, child: child));
On the web the closest single curve is an overshooting bezier, cubic-bezier(0.34, 1.56, 0.64, 1), which the RetroReady website uses for its hover lifts.
The rule behind all four
None of these numbers came from a spec sheet; they came from asking of each animation whether it was about the thing touched or found. The same test shaped the rest of the app. The scanning screen has no spinner: a beam sweeps the user's own photos, because those are the thing being worked on. Onboarding steps slide in the direction of travel and follow the finger, and a swipe that cannot go further rubber-bands instead of doing nothing. The mascot shrinks from the welcome screen into the header rather than cutting away.
If you take one thing from this: make motion answer "what just happened to the thing I touched?" Keep it under half a second, start it on touch-down, and let the user's next tap interrupt it. The curves are the easy part.