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.
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:
The timeline is at 3.35 s.
(3.35 − 3.05) / 0.6 = 50%
With
power3.out, 50% of the time ≈ 87% of the movementy = 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.
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.
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
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 |
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.