CameraRig
Pan and zoom toward a world-space point without reflowing the scene.
import React, { type CSSProperties, type ReactNode } from "react";
import { AbsoluteFill } from "remotion";
import { AppFrame, CameraRig } from "./system";
const ink = "#141414", lime = "#b6f36b";
const center: CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center" };
const card: CSSProperties = { background: "#fff", border: "1px solid #ddd", borderRadius: 20, padding: 32 };
function Stage({ children, dark = false, style }: {
children: ReactNode;
dark?: boolean;
style?: CSSProperties;
}) {
return <AbsoluteFill style={{ fontFamily: "Arial, Helvetica, sans-serif", color: dark ? "#fff" : ink, background: dark ? ink : "#f7f7f5", ...center, ...style }}>{children}</AbsoluteFill>;
}
function Dashboard({ compact = false, emphasizeMetric = false }: {
compact?: boolean;
emphasizeMetric?: boolean;
}) {
return <div style={{ padding: compact ? 24 : 36, display: "grid", gap: 26, color: ink }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}><strong style={{ fontSize: 30, letterSpacing: "-0.035em" }}>Project overview</strong><span style={{ background: lime, padding: "10px 18px", borderRadius: 8, fontSize: 16 }}>New project +</span></div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 16 }}>{[["In progress", "08"], ["Completed", "24"], ["This week", "+12%"]].map(([label, value], i) => <div key={label} style={{ ...card, padding: 20, background: "#fafafa", outline: emphasizeMetric && i === 2 ? `4px solid ${lime}` : undefined }}><div style={{ fontSize: 16, color: "#666" }}>{label}</div><div style={{ fontSize: 42, marginTop: 12, letterSpacing: "-0.05em" }}>{value}</div></div>)}</div>
<div style={{ display: "flex", height: compact ? 110 : 150, alignItems: "flex-end", gap: 14, borderBottom: "1px solid #ddd" }}>{[24, 42, 35, 68, 54, 78, 96, 74, 84, 100].map((n, i) => <div key={i} style={{ flex: 1, height: `${n}%`, background: i === 9 ? lime : "#e4e5e0", borderRadius: "8px 8px 0 0" }}/>)}</div>
</div>;
}
export function CameraRigExample() { return <Stage style={{ overflow: "hidden" }}><CameraRig from={{ x: 640, y: 360, zoom: 1 }} to={{ x: 968, y: 317, zoom: 1.8 }} at={1.3} duration={1.6}><div style={{ position: "absolute", left: 120, top: 105 }}><AppFrame width={1040} height={510}><Dashboard emphasizeMetric/></AppFrame></div></CameraRig></Stage>; }
Installation
Run once from your product repo. The installer includes CameraRig, its source, and the agent skill.
npx -y https://20cuts.com/releases/v0.3.0/20cuts-create-0.3.0.tgz video/20cutsAlready installed? Import it in your video project. Full setup guide
import { CameraRig } from "./system/motion";Usage
The target’s x and y land in the center of the video. zoom controls how close you get.
(900, 340).Save your 1280×720 product screenshot as public/product.png. Use a composition of the same size: 30fps, 180 frames.
import { AbsoluteFill, Img, staticFile } from "remotion";
import { CameraRig } from "./system";
const wide = {x: 640, y: 360, zoom: 1};
const detail = {x: 900, y: 340, zoom: 1.7};
export default function ProductZoom() {
return (
<AbsoluteFill style={{background: "#fff", overflow: "hidden"}}>
<CameraRig
from={wide}
to={detail}
at={1}
duration={1.2}
>
<Img
src={staticFile("product.png")}
style={{width: 1280, height: 720}}
/>
</CameraRig>
</AbsoluteFill>
);
}
Replace detail with the center of the feature you want to show. The move starts at 1 second and settles at 2.2 seconds.
Return to the full scene
Hold the detail, then pull back at 4 seconds. The shared pose keeps the switch smooth.
import { AbsoluteFill, Img, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
import { CameraRig } from "./system";
const wide = {x: 640, y: 360, zoom: 1};
const detail = {x: 900, y: 340, zoom: 1.7};
export default function ProductZoom() {
const seconds = useCurrentFrame() / useVideoConfig().fps;
const returning = seconds >= 4;
return (
<AbsoluteFill style={{background: "#fff", overflow: "hidden"}}>
<CameraRig
from={returning ? detail : wide}
to={returning ? wide : detail}
at={returning ? 4 : 1}
duration={1.2}
>
<Img
src={staticFile("product.png")}
style={{width: 1280, height: 720}}
/>
</CameraRig>
</AbsoluteFill>
);
}
Register in Remotion
Save the scene above as src/ProductZoom.tsx. For a separate entry point, replace src/index.tsx with this registration, then run npm run studio.
import { Composition, registerRoot } from "remotion";
import ProductZoom from "./ProductZoom";
registerRoot(() => (
<Composition
id="ProductZoom"
component={ProductZoom}
width={1280}
height={720}
fps={30}
durationInFrames={180}
/>
));Props
| Prop | Type | Default |
|---|---|---|
childrenReact content to display. | ReactNode | Required |
fromStarting world-space center point and zoom. | CameraPose | Required |
toEnding world-space center point and zoom. | CameraPose | Required |
atStart time in seconds within the current Remotion Sequence. | number | 0 |
durationAnimation duration in seconds. | number | 1.2 |
styleStyles applied to the component wrapper. | CSSProperties | — |
- Both poses are required. Their x and y coordinates identify the world point placed at the viewport center; zoom must be positive.
- Viewport width and height come from useVideoConfig(). CameraRig has no width or height prop and does not reflow its children.
MotionTiming
export type MotionTiming = {at?: number; duration?: number};| Prop | Type | Default |
|---|---|---|
atStart time in seconds within the current Remotion Sequence. | number | — |
durationAnimation duration in seconds. | number | — |
CameraPose
export type CameraPose = Point & {zoom: number};| Prop | Type | Default |
|---|---|---|
xWorld-space horizontal center in pixels. | number | Required |
yWorld-space vertical center in pixels. | number | Required |
zoomPositive magnification; 1 preserves scale. | number | Required |
Point
export type Point = {x: number; y: number};| Prop | Type | Default |
|---|---|---|
xHorizontal coordinate in pixels. | number | Required |
yVertical coordinate in pixels. | number | Required |
Source
Read the component files
import React, {type CSSProperties, type ReactNode} from "react";
import {useCurrentFrame, useVideoConfig} from "remotion";
import {cameraTransform, focusStyle, inOut, phase, type CameraPose, type MotionTiming} from "./core.js";
export type FocusPullProps = MotionTiming & {children: ReactNode; direction?: "out" | "in"; blur?: number; dim?: number; style?: CSSProperties};
export function FocusPull({children, at = 0, duration = 0.7, direction = "out", blur = 6, dim = 0.45, style}: FocusPullProps) {
const p = inOut(phase(useCurrentFrame(), useVideoConfig().fps, at, duration));
if (direction !== "out" && direction !== "in") throw new TypeError("direction must be out or in.");
return <div style={{...style, ...focusStyle(direction === "out" ? p : 1 - p, blur, dim)}}>{children}</div>;
}
export type CameraRigProps = MotionTiming & {children: ReactNode; from: CameraPose; to: CameraPose; style?: CSSProperties};
/** The point in each pose is the world coordinate to place at the viewport center. */
export function CameraRig({children, from, to, at = 0, duration = 1.2, style}: CameraRigProps) {
const frame = useCurrentFrame(), {fps, width, height} = useVideoConfig();
const p = inOut(phase(frame, fps, at, duration));
return <div style={{position: "absolute", inset: 0, ...style, transform: cameraTransform(from, to, p, width, height), transformOrigin: "0 0"}}>{children}</div>;
}
import {Easing} from "remotion";
/** All motion is evaluated from the local Remotion frame, including frames before a Sequence. */
export type MotionTiming = {at?: number; duration?: number};
export type Point = {x: number; y: number};
export type CameraPose = Point & {zoom: number};
export const out = Easing.bezier(0.16, 1, 0.3, 1);
export const inOut = Easing.bezier(0.65, 0, 0.35, 1);
export function finite(value: number, name: string): number {
if (!Number.isFinite(value)) throw new TypeError(`${name} must be finite.`);
return value;
}
export function positive(value: number, name: string): number {
if (finite(value, name) <= 0) throw new RangeError(`${name} must be greater than zero.`);
return value;
}
export function nonNegative(value: number, name: string): number {
if (finite(value, name) < 0) throw new RangeError(`${name} must be zero or greater.`);
return value;
}
export function unit(value: number, name = "progress"): number {
return Math.min(1, Math.max(0, finite(value, name)));
}
export function lerp(from: number, to: number, p: number): number {
finite(from, "from"); finite(to, "to");
const t = unit(p);
return finite(from * (1 - t) + to * t, "interpolated value");
}
export function phase(frame: number, fps: number, at = 0, duration = 0.6): number {
finite(frame, "frame"); positive(fps, "fps"); finite(at, "at"); positive(duration, "duration");
return unit((frame / fps - at) / duration);
}
export function reveal(frame: number, fps: number, at = 0, duration = 0.6): number {
return out(phase(frame, fps, at, duration));
}
export function staggerAt(at: number, index: number, interval: number): number {
finite(at, "at"); nonNegative(index, "index"); nonNegative(interval, "stagger");
return at + index * interval;
}
export function swapState(progress: number): {side: "from" | "to"; opacity: number} {
const p = unit(progress);
return {side: p < 0.5 ? "from" : "to", opacity: Math.min(1, Math.abs(p - 0.5) * 4)};
}
export function cameraTransform(from: CameraPose, to: CameraPose, p: number, width: number, height: number): string {
positive(from.zoom, "from.zoom"); positive(to.zoom, "to.zoom");
positive(width, "width"); positive(height, "height");
const x = lerp(from.x, to.x, p), y = lerp(from.y, to.y, p), zoom = lerp(from.zoom, to.zoom, p);
const tx = finite(width / 2 - x * zoom, "camera translation x");
const ty = finite(height / 2 - y * zoom, "camera translation y");
return `translate(${tx}px, ${ty}px) scale(${zoom})`;
}
export function focusStyle(progress: number, blur: number, dim: number): {filter: string; opacity: number} {
nonNegative(blur, "blur");
if (finite(dim, "dim") < 0 || dim > 1) throw new RangeError("dim must be between zero and one.");
const p = unit(progress);
return {filter: `blur(${blur * p}px)`, opacity: lerp(1, dim, p)};
}
export function connectorGeometry(from: Point, to: Point, bend: number): {path: string; point: (p: number) => Point} {
finite(from.x, "from.x"); finite(from.y, "from.y"); finite(to.x, "to.x"); finite(to.y, "to.y"); finite(bend, "bend");
const middle = {x: lerp(from.x, to.x, 0.5), y: finite(lerp(from.y, to.y, 0.5) - bend, "curve midpoint")};
return {
path: `M ${from.x} ${from.y} Q ${middle.x} ${middle.y} ${to.x} ${to.y}`,
point(p) { const t = unit(p); return {x: lerp(lerp(from.x, middle.x, t), lerp(middle.x, to.x, t), t), y: lerp(lerp(from.y, middle.y, t), lerp(middle.y, to.y, t), t)}; },
};
}