Scripted product demos with GSAP instead of video

Type EngineeringState plated

I was exporting a 15-second screen recording of the product for the landing page when the file hit 3.4 MB. On a phone it would letterbox. If the user had prefers-reduced-motion enabled, it would just... play anyway. I could not theme it, could not pause it at a specific scene, could not scrub to the materials tab when a user scrolled there. A video is a frozen artifact. The product it was supposed to show off is alive.

So I deleted the MP4 and built the walkthrough as a scripted GSAP animation. Pure DOM. No video file. Under 40 KB gzipped. Every element in the animation is a real element on the page, styled with the same tokens as the rest of the site.

Here is a simplified version of the result. Pause it below.

W
Workspace
Tasks
Notes
Settings
S
sugam
4 items
Design mockups
In Progress
API endpoints
Todo
Write tests
Todo
Deploy staging
Blocked
Notes
5 items
Review pull request #42
Update dependencies
Fix auth redirect bug
Write migration script
Add error boundaries
Add a note...
New Task
Add a task to your workspace
Create

One cursor. One gsap.timeline(). Seven scenes. The cursor enters, clicks a button, cards stagger in with slight rotation offsets, an overlay appears, the scene cross-fades to a new tab, list items reveal, text types out character by character. When it finishes, it loops.

This is not a recording. It is running in your browser right now.

The problem with one timeline

My first instinct was multiple timelines: one for the cursor, one for the cards, one for the scene transitions. They drifted apart within seconds. If the user switched tabs and came back, the cursor was clicking on empty space where the cards used to be. Pausing one timeline did not pause the others.

The fix was to put everything on a single gsap.timeline() and use labels as the skeleton:

const tl = gsap.timeline({
  repeat: -1,
  repeatDelay: 1.2,
  defaults: { ease: "power3.out" },
});

Labels are named after what the user does, not what animates. "addClick" is the moment the cursor clicks Add. "navClick" is the moment it clicks Notes. Everything else positions relative to these with offsets like "addClick+=0.16".

The important consequence: inserting a new scene between two labels does not require recalculating any downstream timing. Labels absorb the shift.

What makes a cursor feel human

Watch the cursor in the demo. It does not slide across the screen at constant speed. Real hands decelerate into a target, so every movement uses power2.out. Duration scales with distance: short hops take 0.4--0.6s, cross-screen travel takes 0.7--1.0s, entering from off-screen takes a full second.

The click is the hardest part to get right. Two things happen simultaneously: the cursor squeezes to 88% scale on press, and a ripple circle bursts outward from the click point.

function click(position, label) {
  tl.set(ripple, { x: position.x, y: position.y, scale: 0.2, autoAlpha: 0.55 }, label)
    .to(cursor, { scale: 0.88, duration: 0.08, ease: "power2.out" }, label)
    .to(ripple, { scale: 3.4, autoAlpha: 0, duration: 0.54, ease: "power2.out" }, label)
    .to(cursor, { scale: 1, duration: 0.16, ease: "back.out(2.2)" }, `${label}+=0.09`);
}

The back.out(2.2) on the release is the detail that matters. The cursor slightly overshoots back to full size, like a finger lifting off glass. Replace it with power2.out and the click looks mechanical. The overshoot adds exactly the micro-gesture that your eye expects from a real hand.

Scenes cross-fade, state does not

When the cursor clicks "Notes" in the demo, three things happen: the card grid fades out, the list view fades in, and the sidebar highlighting switches. The first two are animated. The third is instant.

tl.to(cardArea, { autoAlpha: 0, duration: 0.24 }, "navClick+=0.08");
tl.to(listView, { autoAlpha: 1, duration: 0.28 }, "navClick+=0.14");

I use autoAlpha instead of opacity everywhere. At zero, GSAP sets visibility: hidden as well, which pulls invisible elements out of the tab order and screen readers. Plain opacity: 0 leaves ghost elements capturing clicks.

The nav highlighting uses classList, not GSAP. State changes should be instant. Animating a nav highlight makes the interface feel laggy, not smooth.

Timing is the whole game

The cards stagger in at 70ms intervals with per-card rotation offsets ([-2, 1.5, -1, 2.5] degrees). Without the rotation, four cards appearing on a grid look like a spreadsheet loading. With slight tilts, they feel dropped onto a desk.

tl.to(cards, {
  autoAlpha: 1, y: 0, scale: 1,
  rotation: (i) => rotations[i],
  stagger: 0.07,
  duration: 0.46,
}, "addClick+=0.16");

List items use a tighter stagger, 60ms, because they are simpler shapes arriving in sequence rather than objects being placed.

The most counterintuitive rule: after every major action, do nothing. After the cards appear, there is 1.4 seconds of dead time before the cursor moves again. After the overlay appears, it sits for a full second. Viewers need time to register what changed. Removing the pauses makes the animation faster but incomprehensible.

The typed text uses ease: "none", constant speed. This is one of the rare cases where linear motion is correct. Eased typing looks like someone accelerating through a sentence.

The loop trap

The first time the animation looped, every card was already visible. The overlay was still showing. The cursor was in the wrong position. The timeline's end state became the second loop's start state.

The fix is a reset block at timeline position 0 that explicitly restores every animated property:

tl.add(() => {
  gsap.set(cursor, { x: CURSOR_START.x, y: CURSOR_START.y, scale: 1 });
  gsap.set(cards, { autoAlpha: 0, y: 26, scale: 0.82, rotation: 0 });
  gsap.set(overlay, { autoAlpha: 0, scale: 0.96, y: 10 });
  gsap.set(typed, { textContent: "" });
}, 0);

Miss one property and you see it immediately on the second loop. I missed rotation the first time and the cards snapped to their tilted positions before animating. A subtle jump that took twenty minutes to find.

The architecture that makes this maintainable

The production version at costumary.com is 1,800 lines across five files:

film-script.ts          → data: scenes, cursor paths, timings
film-primitives.tsx     → DOM: frame, sidebar, cursor SVG
film-panels.tsx         → DOM: each tab's content
film-demo.tsx           → GSAP: the entire choreography
animation-provider.tsx  → React context: play/pause/restart

GSAP code lives in exactly one file. Everything else is inert markup with data-film-* attributes. A designer can rearrange the reference board without touching the timeline. The timeline targets elements by data attribute using gsap.utils.selector(root), so React refs do not need to thread through component boundaries.

The demo in this article follows the same separation in miniature: every animated element has a data-demo-* attribute, and a single useGSAP hook contains the entire choreography.

Responsive scaling is a CSS transform from a fixed design width. The container holds the aspect ratio, the film renders at full size and scales down. Same proportions, same cursor positions, same timing at every viewport width. No media queries.

And you can get crazy with it

Three designers, one canvas, nobody waiting for a turn.

L
Ligma
Pages
Overview
Iterations
Feedback
Archive
Layers
Mobile / Landing
Card
Nav
Welcome
Profile
Detail Card
Hi Chef
What are we cooking?
Featured
Pasta Rosa
Chicken
256
Tofu
121
Salmon
312
Yasmin
live
Photographer & stylist
228 followers
‹ Back
Florian
Plant-based creator
Saved
88
Cooked
24
move this left?
T
Frame
Mobile / Landing
Layout
W
375
H
812
Appearance
Opacity
100%
Fill
#FFFFFF
Xi
Alex
Francis
Mia

When this makes sense and when it does not

I am not going to pretend this replaces video everywhere. It does not.

If your product demo involves real user data, logged-in dashboards, or workflows that change weekly, record a video. Scripting a timeline that mirrors a live product exactly is maintenance you do not want. Every time the UI changes, the animation breaks. A screen recording takes five minutes to redo.

If you are showing a physical product, a person talking, or anything outside the browser, video. Obviously. GSAP animates DOM elements, not reality.

If your team does not have someone comfortable reading a 400-line timeline file, video. This approach has a learning curve. The demos in this article took real engineering time, not drag-and-drop. Or an agent on your behalf.

Where it works: product walkthroughs of a stable UI that you want to feel alive. Onboarding sequences where you need the cursor to hit exact targets. Landing pages where the hero asset is the heaviest thing on the page.

Here is the math. I measured the actual production build for the demos in this article:

ApproachRawCompressedNotes
GSAP core (gsap.min.js)73 KB28 KB gzipShared across all animations on the page
One demo component20 KB5.5 KB gzipThe simple task app above
Both demos together115 KB37 KB gzipEverything in this article
15s 1080p MP4 (H.264, optimized)2-4 MBDoes not compress furtherAlready codec-compressed
15s 1080p GIF8-15 MBDoes not compress furtherGIF is the worst option by far
15s 1080p WebM (VP9)1-2 MBDoes not compress furtherBest video codec, still 30x larger

The full production animation at costumary.com, four scenes with multi-cursor collaboration, sidebar morphing, and typed AI prompts, ships under 40 KB gzipped. A screen recording of the same walkthrough was 3.4 MB as an MP4. That is roughly 85x heavier for content that cannot pause at a labeled scene, cannot respond to prefers-reduced-motion, and cannot adapt to the user's viewport.

The GIF option is worth mentioning because people still try it. A 15-second GIF of a product walkthrough is easily 10 MB. It has no pause button, no accessibility, no scrubbing, and it loops whether the user wants it to or not. The only thing a GIF has going for it is that it autoplays everywhere. GSAP also autoplays everywhere, at 0.3% of the file size.

The real test is whether the animation needs to change with the product. If it does, you will curse this approach within a month. If the animation is the product's story and the story is stable, it is worth the effort.

The production version

The costumary.com hero runs four scenes: a drag-and-drop reference import with progress bars, a multi-cursor collaboration sequence where three users work simultaneously, a sidebar that collapses from labeled nav to icon-only while the workspace expands, and an AI assistant that receives a typed question and streams a contextual response. It supports prefers-reduced-motion with a static fallback state and has play/pause/restart controls.

Same architecture. Same click helper. Same label convention. Under 40 KB where a video would have been three megabytes.

For agents

If you build with Claude Code, Cursor, or similar, you can get the skill here: gsap-choreography.

Thanks for reading. If you enjoyed this piece, consider sharing it.