I Tried to Build a Clickable Lottie Squeeze Toy. It Took Three Tries to Feel Right..
Watch three failed attempts to build an interactive Lottie squeeze toy — from a dead mousemove hack to a broken freeze-frame click, to the segment-based fix that finally feels tactile.
| Category | Design |
|---|---|
| Published | 24·08·2026 |
| Read time | 5 min |
- Design
- AI Tools
- Productivity
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.

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.

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.

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.
Done reading? There’s more where this came from.
Related posts

Stop Hardcoding Colors: Build a Real Design System with Figma Variables

How to Art-Direct AI Images So They Stop Looking Like AI

How to Composite a Product into Any Background Using Photoshop Generative Fill

Your Figma File Is Already a Website. Here's How to Publish It in 8 Steps.

I Tried to Keep My AI Character Consistent Across 10 Scenes. Here's What Actually Broke.

