Skip to content20cuts
Menu

AppFrame

Put product screens inside a clean application window with fixed dimensions.

MarkdownView source

Installation

Run once from your product repo. The installer includes AppFrame, its source, and the agent skill.

Terminal
npx -y https://20cuts.com/releases/v0.3.0/20cuts-create-0.3.0.tgz video/20cuts

Already installed? Import it in your video project. Full setup guide

Import
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.

src/index.tsx
import { Composition, registerRoot } from "remotion";
import { AppFrameExample } from "./AppFrameExample";

registerRoot(() => (
  <Composition
    id="AppFrameExample"
    component={AppFrameExample}
    width={1280}
    height={720}
    fps={30}
    durationInFrames={180}
  />
));

Props

PropTypeDefault
children

React content to display.

ReactNodeRequired
title

Window title.

string"Your product"
width

Width in pixels.

number960
height

Height in pixels.

number540
background

CSS background inside the window.

string"#fff"
style

Styles applied to the component wrapper.

CSSProperties

Source

Read the component files
motion/surfaces.tsx
Source
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>;
}