Skip to content20cuts
Menu

HighlightSweep

Sweep a colored mark behind a phrase without covering its letters.

MarkdownView source

Installation

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

Usage

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

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

Props

PropTypeDefault
children

React content to display.

ReactNodeRequired
at

Start time in seconds within the current Remotion Sequence.

number0
duration

Animation duration in seconds.

number0.65
color

CSS color.

string"#b5f36d"
opacity

Highlight opacity from 0 to 1.

number1
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>;
}