AppFrame
Put product screens inside a clean application window with fixed dimensions.
import React, { type CSSProperties, type ReactNode } from "react";
import { AbsoluteFill } from "remotion";
import { AppFrame } 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 AppFrameExample() { return <Stage><AppFrame width={960} height={540} title="Workspace"><Dashboard /></AppFrame></Stage>; }
Installation
Run once from your product repo. The installer includes AppFrame, 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 { AppFrame } from "./system/motion";Usage
The starter already includes AppFrameExample in Remotion Studio. To edit a copy, save the Code tab as src/AppFrameExample.tsx and register it below.
Register in Remotion
Use the complete example from the Code tab. For a separate entry point, replace src/index.tsx with this registration, then run npm run studio.
import { Composition, registerRoot } from "remotion";
import { AppFrameExample } from "./AppFrameExample";
registerRoot(() => (
<Composition
id="AppFrameExample"
component={AppFrameExample}
width={1280}
height={720}
fps={30}
durationInFrames={180}
/>
));Props
| Prop | Type | Default |
|---|---|---|
childrenReact content to display. | ReactNode | Required |
titleWindow title. | string | "Your product" |
widthWidth in pixels. | number | 960 |
heightHeight in pixels. | number | 540 |
backgroundCSS background inside the window. | string | "#fff" |
styleStyles applied to the component wrapper. | CSSProperties | — |
Source
Read the component files
import React, {type CSSProperties, type ReactNode} from "react";
import {useCurrentFrame, useVideoConfig} from "remotion";
import {connectorGeometry, phase, positive, reveal, type MotionTiming, type Point} from "./core.js";
export type AppFrameProps = {children: ReactNode; title?: string; width?: number; height?: number; background?: string; style?: CSSProperties};
export function AppFrame({children, title = "Your product", width = 960, height = 540, background = "#fff", style}: AppFrameProps) {
positive(width, "width"); positive(height, "height");
return <div style={{width, height, display: "flex", flexDirection: "column", overflow: "hidden", border: "1px solid #d8d8d8", borderRadius: 18, background, boxShadow: "0 24px 72px #00000012", ...style}}>
<div style={{height: 52, flex: "0 0 52px", display: "flex", alignItems: "center", gap: 8, padding: "0 20px", borderBottom: "1px solid #e9e9e9", background: "#fafafa"}}>
<div aria-hidden style={{display: "flex", gap: 6}}>{[0, 1, 2].map(i => <span key={i} style={{width: 9, height: 9, borderRadius: "50%", background: "#d0d0d0"}} />)}</div>
<span style={{fontSize: 16, color: "#666", marginLeft: 12}}>{title}</span>
</div>
<div style={{position: "relative", flex: 1, minHeight: 0, overflow: "hidden"}}>{children}</div>
</div>;
}
export type ProcessNodeProps = {title: string; rows?: readonly string[]; state?: "idle" | "running" | "success" | "error"; accent?: string; width?: number; style?: CSSProperties};
export function ProcessNode({title, rows = [], state = "idle", accent = "#62972d", width = 300, style}: ProcessNodeProps) {
positive(width, "width");
if (!["idle", "running", "success", "error"].includes(state)) throw new TypeError("state must be idle, running, success, or error.");
const active = state !== "idle", color = state === "error" ? "#b84636" : state === "idle" ? "#8b8b8b" : accent;
return <div style={{width, boxSizing: "border-box", border: `1.5px solid ${active ? color : "#ddd"}`, borderRadius: 16, padding: 24, background: "#fff", color: "#171717", boxShadow: active ? `0 0 0 4px color-mix(in srgb, ${color} 10%, transparent)` : "none", ...style}}>
<div style={{display: "flex", alignItems: "center", gap: 12, fontWeight: 650, fontSize: 24}}><span aria-label={state} style={{width: 10, height: 10, flexShrink: 0, borderRadius: "50%", background: color}} />{title}</div>
{rows.length > 0 && <div style={{display: "grid", gap: 10, marginTop: 18, color: "#666", fontSize: 18}}>{rows.map((row, i) => <div key={i}>{row}</div>)}</div>}
</div>;
}
export type ConnectorProps = MotionTiming & {from: Point; to: Point; width?: number; height?: number; bend?: number; color?: string; thickness?: number; pulse?: boolean; pulseDuration?: number; style?: CSSProperties};
/** Coordinates use the explicit SVG viewBox; pass the same size as the containing world. */
export function Connector({from, to, width = 1280, height = 720, bend = 0, color = "#777", thickness = 3, pulse = true, pulseDuration = 1.2, at = 0, duration = 0.7, style}: ConnectorProps) {
const frame = useCurrentFrame(), {fps} = useVideoConfig();
positive(width, "width"); positive(height, "height"); positive(thickness, "thickness"); positive(pulseDuration, "pulseDuration");
const geometry = connectorGeometry(from, to, bend), p = reveal(frame, fps, at, duration);
const elapsed = Math.max(0, frame / fps - at - duration);
const pulseAt = phase(elapsed % pulseDuration, 1, 0, pulseDuration), dot = geometry.point(pulseAt);
return <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} style={{overflow: "visible", ...style}}>
{p > 0 && <path d={geometry.path} fill="none" stroke={color} strokeWidth={thickness} strokeLinecap="round" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - p} />}
{pulse && p === 1 && <circle cx={dot.x} cy={dot.y} r={thickness * 1.8} fill={color} />}
</svg>;
}
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)}; },
};
}