HypeTicker
Count to a numeric result with explicit precision and tabular digits.
import React, { type CSSProperties, type ReactNode } from "react";
import { AbsoluteFill } from "remotion";
import { HypeTicker } from "./system";
const ink = "#141414", lime = "#b6f36b";
const center: CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center" };
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 HypeTickerExample() { return <Stage dark><div style={{ textAlign: "center" }}><div style={{ fontSize: 30, color: "#aaa", marginBottom: 24 }}>Minutes back in your day</div><HypeTicker value={120} at={0.4} duration={2} suffix="+" style={{ fontSize: 200, fontWeight: 700, letterSpacing: "-0.07em", color: lime }}/></div></Stage>; }
Installation
Run once from your product repo. The installer includes HypeTicker, 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 { HypeTicker } from "./system/motion";Usage
The starter already includes HypeTickerExample in Remotion Studio. To edit a copy, save the Code tab as src/HypeTickerExample.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 { HypeTickerExample } from "./HypeTickerExample";
registerRoot(() => (
<Composition
id="HypeTickerExample"
component={HypeTickerExample}
width={1280}
height={720}
fps={30}
durationInFrames={180}
/>
));Props
| Prop | Type | Default |
|---|---|---|
valueFinal number. | number | Required |
fromStarting number. | number | 0 |
prefixText before the number. | string | "" |
suffixText after the number. | string | "" |
decimalsFraction digits; an integer from 0 to 6. | number | 0 |
atStart time in seconds within the current Remotion Sequence. | number | 0 |
durationAnimation duration in seconds. | number | 1.7 |
styleStyles applied to the component wrapper. | CSSProperties | — |
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 {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>;
}
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)}; },
};
}