- Client
- Chickenpie Tutorial
- Year
- 2026
- Services
- Motion · Lottie · Animation · JavaScript
The challenge
Aspiring creatives want to build interactive, tactile-feeling Lottie animations but default to physics engines or naive mouse-tracking that ends up feeling either lifeless or broken.
The approach
Use lottie-web's playSegments() with distance-based hit detection (Math.hypot()) driven by discrete click events instead of continuous mousemove, so the animation travels through frame ranges and settles back to idle instead of snapping and freezing.
The result
- Understand why goToAndStop() causes dead freeze-frame interactions and when to use playSegments() instead
- Build distance-based hit detection using Math.hypot() converted into a character's local coordinate space
- Map click proximity to scoring/reaction tiers the same way production squeeze-toy shelves do
- Know when to reach for a discrete trigger event instead of continuous mousemove for tactile-feeling UI
Here's the end state: click anywhere on a squishy little character, and it visibly reacts — flinches on a light tap, squeezes on a real poke, "pops" with a burst of confetti if you nail the center. No physics engine. No canvas game framework. Just lottie-web, a few frame ranges, and one line of math you probably learned in eighth grade: Math.hypot().
I built this after reading Alexey Kopytin's Smashing Magazine breakdown of a real production squeeze-toy shelf — 21 simultaneous character animations rendered at once, each scored on a 0–100 scale based on how close the click lands to center. I assumed something like that needed a physics engine — Matter.js, rigid-body collisions, the works. It doesn't. The production version does something much dumber and much smarter: it measures distance from a center point with Math.hypot() and maps that single number onto pre-drawn animation frames and a scoring tier. Three attempts to get my own version there. The first two taught me why the third one works.
What you need
A Lottie JSON export (a free "squish toy" character from LottieFiles works, or any rigged character with 3–4 distinct poses), lottie-web loaded via CDN or npm install lottie-web, vanilla JS — no framework required, and basic familiarity with addEventListener and CSS positioning.
Attempt 1: I animated on mousemove and it felt like nothing
My first instinct was to wire the squeeze animation straight to mousemove, updating the frame on every pointer position. The logic seemed sound: closer to center = deeper into the squeeze frames. It ran. It also felt completely dead. The character twitched under the cursor like a flickering light, not like something being touched. The problem wasn't the math — the distance calculation was correct. The problem was the trigger. mousemove fires constantly and carries no intent. A toy that reacts to you merely hovering near it isn't tactile, it's twitchy. I was animating proximity when I should have been animating a gesture.
container.addEventListener("mousemove", (e) => {
const rect = container.getBoundingClientRect();
const dx = e.clientX - (rect.left + rect.width / 2);
const dy = e.clientY - (rect.top + rect.height / 2);
const distance = Math.hypot(dx, dy);
anim.goToAndStop(mapDistanceToFrame(distance), true);
});
Attempt 2: I switched to click, and now it felt disconnected
Reasonable fix — swap mousemove for click, keep the same distance math, and now the reaction only fires on a deliberate tap. This was better — the twitchiness was gone — but goToAndStop is exactly what it says: it stops. Clicking near the edge snapped straight to the "light flinch" frame and froze there. Clicking dead center snapped to the "full squeeze" frame and froze there too. There was no travel, no sense of the toy moving through a squeeze and settling back. That's the part that actually reads as "cheap" to a user — not the visuals, the lack of follow-through. I'd fixed the trigger and broken the motion.
container.addEventListener("click", (e) => {
const distance = getDistanceFromCenter(e, container);
const frame = mapDistanceToFrame(distance);
anim.goToAndStop(frame, true);
});
Attempt 3: segments, not single frames — and the fix actually stuck
The real fix was realizing a squeeze isn't one static pose, it's a range of frames, and the click should choose which range to play through rather than which single frame to land on. Two changes made the difference: playSegments() instead of goToAndStop(), so the animation actually travels through frames instead of snapping; and a complete listener that returns the rig to idle automatically, so it settles instead of freezing mid-squeeze. Now a bullseye click plays the full pop-and-confetti range, an edge click plays a quick flinch, and everything relaxes back to resting on its own. That "settle back" beat is what sells it as tactile — it's the same reason a real stress ball feels satisfying even after you let go.
const sections = {
idle: [0, 40],
flinch: [41, 70],
squeeze: [71, 110],
pop: [111, 150],
};
function playForDistance(distance, radius) {
const t = Math.min(distance / radius, 1); // 0 = center, 1 = edge
let segment;
if (t < 0.15) segment = sections.pop;
else if (t < 0.5) segment = sections.squeeze;
else segment = sections.flinch;
anim.playSegments(segment, true);
anim.addEventListener("complete", () => {
anim.playSegments(sections.idle, true);
}, { once: true });
}
container.addEventListener("click", (e) => {
const rect = container.getBoundingClientRect();
const dx = e.clientX - (rect.left + rect.width / 2);
const dy = e.clientY - (rect.top + rect.height / 2);
const distance = Math.hypot(dx, dy);
playForDistance(distance, rect.width / 2);
});
Pro tip: convert the click position into the character's local space (subtract the container's center, not the page's) before running the distance math. Skip this and every hit registers relative to the browser window instead of the character, which breaks the moment you resize or reposition the element.
Common mistake: don't reach for mousemove because "more responsive feels better." For anything meant to feel deliberate — squeeze toys, buttons, drag targets — a discrete trigger event with segment-based playback almost always reads as more real than continuous frame-chasing. Constant feedback isn't the same as good feedback.
If you get stuck
If your segments won't play at all, check that loop is set to false on the animation instance — a looping animation will fight your playSegments calls and jump back to frame 0 mid-sequence. If the "settle back to idle" never fires, confirm you're listening for complete and not enterFrame, which fires on every single frame and will spam your idle segment dozens of times per second. And if the character feels laggy on mobile, drop lottie.setQuality() to 0.5 — most squeeze-toy rigs don't need full render quality to sell the interaction on a phone screen.
Next up: doing this same distance-based trick across a shelf of multiple characters at once, without tanking frame rate — because animating twenty-one Lottie instances simultaneously is its own separate disaster.





