Skip to content20cuts
Menu

BlurWords

Resolve a sentence from blur one word at a time.

MarkdownView source

Installation

Run once from your product repo. The installer includes BlurWords, 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 { BlurWords } from "./system";

Usage

The starter already includes BlurWordsExample in Remotion Studio. To edit a copy, save the Code tab as src/BlurWordsExample.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 { BlurWordsExample } from "./BlurWordsExample";

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

Props

PropTypeDefault
text

Text to display.

stringRequired
at

Start time in seconds within the current Remotion Sequence.

number0
stagger

Delay between words, in seconds.

number0.07
duration

Reveal duration for each word, in seconds.

number0.55
blur

Initial blur radius in pixels.

number8
style

Styles applied to the component wrapper.

React.CSSProperties

Source

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