How I Turn a Web Page into an Instagram Reel: GSAP, Chrome Tab Capture and ffmpeg

How I Turn a Web Page into an Instagram Reel

GSAP animates a plain HTML page, Chrome photographs it 60 times a second, and ffmpeg makes it Instagram-ready. No video editor, no After Effects. Just the browser.

The idea: the video is a web page that moves

I built a small local app that generates quiz Reels for Instagram: a hook intro, five questions with a countdown timer, a glowing correct answer, and an outro asking “How many did you get?”. A local AI model (via Ollama) writes the questions. Everything else is plain web technology.

The surprising part: there is no video while you edit. What you see in the preview is an ordinary web page: <div>s, CSS and text. It only looks like a video because an animation library changes the elements’ styles about 60 times per second. Recording simply photographs those frames.

1. React builds the pageA 1080×1920 box with every scene inside it (intro, questions, outro), all hidden at first
↓
2. CSS styles itColours, fonts, glass cards, glow. Themes just swap variables.
↓
3. GSAP moves it on a scheduleOne timeline: “at 3.05s slide the card up, at 8.2s turn the answer green…”
↓
4. Chrome repaintsEvery changed style is redrawn, frame after frame, like a flip-book
↓
5. Chrome records the tabTab capture, cropped to the 9:16 box, 60 fps, with the app’s audio
↓
6. ffmpeg finishes itConverts to an Instagram-friendly H.264 MP4

Step 1: The stage is just HTML

Each question is a normal block of elements. It sits on the page from the start, invisible:

<div class="stage">                          <!-- the 1080×1920 Reel -->
  <div class="scene" data-q="0">             <!-- question 1 -->
    <div class="timer">5</div>
    <div class="q-card">What is the capital of Australia?</div>
    <div class="opt">A  Sydney</div>
    <div class="opt">B  Canberra</div>
  </div>
  <div class="scene-outro">How many did you get? 👇</div>
</div>

The stage is designed at real Instagram size, 1080×1920, and scaled down to fit the screen while you edit. Because it’s just HTML, you can right-click the quiz in Chrome, choose Inspect, and watch the styles change live while it plays.

Step 2: GSAP, the animator

GSAP (GreenSock Animation Platform) is a free JavaScript animation library. I build the whole video as one GSAP timeline: a schedule of what changes, from what to what, when, and how fast.

import gsap from "gsap";
const tl = gsap.timeline({ paused: true });

tl.set(scene, { autoAlpha: 1 }, t);                                  // show question 1
tl.from(card, { y: 140, scale: 0.9, autoAlpha: 0,
                duration: 0.6, ease: "power3.out" }, t + 0.05);       // card slides up
tl.from(option, { x: -180, autoAlpha: 0, duration: 0.5 }, t + 0.45);  // options fly in
tl.fromTo(ring, { strokeDashoffset: 0 },
              { strokeDashoffset: RING_C, duration: 5, ease: "none" }, tc); // timer drains
tl.call(() => sfx.play("correct"), [], reveal);                   // chime, exactly on time

Important: these lines don’t move anything yet. They only add entries to a schedule.

One line, explained

tl.from(card, { y: 140, scale: 0.9, autoAlpha: 0, duration: 0.6, ease: "power3.out" }, t + 0.05);
Part Meaning
tl.from Animate from these values to wherever the element normally is according to the CSS. Perfect for entrances: design the final layout in CSS and only describe where it comes from. (to = current → given, fromTo = given → given.)
card The element to animate: the .q-card div
y: 140 Start 140px lower, then rise to 0 (a CSS transform, so the layout around it isn’t disturbed)
scale: 0.9 Start at 90% size, then grow to 100%
autoAlpha: 0 Start invisible: opacity 0 plus visibility:hidden, so it can’t be clicked while hidden
duration: 0.6 The move takes 0.6 s, about 36 frames at 60 fps
ease The speed curve. .out means fast start, gentle stop. back.out overshoots and settles (bouncy), expo.out brakes hard (snappy).
t + 0.05 When it starts: 50 ms after the question scene appears

Step 3: Who actually changes the screen?

When tl.play() is called, GSAP starts its internal clock, the ticker. It runs on the browser’s requestAnimationFrame, which is Chrome saying “I’m about to draw a frame, update now”. That happens about 60 times a second. On every tick, for every active tween, GSAP does this:

1. What time is it?
The timeline is at 3.35 s.
2. How far through the tween?
(3.35 − 3.05) / 0.6 = 50%
3. Apply the ease
With power3.out, 50% of the time ≈ 87% of the movement
4. Interpolate and write
y = 140 → 18px, opacity 0.87, written to style
<div class="q-card" style="transform: translate(0px, 18px) scale(0.987); opacity: 0.87">

Chrome sees the changed style and repaints. 16 ms later, slightly different numbers. Then again, and again:

time 3.05 3.15 3.35 3.50 3.65
y 140px 59px 18px 2px 0
opacity 0 0.58 0.87 0.98 1

Played 60 times a second, those small differences look like smooth motion. A flip-book, drawn by the browser.

Why one timeline matters: every value is calculated from time, not “move a bit more each frame”. So tl.seek(3.35) instantly puts every element exactly where it belongs at 3.35 s. That’s what makes the scrub slider work, and why my rule is: every visual change lives on the timeline. No setTimeout, no CSS transitions. Sounds and voice-overs are scheduled with tl.call too, so audio and visuals can never drift apart.

Step 4: Recording: Chrome photographs the tab

Now the page needs to become a file. The browser has everything required built in.

Capture the tab and crop it to the quiz box

const display = await navigator.mediaDevices.getDisplayMedia({
  video: { frameRate: { ideal: 60, max: 60 }, displaySurface: "browser" },  // ask for 60 fps
  preferCurrentTab: true,                                              // "share this tab"
});
const [track] = display.getVideoTracks();

// Throw away everything except the 9:16 quiz div
const target = await CropTarget.fromElement(frameDiv);
await track.cropTo(target);

getDisplayMedia gives a live stream of the tab as Chrome paints it. Region Capture (CropTarget + cropTo) keeps only the pixels of one element. Attach it to a different div and you’d record a different part of the page.

Retina trick: on a 2× display, the preview is drawn at ½ scale during recording, so the 540×960 CSS box lands on exactly 1080×1920 real pixels.

Add the sound and encode

All audio (music, sound effects, voice-overs) is generated with the Web Audio API and routed into one master bus. That bus feeds both your speakers and a MediaStreamAudioDestinationNode, which gives a recordable audio track:

const stream = new MediaStream([track, ...sfx.streamDest.stream.getAudioTracks()]); // pictures + sound
const rec = new MediaRecorder(stream, {
  mimeType: "video/webm;codecs=vp9,opus",
  videoBitsPerSecond: 16_000_000,                 // quality: 16 Mbps
});
rec.ondataavailable = (e) => chunks.push(e.data);
rec.start(500);                                  // hand over a compressed piece every 0.5 s

Play, wait, stop

const rec = await startRecording(frameDiv, sfx, 60);  // 1. camera on
const done = player.onceComplete();                 // 2. "tell me when it ends"
await player.play(0);                               // 3. tl.play() from the start
await done;
const { blob } = await rec.stop();                    // 4. camera off → video file in memory
await uploadRecording(blob);                          // 5. to the local server → ffmpeg

Step 5: Why ffmpeg, if Chrome already made a video?

Chrome’s recording is made for live streaming, not publishing. In practice it is WebM, with variable frame timing, and sometimes slightly smaller than 1080×1920 if the window was small. Instagram wants H.264 MP4 at a fixed frame rate. So the local server runs:

ffmpeg -i recording.webm \
  -vf "scale=1080:1920:flags=lanczos" -r 60 \
  -c:v libx264 -preset slow -crf 16 -pix_fmt yuv420p \
  -c:a aac -b:a 192k -ar 48000 \
  -movflags +faststart  reel.mp4
A lesson learned the hard way: my first version asked Chrome to record MP4/H.264 directly, to skip conversion. On my machine that produced a corrupt bitstream, which looked like a “blurry” video. ffmpeg reported hundreds of error while decoding MB lines. Switching the recorder to WebM (VP9) and letting ffmpeg do the H.264 encode fixed it completely. The server now also counts decode errors during conversion and warns if a recording is damaged.

Conversion with -preset slow takes a while, so ffmpeg runs as a background job. It reports progress with -progress pipe:1, and the page polls the job and shows Saving → Converting → Download.

The whole stack

Piece Job
React + Vite + TypeScript Builds the stage and the editor UI
CSS All visuals and themes
GSAP One seekable timeline: animation, sound cues, voice-over cues
Web Audio API Sound effects, music with ducking, voice-over effects (all synthesized, no asset files)
getDisplayMedia + cropTo Captures exactly the quiz box from the tab
MediaRecorder Encodes frames and audio to WebM inside Chrome
Express + ffmpeg Converts to Instagram-ready MP4 with progress
Ollama (local LLM) Writes the quiz questions
The big picture: React builds the quiz as a web page, CSS makes it look good, GSAP moves it on a timed schedule, Chrome draws it, and the recorder photographs it 60 times a second. A video editor turns out to be something the browser already contains.

One limitation of live capture: if the computer is busy, frames can be dropped. Because everything is on a seekable timeline, the natural next step is a frame-by-frame renderer: seek to frame n, take a screenshot, repeat. That is exactly how tools like Remotion work.

Built with GSAP, the Web Audio API, Chrome tab capture and ffmpeg. Runs entirely on a laptop.

You may also like...

Leave a Reply

Your email address will not be published. Required fields are marked *