FocusPull
Blur and dim a background layer to direct attention to a foreground result.
import React, { type CSSProperties, type ReactNode } from "react";
import { AbsoluteFill } from "remotion";
import { AppFrame, FocusPull, ScaleIn } 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 FocusPullExample() { return <Stage><FocusPull at={1.4} duration={0.8} blur={8} dim={0.35}><AppFrame width={980} height={550}><Dashboard /></AppFrame></FocusPull><ScaleIn at={1.8} duration={0.8} style={{ position: "absolute" }}><div style={{ ...card, padding: "38px 52px", fontSize: 52, boxShadow: "0 20px 80px #0002", letterSpacing: "-0.04em" }}>All clear. <span style={{ color: "#62972d" }}>✓</span></div></ScaleIn></Stage>; }
Installation
Run once from your product repo. The installer includes FocusPull, 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 { FocusPull } from "./system/motion";Usage
The starter already includes FocusPullExample in Remotion Studio. To edit a copy, save the Code tab as src/FocusPullExample.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 { FocusPullExample } from "./FocusPullExample";
registerRoot(() => (
<Composition
id="FocusPullExample"
component={FocusPullExample}
width={1280}
height={720}
fps={30}
durationInFrames={180}
/>
));Props
| Prop | Type | Default |
|---|---|---|
childrenReact content to display. | ReactNode | Required |
atStart time in seconds within the current Remotion Sequence. | number | 0 |
durationAnimation duration in seconds. | number | 0.7 |
directionOut adds blur and dimming; in removes them. | "out" | "in" | "out" |
blurMaximum blur radius in pixels. | number | 6 |
dimOpacity at full defocus, from 0 to 1. | number | 0.45 |
styleStyles applied to the component wrapper. | CSSProperties | — |
- Place foreground content outside this wrapper to keep it sharp.
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 | — |
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)}; },
};
}