Skip to content20cuts
Menu

RuleDraw

Draw a line from left to right while keeping its layout width fixed.

MarkdownView source

Installation

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

Usage

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

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

Props

PropTypeDefault
at

Start time in seconds within the current Remotion Sequence.

number0
duration

Animation duration in seconds.

number0.65
width

Width in pixels.

number420
thickness

Stroke thickness in pixels.

number6
color

CSS color.

string"currentColor"
style

Styles applied to the component wrapper.

CSSProperties
MotionTiming
MotionTiming type
Source
export type MotionTiming = {at?: number; duration?: number};
PropTypeDefault
at

Start time in seconds within the current Remotion Sequence.

number
duration

Animation duration in seconds.

number

Source

Read the component files
motion/typography.tsx
Source
import React, {type CSSProperties, type ReactNode} from "react";
import {spring, useCurrentFrame, useVideoConfig} from "remotion";
import {finite, inOut, lerp, nonNegative, phase, positive, reveal, staggerAt, type MotionTiming} from "./core.js";

export type StaggeredWordsProps = MotionTiming & {text: string; stagger?: number; distance?: number; style?: CSSProperties};
export function StaggeredWords({text, at = 0, duration = 0.55, stagger = 0.07, distance = 24, style}: StaggeredWordsProps) {
  const frame = useCurrentFrame(), {fps} = useVideoConfig();
  nonNegative(distance, "distance"); nonNegative(stagger, "stagger"); positive(duration, "duration"); finite(at, "at");
  return <div aria-label={text} style={{display: "flex", flexWrap: "wrap", columnGap: "0.28em", rowGap: 0, ...style}}>{text.trim().split(/\s+/).filter(Boolean).map((word, i) => {
    const p = reveal(frame, fps, staggerAt(at, i, stagger), duration);
    return <span aria-hidden key={i} style={{display: "inline-block", opacity: p, transform: `translateY(${distance * (1 - p)}px)`}}>{word}</span>;
  })}</div>;
}

export type KineticCaptionProps = MotionTiming & {text: string; recedeAt?: number; recedeDuration?: number; captionScale?: number; captionY?: number; style?: CSSProperties};
/** A headline establishes the idea, then makes room for a product shot. */
export function KineticCaption({text, at = 0, duration = 0.55, recedeAt = 1.7, recedeDuration = 0.6, captionScale = 0.42, captionY, style}: KineticCaptionProps) {
  const frame = useCurrentFrame(), {fps, height} = useVideoConfig();
  positive(captionScale, "captionScale"); finite(captionY ?? height * 0.88, "captionY");
  const p = inOut(phase(frame, fps, recedeAt, recedeDuration));
  return <div style={{position: "absolute", left: 0, right: 0, textAlign: "center", fontSize: 88, fontWeight: 700, letterSpacing: "-0.05em", ...style, top: lerp(height / 2, captionY ?? height * 0.88, p), transform: `translateY(-50%) scale(${lerp(1, captionScale, p)})`, transformOrigin: "50% 50%"}}>
    <StaggeredWords text={text} at={at} duration={duration} style={{justifyContent: "center"}} />
  </div>;
}

export type SlamWordProps = MotionTiming & {text: string; size?: number; color?: string; style?: CSSProperties};
export function SlamWord({text, at = 0, duration = 0.6, size = 150, color = "currentColor", style}: SlamWordProps) {
  const frame = useCurrentFrame(), {fps} = useVideoConfig();
  positive(size, "size"); positive(duration, "duration"); finite(at, "at");
  const local = frame - at * fps;
  const p = spring({frame: Math.max(0, local), fps, durationInFrames: Math.max(1, Math.round(duration * fps)), config: {damping: 13, stiffness: 320, mass: 0.6}});
  const scale = 1.55 - 0.55 * p;
  return <div style={{fontSize: size, fontWeight: 850, lineHeight: 0.95, letterSpacing: "-0.055em", color, ...style, opacity: local < 0 ? 0 : 1, transform: `scale(${scale})`}}>{text}</div>;
}

export type HypeTickerProps = MotionTiming & {value: number; from?: number; prefix?: string; suffix?: string; decimals?: number; style?: CSSProperties};
export function HypeTicker({value, from = 0, prefix = "", suffix = "", decimals = 0, at = 0, duration = 1.7, style}: HypeTickerProps) {
  const frame = useCurrentFrame(), {fps} = useVideoConfig();
  finite(value, "value"); finite(from, "from");
  if (!Number.isInteger(decimals) || decimals < 0 || decimals > 6) throw new RangeError("decimals must be an integer from zero to six.");
  const p = reveal(frame, fps, at, duration);
  const formatted = new Intl.NumberFormat("en-US", {minimumFractionDigits: decimals, maximumFractionDigits: decimals}).format(lerp(from, value, p));
  return <span style={{fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap", ...style, opacity: frame / fps < at ? 0 : 1}}>{prefix}{formatted}{suffix}</span>;
}

export type RuleDrawProps = MotionTiming & {width?: number; thickness?: number; color?: string; style?: CSSProperties};
export function RuleDraw({at = 0, duration = 0.65, width = 420, thickness = 6, color = "currentColor", style}: RuleDrawProps) {
  const p = reveal(useCurrentFrame(), useVideoConfig().fps, at, duration);
  positive(width, "width"); positive(thickness, "thickness");
  return <div style={{width, height: thickness, borderRadius: thickness / 2, background: color, ...style, opacity: p === 0 ? 0 : 1, transform: `scaleX(${p})`, transformOrigin: "left center"}} />;
}

export type HighlightSweepProps = MotionTiming & {children: ReactNode; color?: string; opacity?: number; style?: CSSProperties};
export function HighlightSweep({children, at = 0, duration = 0.65, color = "#b5f36d", opacity = 1, style}: HighlightSweepProps) {
  const p = reveal(useCurrentFrame(), useVideoConfig().fps, at, duration);
  if (finite(opacity, "opacity") < 0 || opacity > 1) throw new RangeError("opacity must be between zero and one.");
  return <span style={{position: "relative", display: "inline-block", isolation: "isolate", ...style}}>
    <span aria-hidden style={{position: "absolute", inset: "0.05em -0.1em 0", background: color, opacity, zIndex: -1, transform: `scaleX(${p})`, transformOrigin: "left center"}} />
    {children}
  </span>;
}