MaskReveal
Reveal a layer through a directional clipping mask.
import React, { type CSSProperties, type ReactNode } from "react";
import { AbsoluteFill } from "remotion";
import { MaskReveal } from "./system";
const ink = "#141414", lime = "#b6f36b";
const center: CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center" };
const large: CSSProperties = { fontSize: 104, lineHeight: 1.06, fontWeight: 700, letterSpacing: "-0.055em" };
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>;
}
export function MaskRevealExample() { return <Stage dark><MaskReveal at={0.4} duration={1.2}><div style={{ ...large, maxWidth: 930 }}>Show what your<br /><span style={{ color: lime }}>product does.</span></div></MaskReveal></Stage>; }
Installation
Run once from your product repo. The installer includes MaskReveal, 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 { MaskReveal } from "./system";Usage
The starter already includes MaskRevealExample in Remotion Studio. To edit a copy, save the Code tab as src/MaskRevealExample.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 { MaskRevealExample } from "./MaskRevealExample";
registerRoot(() => (
<Composition
id="MaskRevealExample"
component={MaskRevealExample}
width={1280}
height={720}
fps={30}
durationInFrames={180}
/>
));Props
| Prop | Type | Default |
|---|---|---|
childrenReact content to display. | React.ReactNode | Required |
atStart time in seconds within the current Remotion Sequence. | number | 0 |
durationAnimation duration in seconds. | number | 0.8 |
directionEdge the mask opens toward. | "up" | "down" | "left" | "right" | "up" |
styleStyles applied to the component wrapper. | React.CSSProperties | — |
Source
Read the component files
import React from "react";
import {Easing, useCurrentFrame, useVideoConfig} from "remotion";
/** Seconds come from the current Remotion sequence; no wall-clock animation. */
export const useSeconds = () => useCurrentFrame() / useVideoConfig().fps;
export const easeOut = Easing.bezier(0.16, 1, 0.3, 1);
export const easeInOut = Easing.bezier(0.65, 0, 0.35, 1);
export const progress = (t: number, start: number, duration: number) => {
if (!Number.isFinite(duration) || duration <= 0) throw new Error("Duration must be positive");
return Math.max(0, Math.min(1, (t - start) / duration));
};
/** Preserves final word geometry while each word resolves from blur. */
export function BlurWords({text, at = 0, stagger = 0.07, duration = 0.55, blur = 8, style}: {
text: string; at?: number; stagger?: number; duration?: number; blur?: number; style?: React.CSSProperties;
}) {
const t = useSeconds();
const words = text.trim().split(/\s+/);
return <span style={{whiteSpace: "pre-wrap", ...style}}>{words.map((word, index) => {
const p = easeOut(progress(t, at + index * stagger, duration));
return <React.Fragment key={index}><span style={{display: "inline-block", opacity: p, filter: p < 1 ? `blur(${blur * (1 - p)}px)` : undefined}}>{word}</span>{index < words.length - 1 ? " " : ""}</React.Fragment>;
})}</span>;
}
/** A fixed mask reveals its children; animate the children separately if needed. */
export function MaskReveal({children, at = 0, duration = 0.8, direction = "up", style}: {
children: React.ReactNode; at?: number; duration?: number; direction?: "up" | "down" | "left" | "right"; style?: React.CSSProperties;
}) {
const p = easeOut(progress(useSeconds(), at, duration));
const hidden = `${(1 - p) * 100}%`;
const inset = {up: `${hidden} 0 0 0`, down: `0 0 ${hidden} 0`, left: `0 0 0 ${hidden}`, right: `0 ${hidden} 0 0`}[direction];
return <div style={{...style, clipPath: `inset(${inset})`}}>{children}</div>;
}
export type TerminalStep = {command: string; at: number; typeSeconds: number; lines: {text: string; after: number; positive?: boolean}[]};
/** One command event owns typing and its output; times are sequence-local seconds. */
export function TerminalScene({steps, title = "Product demo", accent = "#bcf269", style}: {
steps: TerminalStep[]; title?: string; accent?: string; style?: React.CSSProperties;
}) {
const t = useSeconds();
const rows = steps.flatMap((step, index) => {
if (step.at < 0 || step.typeSeconds <= 0 || step.lines.some(line => line.after < 0)) throw new Error("Invalid terminal timing");
if (t < step.at) return [];
const enteredAt = step.at + step.typeSeconds;
const typed = Math.floor(step.command.length * progress(t, step.at, step.typeSeconds));
return [<div key={`command-${index}`} style={{color: "#fafbf6", minHeight: 40, whiteSpace: "pre-wrap", overflowWrap: "anywhere"}}><span style={{color: accent}}>❯ </span>{step.command.slice(0, typed)}{t < enteredAt && <span style={{color: accent}}>▌</span>}</div>, ...step.lines.map((line, i) => {
const appearAt = enteredAt + line.after;
const p = easeOut(progress(t, appearAt, 0.25));
return p === 0 ? null : <div key={`${index}-${i}`} style={{minHeight:40, opacity:p, transform:`translateY(${8 * (1-p)}px)`, color: line.positive ? accent : "#a8adb9"}}>{line.text}</div>;
})];
});
return <div style={{background: "#14161c", border:"1px solid #ffffff20", borderRadius:20, overflow:"hidden", boxShadow:"0 28px 100px #00000030", ...style}}>
<div style={{fontSize:16, color:"#969eac", padding:"18px 24px", borderBottom:"1px solid #ffffff15", display:"flex", alignItems:"center", gap:8}}><span style={{width:8,height:8,borderRadius:8,background:accent}}/><span>{title}</span></div>
<div style={{padding:32, fontFamily:"ui-monospace, Menlo, monospace", fontSize:24, lineHeight:"40px"}}>{rows}</div>
</div>;
}