<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sugam Panthi</title>
    <description>The latest articles on DEV Community by Sugam Panthi (@vein05).</description>
    <link>https://dev.to/vein05</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3839377%2F8ba40800-953f-42a0-9f4e-811bbb57f2eb.jpeg</url>
      <title>DEV Community: Sugam Panthi</title>
      <link>https://dev.to/vein05</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vein05"/>
    <language>en</language>
    <item>
      <title>Part 2: Replacing a 3.4MB video with 40kb of scripted GSAP animations: adding a camera</title>
      <dc:creator>Sugam Panthi</dc:creator>
      <pubDate>Wed, 27 May 2026 17:24:51 +0000</pubDate>
      <link>https://dev.to/vein05/part-2-replacing-a-34mb-video-with-40kb-of-scripted-gsap-animations-adding-a-camera-1ak2</link>
      <guid>https://dev.to/vein05/part-2-replacing-a-34mb-video-with-40kb-of-scripted-gsap-animations-adding-a-camera-1ak2</guid>
      <description>&lt;p&gt;&lt;a href="https://spanthi.com/blog/gsap-choreography" rel="noopener noreferrer"&gt;Part one&lt;/a&gt; reached &lt;strong&gt;6,000&lt;/strong&gt; views on r/webdev in two days. I did not expect that. I wrote it because I thought replacing a 3.4 MB video with 40 KB of DOM animation was interesting enough to share. Turns out a lot of people are thinking about the same problem.&lt;/p&gt;

&lt;p&gt;The comments were better than the post. People asked about SEO indexing, screen reader behavior, &lt;code&gt;prefers-reduced-motion&lt;/code&gt; fallbacks, whether GSAP is even necessary, and several pointed out it was missing something. They were right.&lt;/p&gt;

&lt;p&gt;Part one had a cursor clicking through scenes on a flat stage. Everything happened at the same zoom level, the same distance from the viewer. Watch a real product demo video and you notice something different: the camera moves. It zooms into the action when something important happens, follows the cursor through a workflow, and pulls back when the scene changes. That is the difference between a slide deck and a directed film.&lt;/p&gt;

&lt;p&gt;This post adds the camera. Same 40 KB budget. No video files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://spanthi.com/blog/gsap-choreography-part-2" rel="noopener noreferrer"&gt;See the full post with 4 interactive demos, side-by-side comparisons, and a live FPS stress test&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The zoom wrapper problem
&lt;/h2&gt;

&lt;p&gt;The first thing you try is &lt;code&gt;gsap.to(frame, { scale: 1.4 })&lt;/code&gt;. It works for half a second, then you notice your responsive scaling is gone. The film frame uses &lt;code&gt;transform: scale(filmScale)&lt;/code&gt; to fit its container width, and GSAP just overwrote it. Both write to the same CSS &lt;code&gt;transform&lt;/code&gt; property. They cannot coexist on the same element.&lt;/p&gt;

&lt;p&gt;The fix is a wrapper inside the frame that GSAP owns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;film&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`scale(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;filmScale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;film&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;zoom&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;origin-center&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* All scene content */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Cursor&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Outside the zoom wrapper */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GSAP animates &lt;code&gt;data-film-zoom&lt;/code&gt;. The responsive scale on the outer frame is untouched. The cursor lives outside the zoom wrapper, so it stays the same size while content scales up around it. This took me an embarrassing amount of time to figure out. Two divs. That was the whole fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pan math
&lt;/h2&gt;

&lt;p&gt;Zooming into the center of the frame is easy. Zooming into a specific element, like a Pinterest pin the user is about to right-click, is the real problem. You need to translate the zoom wrapper so the target ends up centered in the visible viewport:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ZOOM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.45&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;panTo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FILM_WIDTH&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ZOOM&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FILM_HEIGHT&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ZOOM&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At zoom 1.0, no translation needed. At zoom 1.45, every pixel away from center needs 0.45 times that distance in translation to compensate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stay zoomed, pan to follow
&lt;/h2&gt;

&lt;p&gt;The biggest mistake in my first attempt was zooming in for one interaction, zooming out, zooming in for the next, zooming out again. It looked like a PowerPoint with a budget zoom transition.&lt;/p&gt;

&lt;p&gt;The correct pattern: zoom in once, stay zoomed, pan to follow the cursor through the whole interaction sequence, zoom out once when changing scenes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Zoom in on right-click&lt;/span&gt;
&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ZOOM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;panPin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;panPin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.65&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expo.out&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zoomIn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Context menu while zoomed&lt;/span&gt;
&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;autoAlpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.16&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zoomIn+=0.35&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Camera pans to follow cursor to the save button (still zoomed)&lt;/span&gt;
&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;panSave&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;panSave&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sine.inOut&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;panToSave&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Only zoom out when done&lt;/span&gt;
&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sine.inOut&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zoomOut&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;a href="https://spanthi.com/blog/gsap-choreography-part-2" rel="noopener noreferrer"&gt;See this running as a live interactive demo in the full post&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Easing is not one-size-fits-all
&lt;/h2&gt;

&lt;p&gt;Part one used &lt;code&gt;power2.out&lt;/code&gt; for almost everything. Fine for a flat demo. But when you add camera movement, different motion types need different eases or the whole thing feels off.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Motion&lt;/th&gt;
&lt;th&gt;Ease&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Camera pan&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sine.inOut&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Smoothest curve. Feels like a real camera on a dolly.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dramatic zoom-in&lt;/td&gt;
&lt;td&gt;&lt;code&gt;expo.out&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fast start, long deceleration. Whoosh then settle.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cursor movement&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sine.inOut&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Natural hand acceleration.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fade in&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sine.out&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Decelerates into visibility.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Click squeeze&lt;/td&gt;
&lt;td&gt;&lt;code&gt;power2.out&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Snappy press response.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Click release&lt;/td&gt;
&lt;td&gt;&lt;code&gt;back.out(2.2)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Slight overshoot. Feels physical.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typed text&lt;/td&gt;
&lt;td&gt;&lt;code&gt;none&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Real typing does not ease.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The single biggest upgrade was replacing &lt;code&gt;power2.inOut&lt;/code&gt; with &lt;code&gt;sine.inOut&lt;/code&gt; on camera moves. &lt;code&gt;power2&lt;/code&gt; has a noticeable acceleration kick that feels mechanical at large scale. &lt;code&gt;sine&lt;/code&gt; has no perceptible kick at any point in the curve.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cursor is never dead
&lt;/h2&gt;

&lt;p&gt;With camera movement, there are moments where the scene shifts but the cursor is frozen. Those moments break the illusion. Every camera move should have a simultaneous cursor movement at the same timeline label:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BAD: cursor is dead during zoom&lt;/span&gt;
&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;panX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;panY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zoomIn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// GOOD: cursor drifts toward its next target during zoom&lt;/span&gt;
&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;panX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;panY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expo.out&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zoomIn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.65&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sine.out&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zoomIn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cursor does not need to arrive. A 20-30% drift toward the next target is enough. Calm, but never dead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://spanthi.com/blog/gsap-choreography-part-2" rel="noopener noreferrer"&gt;The full post has a side-by-side comparison of frozen vs drifting cursor during zoom&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Graceful loops
&lt;/h2&gt;

&lt;p&gt;Part one's loop was abrupt. With camera movement the problem is worse because the zoom jumps from 1.4x to 1.0 instantaneously.&lt;/p&gt;

&lt;p&gt;The fix is an outro:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sine.inOut&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;outro&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;offScreenX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;offScreenY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sine.in&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;outro+=0.1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// breathing room&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a timing rule: first drafts are always too slow. Cut 20-30% from your timing after the first working version.&lt;/p&gt;

&lt;h2&gt;
  
  
  SEO, theming, and the stuff video cannot do
&lt;/h2&gt;

&lt;p&gt;View source on a &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; tag and Google sees &lt;code&gt;&amp;lt;video src="demo.mp4"&amp;gt;&lt;/code&gt;. A black box. View source on a GSAP demo and every button label, menu item, and heading is indexable text. I have not run a controlled ranking test, but logically: real DOM text beats an opaque binary blob.&lt;/p&gt;

&lt;p&gt;Beyond indexing, DOM demos change when the product does. Marketing changes a button label? Change a text string. Brand color update? One hex value. You want the demo buttons to actually convert? Wire them to a real signup modal. A video cannot do any of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accessibility
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;prefers-reduced-motion: reduce&lt;/code&gt; is enabled, skip the entire timeline and render the resting state. GSAP's &lt;code&gt;autoAlpha&lt;/code&gt; sets both &lt;code&gt;opacity: 0&lt;/code&gt; and &lt;code&gt;visibility: hidden&lt;/code&gt;, removing elements from tab order and screen readers. Every hidden element uses &lt;code&gt;autoAlpha&lt;/code&gt;, not &lt;code&gt;opacity&lt;/code&gt;, so screen readers only see what is currently visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does it need GSAP?
&lt;/h2&gt;

&lt;p&gt;For simple animations, no. But the web clipper demo has roughly 50 coordinated animations across two scenes with camera movement. GSAP gives you labels (named beats), &lt;code&gt;repeat: -1&lt;/code&gt; with reset logic, &lt;code&gt;gsap.utils.selector(root)&lt;/code&gt; for data-attribute queries, a full easing library, and stagger. You could build all of this without GSAP. It would take longer and produce worse results.&lt;/p&gt;

&lt;p&gt;GSAP core is 28 KB gzipped. If you are orchestrating a 50-animation cinematic demo, it is infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent reality
&lt;/h2&gt;

&lt;p&gt;Five demos across the site, roughly 4,000 lines of choreography code. I wrote maybe 60% of that. An AI agent wrote the other 40%.&lt;/p&gt;

&lt;p&gt;My 60% was the directing: deciding what to zoom into, choosing eases after watching both options, noticing the cursor felt dead during zooms, saying "the zoom-out is 200ms too slow." The agent cannot watch the animation and feel that something is off.&lt;/p&gt;

&lt;p&gt;The agent's 40% was the implementation: 1,500 lines of timeline code, DOM elements, click helpers, reset blocks. Pattern-based work following rules I defined in a &lt;a href="https://github.com/Costumary/gsap-choreography" rel="noopener noreferrer"&gt;skill file&lt;/a&gt;. The patterns are stable enough that the output is consistent across different demos. But the agent does not know when something looks wrong. That part is still mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance
&lt;/h2&gt;

&lt;p&gt;The web clipper demo has 50 &lt;code&gt;.to()&lt;/code&gt; calls but only 3-5 are actively tweening at any frame. Every &lt;code&gt;.to()&lt;/code&gt; writes to &lt;code&gt;transform&lt;/code&gt; or &lt;code&gt;opacity&lt;/code&gt;, both compositor-friendly. &lt;strong&gt;&lt;a href="https://spanthi.com/blog/gsap-choreography-part-2" rel="noopener noreferrer"&gt;The full post has a live stress test with an FPS counter&lt;/a&gt;&lt;/strong&gt; where you can push from 8 to 48 concurrent elements and watch the frame rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The updated math
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Compressed&lt;/th&gt;
&lt;th&gt;Camera?&lt;/th&gt;
&lt;th&gt;Indexable?&lt;/th&gt;
&lt;th&gt;Accessible?&lt;/th&gt;
&lt;th&gt;Themeable?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GSAP (flat, part one)&lt;/td&gt;
&lt;td&gt;~37 KB&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GSAP (pan-zoom, part two)&lt;/td&gt;
&lt;td&gt;~40 KB&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15s MP4 (H.264)&lt;/td&gt;
&lt;td&gt;2-4 MB&lt;/td&gt;
&lt;td&gt;Baked in&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15s WebM (VP9)&lt;/td&gt;
&lt;td&gt;1-2 MB&lt;/td&gt;
&lt;td&gt;Baked in&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15s GIF&lt;/td&gt;
&lt;td&gt;8-15 MB&lt;/td&gt;
&lt;td&gt;Baked in&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Adding the camera added roughly 3 KB to the bundle. All five production demos across &lt;a href="https://costumary.com" rel="noopener noreferrer"&gt;costumary.com&lt;/a&gt; ship under 80 KB gzipped combined. The equivalent in video would be 15-20 MB.&lt;/p&gt;

&lt;p&gt;Part one was proof you could replace a video. Part two is the part where it starts to feel directed instead of recorded.&lt;/p&gt;

&lt;p&gt;The upfront cost is real. The ongoing cost is near zero. Updates are code changes, not re-recordings.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Full post with interactive demos:&lt;/strong&gt; &lt;a href="https://spanthi.com/blog/gsap-choreography-part-2" rel="noopener noreferrer"&gt;spanthi.com/blog/gsap-choreography-part-2&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production examples:&lt;/strong&gt; &lt;a href="https://costumary.com" rel="noopener noreferrer"&gt;costumary.com&lt;/a&gt; | &lt;a href="https://costumary.com/web-clipper" rel="noopener noreferrer"&gt;costumary.com/web-clipper&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open source agent skill:&lt;/strong&gt; &lt;a href="https://github.com/Costumary/gsap-choreography" rel="noopener noreferrer"&gt;gsap-choreography&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>design</category>
      <category>animation</category>
      <category>claude</category>
    </item>
    <item>
      <title>Part 2: Replacing a 3.4MB video with 40kb of scripted GSAP animations: adding a camera</title>
      <dc:creator>Sugam Panthi</dc:creator>
      <pubDate>Wed, 27 May 2026 17:24:50 +0000</pubDate>
      <link>https://dev.to/vein05/part-2-replacing-a-34mb-video-with-40kb-of-scripted-gsap-animations-adding-a-camera-1cdj</link>
      <guid>https://dev.to/vein05/part-2-replacing-a-34mb-video-with-40kb-of-scripted-gsap-animations-adding-a-camera-1cdj</guid>
      <description>&lt;p&gt;&lt;a href="https://spanthi.com/blog/gsap-choreography" rel="noopener noreferrer"&gt;Part one&lt;/a&gt; reached &lt;strong&gt;69,000&lt;/strong&gt; views on r/webdev in two days. I did not expect that. I wrote it because I thought replacing a 3.4 MB video with 40 KB of DOM animation was interesting enough to share. Turns out a lot of people are thinking about the same problem.&lt;/p&gt;

&lt;p&gt;The comments were better than the post. People asked about SEO indexing, screen reader behavior, &lt;code&gt;prefers-reduced-motion&lt;/code&gt; fallbacks, whether GSAP is even necessary, and several pointed out it was missing something. They were right.&lt;/p&gt;

&lt;p&gt;Part one had a cursor clicking through scenes on a flat stage. Everything happened at the same zoom level, the same distance from the viewer. Watch a real product demo video and you notice something different: the camera moves. It zooms into the action when something important happens, follows the cursor through a workflow, and pulls back when the scene changes. That is the difference between a slide deck and a directed film.&lt;/p&gt;

&lt;p&gt;This post adds the camera. Same 40 KB budget. No video files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://spanthi.com/blog/gsap-choreography-part-2" rel="noopener noreferrer"&gt;See the full post with 4 interactive demos, side-by-side comparisons, and a live FPS stress test&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The zoom wrapper problem
&lt;/h2&gt;

&lt;p&gt;The first thing you try is &lt;code&gt;gsap.to(frame, { scale: 1.4 })&lt;/code&gt;. It works for half a second, then you notice your responsive scaling is gone. The film frame uses &lt;code&gt;transform: scale(filmScale)&lt;/code&gt; to fit its container width, and GSAP just overwrote it. Both write to the same CSS &lt;code&gt;transform&lt;/code&gt; property. They cannot coexist on the same element.&lt;/p&gt;

&lt;p&gt;The fix is a wrapper inside the frame that GSAP owns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;film&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`scale(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;filmScale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;film&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;zoom&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;origin-center&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* All scene content */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Cursor&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Outside the zoom wrapper */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GSAP animates &lt;code&gt;data-film-zoom&lt;/code&gt;. The responsive scale on the outer frame is untouched. The cursor lives outside the zoom wrapper, so it stays the same size while content scales up around it. This took me an embarrassing amount of time to figure out. Two divs. That was the whole fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pan math
&lt;/h2&gt;

&lt;p&gt;Zooming into the center of the frame is easy. Zooming into a specific element, like a Pinterest pin the user is about to right-click, is the real problem. You need to translate the zoom wrapper so the target ends up centered in the visible viewport:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ZOOM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.45&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;panTo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FILM_WIDTH&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ZOOM&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FILM_HEIGHT&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ZOOM&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At zoom 1.0, no translation needed. At zoom 1.45, every pixel away from center needs 0.45 times that distance in translation to compensate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stay zoomed, pan to follow
&lt;/h2&gt;

&lt;p&gt;The biggest mistake in my first attempt was zooming in for one interaction, zooming out, zooming in for the next, zooming out again. It looked like a PowerPoint with a budget zoom transition.&lt;/p&gt;

&lt;p&gt;The correct pattern: zoom in once, stay zoomed, pan to follow the cursor through the whole interaction sequence, zoom out once when changing scenes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Zoom in on right-click&lt;/span&gt;
&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ZOOM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;panPin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;panPin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.65&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expo.out&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zoomIn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Context menu while zoomed&lt;/span&gt;
&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;autoAlpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.16&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zoomIn+=0.35&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Camera pans to follow cursor to the save button (still zoomed)&lt;/span&gt;
&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;panSave&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;panSave&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sine.inOut&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;panToSave&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Only zoom out when done&lt;/span&gt;
&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sine.inOut&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zoomOut&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;a href="https://spanthi.com/blog/gsap-choreography-part-2" rel="noopener noreferrer"&gt;See this running as a live interactive demo in the full post&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Easing is not one-size-fits-all
&lt;/h2&gt;

&lt;p&gt;Part one used &lt;code&gt;power2.out&lt;/code&gt; for almost everything. Fine for a flat demo. But when you add camera movement, different motion types need different eases or the whole thing feels off.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Motion&lt;/th&gt;
&lt;th&gt;Ease&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Camera pan&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sine.inOut&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Smoothest curve. Feels like a real camera on a dolly.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dramatic zoom-in&lt;/td&gt;
&lt;td&gt;&lt;code&gt;expo.out&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fast start, long deceleration. Whoosh then settle.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cursor movement&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sine.inOut&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Natural hand acceleration.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fade in&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sine.out&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Decelerates into visibility.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Click squeeze&lt;/td&gt;
&lt;td&gt;&lt;code&gt;power2.out&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Snappy press response.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Click release&lt;/td&gt;
&lt;td&gt;&lt;code&gt;back.out(2.2)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Slight overshoot. Feels physical.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typed text&lt;/td&gt;
&lt;td&gt;&lt;code&gt;none&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Real typing does not ease.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The single biggest upgrade was replacing &lt;code&gt;power2.inOut&lt;/code&gt; with &lt;code&gt;sine.inOut&lt;/code&gt; on camera moves. &lt;code&gt;power2&lt;/code&gt; has a noticeable acceleration kick that feels mechanical at large scale. &lt;code&gt;sine&lt;/code&gt; has no perceptible kick at any point in the curve.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cursor is never dead
&lt;/h2&gt;

&lt;p&gt;With camera movement, there are moments where the scene shifts but the cursor is frozen. Those moments break the illusion. Every camera move should have a simultaneous cursor movement at the same timeline label:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BAD: cursor is dead during zoom&lt;/span&gt;
&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;panX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;panY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zoomIn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// GOOD: cursor drifts toward its next target during zoom&lt;/span&gt;
&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;panX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;panY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expo.out&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zoomIn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.65&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sine.out&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zoomIn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cursor does not need to arrive. A 20-30% drift toward the next target is enough. Calm, but never dead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://spanthi.com/blog/gsap-choreography-part-2" rel="noopener noreferrer"&gt;The full post has a side-by-side comparison of frozen vs drifting cursor during zoom&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Graceful loops
&lt;/h2&gt;

&lt;p&gt;Part one's loop was abrupt. With camera movement the problem is worse because the zoom jumps from 1.4x to 1.0 instantaneously.&lt;/p&gt;

&lt;p&gt;The fix is an outro:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sine.inOut&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;outro&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;offScreenX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;offScreenY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sine.in&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;outro+=0.1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// breathing room&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a timing rule: first drafts are always too slow. Cut 20-30% from your timing after the first working version.&lt;/p&gt;

&lt;h2&gt;
  
  
  SEO, theming, and the stuff video cannot do
&lt;/h2&gt;

&lt;p&gt;View source on a &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; tag and Google sees &lt;code&gt;&amp;lt;video src="demo.mp4"&amp;gt;&lt;/code&gt;. A black box. View source on a GSAP demo and every button label, menu item, and heading is indexable text. I have not run a controlled ranking test, but logically: real DOM text beats an opaque binary blob.&lt;/p&gt;

&lt;p&gt;Beyond indexing, DOM demos change when the product does. Marketing changes a button label? Change a text string. Brand color update? One hex value. You want the demo buttons to actually convert? Wire them to a real signup modal. A video cannot do any of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accessibility
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;prefers-reduced-motion: reduce&lt;/code&gt; is enabled, skip the entire timeline and render the resting state. GSAP's &lt;code&gt;autoAlpha&lt;/code&gt; sets both &lt;code&gt;opacity: 0&lt;/code&gt; and &lt;code&gt;visibility: hidden&lt;/code&gt;, removing elements from tab order and screen readers. Every hidden element uses &lt;code&gt;autoAlpha&lt;/code&gt;, not &lt;code&gt;opacity&lt;/code&gt;, so screen readers only see what is currently visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does it need GSAP?
&lt;/h2&gt;

&lt;p&gt;For simple animations, no. But the web clipper demo has roughly 50 coordinated animations across two scenes with camera movement. GSAP gives you labels (named beats), &lt;code&gt;repeat: -1&lt;/code&gt; with reset logic, &lt;code&gt;gsap.utils.selector(root)&lt;/code&gt; for data-attribute queries, a full easing library, and stagger. You could build all of this without GSAP. It would take longer and produce worse results.&lt;/p&gt;

&lt;p&gt;GSAP core is 28 KB gzipped. If you are orchestrating a 50-animation cinematic demo, it is infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent reality
&lt;/h2&gt;

&lt;p&gt;Five demos across the site, roughly 4,000 lines of choreography code. I wrote maybe 60% of that. An AI agent wrote the other 40%.&lt;/p&gt;

&lt;p&gt;My 60% was the directing: deciding what to zoom into, choosing eases after watching both options, noticing the cursor felt dead during zooms, saying "the zoom-out is 200ms too slow." The agent cannot watch the animation and feel that something is off.&lt;/p&gt;

&lt;p&gt;The agent's 40% was the implementation: 1,500 lines of timeline code, DOM elements, click helpers, reset blocks. Pattern-based work following rules I defined in a &lt;a href="https://github.com/Costumary/gsap-choreography" rel="noopener noreferrer"&gt;skill file&lt;/a&gt;. The patterns are stable enough that the output is consistent across different demos. But the agent does not know when something looks wrong. That part is still mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance
&lt;/h2&gt;

&lt;p&gt;The web clipper demo has 50 &lt;code&gt;.to()&lt;/code&gt; calls but only 3-5 are actively tweening at any frame. Every &lt;code&gt;.to()&lt;/code&gt; writes to &lt;code&gt;transform&lt;/code&gt; or &lt;code&gt;opacity&lt;/code&gt;, both compositor-friendly. &lt;strong&gt;&lt;a href="https://spanthi.com/blog/gsap-choreography-part-2" rel="noopener noreferrer"&gt;The full post has a live stress test with an FPS counter&lt;/a&gt;&lt;/strong&gt; where you can push from 8 to 48 concurrent elements and watch the frame rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The updated math
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Compressed&lt;/th&gt;
&lt;th&gt;Camera?&lt;/th&gt;
&lt;th&gt;Indexable?&lt;/th&gt;
&lt;th&gt;Accessible?&lt;/th&gt;
&lt;th&gt;Themeable?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GSAP (flat, part one)&lt;/td&gt;
&lt;td&gt;~37 KB&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GSAP (pan-zoom, part two)&lt;/td&gt;
&lt;td&gt;~40 KB&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15s MP4 (H.264)&lt;/td&gt;
&lt;td&gt;2-4 MB&lt;/td&gt;
&lt;td&gt;Baked in&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15s WebM (VP9)&lt;/td&gt;
&lt;td&gt;1-2 MB&lt;/td&gt;
&lt;td&gt;Baked in&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15s GIF&lt;/td&gt;
&lt;td&gt;8-15 MB&lt;/td&gt;
&lt;td&gt;Baked in&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Adding the camera added roughly 3 KB to the bundle. All five production demos across &lt;a href="https://costumary.com" rel="noopener noreferrer"&gt;costumary.com&lt;/a&gt; ship under 80 KB gzipped combined. The equivalent in video would be 15-20 MB.&lt;/p&gt;

&lt;p&gt;Part one was proof you could replace a video. Part two is the part where it starts to feel directed instead of recorded.&lt;/p&gt;

&lt;p&gt;The upfront cost is real. The ongoing cost is near zero. Updates are code changes, not re-recordings.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Full post with interactive demos:&lt;/strong&gt; &lt;a href="https://spanthi.com/blog/gsap-choreography-part-2" rel="noopener noreferrer"&gt;spanthi.com/blog/gsap-choreography-part-2&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production examples:&lt;/strong&gt; &lt;a href="https://costumary.com" rel="noopener noreferrer"&gt;costumary.com&lt;/a&gt; | &lt;a href="https://costumary.com/web-clipper" rel="noopener noreferrer"&gt;costumary.com/web-clipper&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open source agent skill:&lt;/strong&gt; &lt;a href="https://github.com/Costumary/gsap-choreography" rel="noopener noreferrer"&gt;gsap-choreography&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>design</category>
      <category>animation</category>
      <category>claude</category>
    </item>
    <item>
      <title>Professional Scripted product demos with GSAP with Claude</title>
      <dc:creator>Sugam Panthi</dc:creator>
      <pubDate>Thu, 21 May 2026 00:37:12 +0000</pubDate>
      <link>https://dev.to/vein05/professional-scripted-product-demos-with-gsap-with-claude-32bf</link>
      <guid>https://dev.to/vein05/professional-scripted-product-demos-with-gsap-with-claude-32bf</guid>
      <description>&lt;p&gt;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 &lt;code&gt;prefers-reduced-motion&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://spanthi.com/blog/gsap-choreography" rel="noopener noreferrer"&gt;See the live interactive demos running in your browser&lt;/a&gt;&lt;/strong&gt; (two demos: a simple task app walkthrough and a Figma-style multi-cursor collaborative editor)&lt;/p&gt;

&lt;p&gt;One cursor. One &lt;code&gt;gsap.timeline()&lt;/code&gt;. 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. Not a recording, not a GIF, not a video.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with one timeline
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The fix was to put everything on a single &lt;code&gt;gsap.timeline()&lt;/code&gt; and use labels as the skeleton:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gsap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeline&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;repeatDelay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;power3.out&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The important consequence: inserting a new scene between two labels does not require recalculating any downstream timing. Labels absorb the shift.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes a cursor feel human
&lt;/h2&gt;

&lt;p&gt;The cursor does not slide across the screen at constant speed. Real hands decelerate into a target, so every movement uses &lt;code&gt;power2.out&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ripple&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;autoAlpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.88&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;power2.out&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ripple&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;3.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;autoAlpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.54&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;power2.out&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;back.out(2.2)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;+=0.09`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;back.out(2.2)&lt;/code&gt; 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 &lt;code&gt;power2.out&lt;/code&gt; and the click looks mechanical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scenes cross-fade, state does not
&lt;/h2&gt;

&lt;p&gt;When the cursor clicks "Notes", 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cardArea&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;autoAlpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.24&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;navClick+=0.08&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;listView&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;autoAlpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.28&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;navClick+=0.14&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The nav highlighting uses &lt;code&gt;classList&lt;/code&gt;, not GSAP. State changes should be instant. Animating a nav highlight makes the interface feel laggy, not smooth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timing is the whole game
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cards&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;autoAlpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;rotation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;rotations&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;stagger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.07&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.46&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;addClick+=0.16&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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. Viewers need time to register what changed. Removing the pauses makes the animation faster but incomprehensible.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The loop trap
&lt;/h2&gt;

&lt;p&gt;The first time the animation looped, every card was already visible. The overlay was still showing. The cursor was in the wrong position.&lt;/p&gt;

&lt;p&gt;The fix is a reset block at timeline position 0 that explicitly restores every animated property:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;gsap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CURSOR_START&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CURSOR_START&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;gsap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cards&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;autoAlpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;26&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.82&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rotation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;gsap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;overlay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;autoAlpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.96&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;gsap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;typed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;The production version is 1,800 lines across five files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;film-script.ts          -&amp;gt; data: scenes, cursor paths, timings
film-primitives.tsx     -&amp;gt; DOM: frame, sidebar, cursor SVG
film-panels.tsx         -&amp;gt; DOM: each tab's content
film-demo.tsx           -&amp;gt; GSAP: the entire choreography
animation-provider.tsx  -&amp;gt; React context: play/pause/restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this makes sense and when it does not
&lt;/h2&gt;

&lt;p&gt;I am not going to pretend this replaces video everywhere. It does not.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The math:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Raw&lt;/th&gt;
&lt;th&gt;Compressed&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GSAP core (gsap.min.js)&lt;/td&gt;
&lt;td&gt;73 KB&lt;/td&gt;
&lt;td&gt;28 KB gzip&lt;/td&gt;
&lt;td&gt;Shared across all animations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One demo component&lt;/td&gt;
&lt;td&gt;20 KB&lt;/td&gt;
&lt;td&gt;5.5 KB gzip&lt;/td&gt;
&lt;td&gt;Single scene walkthrough&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Both demos together&lt;/td&gt;
&lt;td&gt;115 KB&lt;/td&gt;
&lt;td&gt;37 KB gzip&lt;/td&gt;
&lt;td&gt;Everything in this article&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15s 1080p MP4 (H.264)&lt;/td&gt;
&lt;td&gt;2-4 MB&lt;/td&gt;
&lt;td&gt;Does not compress further&lt;/td&gt;
&lt;td&gt;Already codec-compressed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15s 1080p GIF&lt;/td&gt;
&lt;td&gt;8-15 MB&lt;/td&gt;
&lt;td&gt;Does not compress further&lt;/td&gt;
&lt;td&gt;Worst option by far&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15s 1080p WebM (VP9)&lt;/td&gt;
&lt;td&gt;1-2 MB&lt;/td&gt;
&lt;td&gt;Does not compress further&lt;/td&gt;
&lt;td&gt;Best video codec, still 30x larger&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  See the production version
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.costumary.com" rel="noopener noreferrer"&gt;costumary.com&lt;/a&gt;&lt;/strong&gt; 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. Under 40 KB where a video would have been three megabytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  For agents
&lt;/h2&gt;

&lt;p&gt;If you build with Claude Code, Cursor, or similar, you can get the skill here: &lt;a href="https://github.com/Costumary/gsap-choreography" rel="noopener noreferrer"&gt;gsap-choreography&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>gsap</category>
      <category>react</category>
      <category>design</category>
      <category>claude</category>
    </item>
  </channel>
</rss>
