This is a build log for a 30-second product demo video, written entirely in React and rendered to an .mp4 you can drop on a landing page, post to X, or attach to a Product Hunt launch. No After Effects, no screen recorder, no dragging clips around a GUI timeline. The stack is Remotion — the React framework that renders real video from components — and remocn, a shadcn-style registry of copy-paste animation and UI components built for it. We'll assemble three scenes: a hook line over an animated background, a simulated product walkthrough (the app UI rebuilt on a timeline instead of screen-recorded), and a closing scene with a live star counter and a call to action. Every scene is a React component, and the whole thing is one file tree you can re-render whenever your UI changes.
Why build a demo video in code
A GUI editor is faster for a one-off. A demo video is rarely a one-off: the product UI changes, the pricing changes, the headline changes, and every change means re-recording and re-cutting. When the video is React, a change is a prop edit and a re-render, and the diff lives in git next to the feature that caused it. You also skip the screen recorder entirely — instead of capturing your running app, you rebuild the parts you want to show as components, which stay crisp at any resolution and never leak a stray notification or the wrong cursor. The tradeoff is real: you write code instead of dragging clips, so this pays off when the video has a life beyond its first export. (There's a longer editor-versus-code comparison elsewhere; this article is the build.)
Install Remotion and the components
remocn assumes a Remotion project already exists. If you don't have one, create-video scaffolds it; if you've never run the shadcn CLI here, init writes a components.json. Then a single add pulls every component this article uses.
# 1. A Remotion project (skip if you already have one)
npx create-video@latest
# 2. shadcn config (skip if you already have components.json)
npx shadcn@latest init
# 3. Every component this article uses, in one run
npx shadcn@latest add \
@remocn/shader-mesh-gradient @remocn/staggered-fade-up \
@remocn/signup-flow @remocn/backdrop \
@remocn/whip-pan @remocn/zoom-blur \
@remocn/github-stars @remocn/soft-blur-in @remocn/confetti \
@remocn/terminal-simulator
remocn follows shadcn's "own your code" model: add copies each component's TypeScript source into your project under components/remocn/, so you import them by path (@/components/remocn/signup-flow) and edit them like your own files — there's no runtime dependency to track. The registry also resolves dependency chains for you. signup-flow alone pulls in the remocn-ui core plus cursor, input, button, toast, field, and blur-in, and whip-pan installs @remotion/transitions, so the single command above leaves you with a working set.
One licensing note, accurate at the time of writing: Remotion is source-available and free for individuals and companies of up to three people; larger companies need a paid company license. remocn itself is MIT.
Set up the composition
A Remotion video is a <Composition>: an id, the root component to render, a frame count, a frame rate, and a resolution. We're rendering 1920×1080 at 30 fps. Thirty seconds is 900 frames, and after accounting for the two transitions the exact number lands at 906 — here's the budget the rest of the article fills in.
| Scene | What's on screen | Frames | Seconds |
|---|---|---|---|
| 1 — Hook | shader background + StaggeredFadeUp headline |
150 | 5.0 |
| ↳ whip-pan | overlaps scenes 1 and 2 | −26 | −0.9 |
| 2 — Product |
signup-flow simulated on a timeline |
500 | 16.7 |
| ↳ zoom-blur | overlaps scenes 2 and 3 | −18 | −0.6 |
| 3 — Closing |
github-stars + CTA + confetti
|
300 | 10.0 |
| Total | 906 | 30.2 |
The scene sequences sum to 950 frames; the two transitions overlap their neighbors by 26 and 18 frames, so the finished video is 950 − 44 = 906 frames. create-video already wired registerRoot for you — you only edit the root:
// src/Root.tsx
import { Composition } from "remotion";
import { ProductDemo } from "./ProductDemo";
export const RemotionRoot = () => (
<Composition
id="ProductDemo"
component={ProductDemo}
durationInFrames={906}
fps={30}
width={1920}
height={1080}
/>
);
ProductDemo is the component we build up over the next sections. Preview it live at any time with npx remotion studio, where you can scrub the timeline frame by frame.
Scene 1 — the hook line
The opening has one job: state the value in a few words before anyone scrolls past. We put an animated shader behind a headline that assembles word by word. Both are remocn components, and both are deterministic — the shader freezes its own internal clock and instead reads the current frame, so the motion is a pure function of the timeline rather than wall-clock time. That's what makes a Remotion render reproducible across machines and safe to render in parallel chunks.
// src/scenes/HookScene.tsx
import { AbsoluteFill } from "remotion";
import { ShaderMeshGradient } from "@/components/remocn/shader-mesh-gradient";
import { StaggeredFadeUp } from "@/components/remocn/staggered-fade-up";
export const HookScene = () => (
<AbsoluteFill>
<ShaderMeshGradient speed={1} />
<AbsoluteFill
style={{ alignItems: "center", justifyContent: "center", padding: 160 }}
>
<StaggeredFadeUp
text="Deploy in one command"
staggerDelay={4}
distance={20}
fontSize={96}
fontWeight={600}
color="#0b0b0f"
/>
</AbsoluteFill>
</AbsoluteFill>
);
ShaderMeshGradient fills the frame; the second AbsoluteFill layers the headline on top. StaggeredFadeUp lifts each word up by distance pixels and fades it in, offsetting each next word by staggerDelay frames — at 4 frames a word, a four-word line finishes assembling in well under a second. Mesh gradients render bright, so the headline uses a near-black color; if you swap in a darker shader (there are 18 in the registry), flip the text to white. The typography components size themselves from fontSize, so they're resolution-independent — no wrapper needed here.
Scene 2 — simulate the product on a timeline
This is the scene most people reach for a screen recorder to make. Don't. Recording your running app captures whatever resolution your display happens to be, drags in real network lag, and risks a notification popping in mid-take. The alternative — the "fake app" technique — is to rebuild the slice of UI you want to show as a component and script it on the timeline. It renders retina-sharp at any output resolution, it re-renders when your real UI changes, and every take is identical.
remocn ships these as scripted flows. signup-flow drives a complete account-creation sequence: a cursor moves field to field, each input fills in, the submit button steps through hover → press → loading → success, and a success toast slides in — all keyed to frame numbers, not timers. You feed it your copy through props.
These scene sims are authored against a 1280×720 reference so their internal pixel math stays predictable. To drop one into a 1080p composition, scale that reference up once with a small wrapper you'll reuse for the closing scene too:
// src/components/Stage.tsx
import type { ReactNode } from "react";
import { AbsoluteFill } from "remotion";
export const Stage = ({ children }: { children: ReactNode }) => (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<div style={{ width: 1280, height: 720, transform: "scale(1.5)" }}>
{children}
</div>
</AbsoluteFill>
);
A 1280×720 box scaled by 1.5 is exactly 1920×1080, centered. Now the scene: a soft gradient behind the scaled signup card.
// src/scenes/ProductScene.tsx
import { AbsoluteFill } from "remotion";
import { SignupFlow } from "@/components/remocn/signup-flow";
import { Stage } from "../components/Stage";
export const ProductScene = () => (
<AbsoluteFill style={{ background: "linear-gradient(160deg, #eef2ff, #faf5ff)" }}>
<Stage>
<SignupFlow
title="Create your Rocket account"
description="Start your 14-day trial — no card required"
fullName="Ada Lovelace"
email="ada@rocket.dev"
password="••••••••"
createLabel="Create account"
toastTitle="Welcome aboard"
/>
</Stage>
</AbsoluteFill>
);
Every string is a prop, so this becomes your product by editing the copy — no re-recording. signup-flow's scripted timeline runs about 360 frames (roughly 12 seconds), which is why Scene 2 gets 500 frames in the budget: enough to play the whole flow and hold on the completed form for a beat before the transition out. If your product is a CLI rather than a web app, swap signup-flow for terminal-simulator, which types commands into a console window and rolls older lines off the top — same idea, different surface.
Stitch the scenes with TransitionSeries
Individual scenes are just components. To cut between them with motion, wrap them in TransitionSeries from @remotion/transitions and drop a TransitionSeries.Transition between each pair. remocn's transitions are presentation functions you hand to that element: whipPan flings both scenes through the frame in a single camera whip, and zoomBlur punches forward into the next scene with a blur. A transition doesn't add time — it overlaps the last frames of the outgoing scene with the first frames of the incoming one, so the finished duration is the sum of the sequences minus the sum of the transitions.
// src/ProductDemo.tsx
import {
TransitionSeries,
linearTiming,
springTiming,
} from "@remotion/transitions";
import { whipPan } from "@/components/remocn/whip-pan";
import { zoomBlur } from "@/components/remocn/zoom-blur";
import { HookScene } from "./scenes/HookScene";
import { ProductScene } from "./scenes/ProductScene";
import { ClosingScene } from "./scenes/ClosingScene";
export const ProductDemo = () => (
<TransitionSeries>
<TransitionSeries.Sequence durationInFrames={150}>
<HookScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
timing={linearTiming({ durationInFrames: 26 })}
presentation={whipPan({ direction: "left", blur: 24 })}
/>
<TransitionSeries.Sequence durationInFrames={500}>
<ProductScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
timing={springTiming({ durationInFrames: 18, config: { damping: 200 } })}
presentation={zoomBlur({ blur: 16 })}
/>
<TransitionSeries.Sequence durationInFrames={300}>
<ClosingScene />
</TransitionSeries.Sequence>
</TransitionSeries>
);
The timing and presentation props are separate on purpose: presentation is the look of the move, timing is its curve and length. whipPan reads well on a linear ramp. zoomBlur applies no internal easing of its own, so its character comes entirely from the timing function — springTiming gives the punch-in a springy settle. Sequences sum to 950 frames, the two transitions overlap 44, and the result matches the 906 we set on the <Composition>. ClosingScene is the one piece still missing; it's next.
Scene 3 — social proof and the call to action
The close does two things back to back: show that other people already use the product, then tell the viewer what to do. github-stars handles the first half — a fly-through of stargazers with an odometer that counts up to the total. It renders standalone from built-in sample data, or you pass real stargazers you fetched. Like the signup sim, it targets a 1280×720 reference, so it goes through the same Stage wrapper. Then a Backdrop fills the frame for the CTA: SoftBlurIn resolves the final line out of a blur, and Confetti fires on top.
// src/scenes/ClosingScene.tsx
import { AbsoluteFill, Sequence } from "remotion";
import { Backdrop } from "@/components/remocn/backdrop";
import { Confetti } from "@/components/remocn/confetti";
import { GitHubStars } from "@/components/remocn/github-stars";
import { SoftBlurIn } from "@/components/remocn/soft-blur-in";
import { Stage } from "../components/Stage";
const STARS_LEN = 150;
export const ClosingScene = () => (
<AbsoluteFill>
<Sequence durationInFrames={STARS_LEN}>
<Stage>
<GitHubStars
repo="rocket-dev/rocket"
totalStars={4218}
accentColor="#f5a623"
orientation="horizontal"
speed={1}
theme="dark"
/>
</Stage>
</Sequence>
<Sequence from={STARS_LEN}>
<Backdrop
fill={{ type: "color", value: "#0b0b0f" }}
padding={0}
radius={0}
shadow=""
>
<AbsoluteFill style={{ alignItems: "center", justifyContent: "center" }}>
<SoftBlurIn
text="Start building with Rocket"
fontSize={84}
fontWeight={600}
color="#fafafa"
/>
</AbsoluteFill>
<Confetti startFrame={10} particleCount={160} seed={7} originY={0.4} />
</Backdrop>
</Sequence>
</AbsoluteFill>
);
The two halves are ordinary <Sequence> elements inside the scene: the star fly-through owns the first 150 frames, and the CTA owns the rest, starting at from={STARS_LEN}. Backdrop is remocn's background component — with padding, radius, and shadow zeroed out it's a full-bleed fill, and if you leave those defaults on instead it wraps the content in a rounded, shadowed floating-window frame. Confetti's startFrame is counted from the start of its Sequence, so 10 means ten frames after the CTA appears. It's worth calling out why the confetti looks the same on every render: each piece comes from a seeded pseudo-random generator, never Math.random(), so a given seed produces an identical burst every time — which is exactly what a deterministic, parallelized render needs.
Render to mp4
With the root registered, one command turns the composition into a file:
npx remotion render ProductDemo out/demo.mp4
That renders ProductDemo at the resolution and frame count from the <Composition> and writes out/demo.mp4. While you're iterating on a single scene, render just its frames instead of all 906 — npx remotion render ProductDemo out/scene2.mp4 --frames=124-624 gives you the product scene in a fraction of the time (it starts at frame 124, since the 26-frame whip-pan overlap pulls it back from 150). When you want the full render to run somewhere other than your laptop — a CI job, or a render button inside your own app — @remotion/renderer drives the identical render headlessly with selectComposition then renderMedia, and Remotion Lambda splits the frames into parallel chunks in the cloud for speed. Because every component here is a pure function of the frame, all of those paths produce the same pixels.
Where to go next
The full catalog — 18 shaders, the rest of the camera and dissolve transitions, chart and chat sims, and the video-ready UI primitives that power the fake-app flows — lives at remocn.dev/docs. The two most useful next stops for a demo are the transitions you cut with and the UI primitives you compose your own product scenes from. Everything is MIT and on GitHub at github.com/Remocn/remocn — a star helps if this saved you an afternoon. Swap the signup sim for your product's real flow, change the copy, and the same three-scene skeleton becomes your next launch video.
Top comments (0)