Skip to content
CHICKENPIE.Start a project →
Making things

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.

Read the paper ↓
Mr. Chicken at a nighttime desk poking a laptop screen showing a bouncy Lottie squeeze-toy animation
A PAPER FROM THE DESK
THE DETAILS
At a glance
CategoryMaking things
PublishedAug 24, 2026
Read time5 min
Covered here
  • Design
  • AI Tools
  • Productivity
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);
});
Diagram showing a jittery mousemove cursor trail near a squeeze-toy character, illustrating lifeless proximity-based animation

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);
});
Diagram of a squeeze-toy character frozen mid-pose after a single click, showing a filmstrip with only one static frame highlighted

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);
});
Diagram of the squeeze-toy character mid-squeeze with confetti, showing a smooth filmstrip sequence and a looping arrow settling back to idle

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.

Share
FROM IDEA TO SOMETHING REAL

What are you making?

Graphics, websites, and curious experiments. Let’s talk about yours.

Talk to the studio ↗
/feed/lottie-squeeze-toy-watch-me-fail

Have a project in mind?

Tell us what you are making and what you need. An unfinished idea is welcome too.

Start a project →

Another paper for you.

Mr. Chicken at a desk pointing at a laptop screen showing a colorful AI-generated image, with reference photos stacked nearby

6 AI Art Tool Tips That Stop Your Marketing Images From Screaming "AI"

6 quick tips for using AI art tools like Nano Banana and Gemini 3 to make marketing visuals that don't scream "AI" — prompting, texture, consistency, and a pre-publish check.

Open paper
Chickenpie mascot Mr. Chicken pointing at a glowing screen showing chaotic color swatches organizing into labeled design system tokens

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

A hands-on 9-step tutorial: wire a Primitives, Semantic, and Brand variable system in Figma so your whole file updates from one place, light mode and dark mode included.

Open paper
Mr. Chicken at a cluttered home studio desk, working on an AI image generation interface with an uncanny AI-slop draft on one side and a clean composited image on the other

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

Nine steps to art-direct AI image generation like a photographer, not a wishlist — light, texture, junk tokens, and the two spots (hands, signage) that always give it away.

Open paper
Mr. Chicken at a cluttered design studio desk working on a product composite in Photoshop

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

Drop a product into any background in 10 steps using Photoshop Generative Fill — with matching light, shadow, and zero obvious AI tells.

Open paper
Mr. Chicken at a studio desk with two monitors — Figma design frames on the left, live Framer website on the right with a glowing Publish button

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

Stop waiting for a developer. Here's an 8-step walkthrough for taking your Figma design into Framer and publishing a live website today — no code required.

Open paper
Mr. Chicken at a designer workstation at night, surrounded by multiple screens showing a grid of AI-generated character portraits — some faces matching, some wildly mismatched

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

Most tutorials skip the step that makes --cref actually work. Here's what failed first, what failed second, and the gotcha that kills most reference image attempts.

Open paper