Core
Theme tokens, responsive design units, platform safe zones and motion presets. Every reelcn component builds on this file.
Installation
npx shadcn add https://www.reelcn.dev/r/core.jsonFiles land in src/reelcn/ with the items it builds on. Dependencies: remotion.
Usage
<ThemeProvider theme="midnight">
<Stage>
<Center><TextReveal text="Hello" /></Center>
</Stage>
</ThemeProvider>Props
PropTypeDescription
delaynumberFrames to wait before entering. Default
0.durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse
duration for an unrelated local timing. See that item's own prop docs.exitnumber | booleanExit at the end of the parent `<Sequence>`.
false keeps it on screen, a number sets the exit length in frames. Default true.motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to
false. Default false.holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to
0. Default 0.Built from
Source
registry/items/core.tsx
/**
* @title Core
* @category lib
* @description Theme tokens, responsive design units, platform safe zones and motion presets. Every reelcn component builds on this file.
* @example
* <ThemeProvider theme="midnight">
* <Stage>
* <Center><TextReveal text="Hello" /></Center>
* </Stage>
* </ThemeProvider>
*/
import type React from "react";
import { createContext, useContext, useEffect, useId, useState } from "react";
import {
AbsoluteFill,
continueRender,
delayRender,
Easing,
interpolate,
random,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import {
type AnchorRect,
anchorId,
anchorToContentPercent,
catmullRom,
checklistDuration,
checklistSchedule,
clamp01,
codeLivePreviewSchedule,
coverPhase,
type DeprecatedThemeName,
flipInterpolate,
geometricCadence,
graphemeInitial,
graphemes,
layoutRectsFor,
lookAtOffset,
type Mat4,
type MotionPersonality,
type MotionPreset,
mat4LookAt,
mat4Multiply,
matchGraphemes,
type NamedLayout,
occupancy,
type Place,
projectPoint,
proximityWeight,
punchCurve,
type Quat,
quantizeMotion,
quatFromLookAt,
quatSlerp,
type Rect,
rankSlots,
redactRectBudget,
requireAnchor,
resolveThemeName,
type StaggerOrder,
type StaggerShape,
type Step,
stackLayout,
stackOffset,
staggerDelay,
stepsDuration,
type TypingModel,
themeAliases,
useTypedText,
useVariableFontAxis,
type Vec3,
} from "./core-math";
import { strokeWidthProfile } from "./core-stroke";
import { fonts } from "./fonts";
// Pure math (motion quantization, stagger ordering, keyframe interpolation, the typing model, grapheme
// helpers) lives in ./core-math so node --test can import it directly, core.tsx is JSX and can't be
// loaded by node's native TypeScript loader. Re-exported here so no item's import site changes.
export {
type AnchorRect,
anchorId,
anchorToContentPercent,
checklistDuration,
checklistSchedule,
clamp01,
codeLivePreviewSchedule,
coverPhase,
type DeprecatedThemeName,
flipInterpolate,
geometricCadence,
graphemeInitial,
graphemes,
layoutRectsFor,
lookAtOffset,
type Mat4,
type MotionPersonality,
type MotionPreset,
mat4LookAt,
mat4Multiply,
matchGraphemes,
type NamedLayout,
occupancy,
type Place,
projectPoint,
proximityWeight,
punchCurve,
type Quat,
quantizeMotion,
quatFromLookAt,
quatSlerp,
type Rect,
rankSlots,
redactRectBudget,
requireAnchor,
resolveThemeName,
type StaggerOrder,
type StaggerShape,
type Step,
stackLayout,
stackOffset,
staggerDelay,
stepsDuration,
type TypingModel,
themeAliases,
useTypedText,
useVariableFontAxis,
type Vec3,
};
/* ────────────────────────────── Theme ────────────────────────────── */
export type Theme = {
name: string;
colors: {
/** Canvas color. */
background: string;
/** Cards, windows, bubbles. */
surface: string;
foreground: string;
/** Secondary text. */
muted: string;
border: string;
accent: string;
/** Text drawn on top of `accent`. */
accentForeground: string;
/** Emphasis: marker strokes, active caption word. */
highlight: string;
/** Positive/confirmed status (toasts, deltas, diffs). */
success: string;
/** Caution status. */
warning: string;
/** Negative/error status (toasts, deltas, diffs). */
danger: string;
/** Neutral informational status. */
info: string;
/** Base color for drop shadows, before `alpha()`. */
shadow: string;
};
fonts: { heading: string; body: string; mono: string };
/** Weight for headings. Serif display faces look best at 400. */
headingWeight: number;
/** Corner radius in design units (see `useViewport().u`). */
radius: number;
/** Default motion personality for every component. */
motion: MotionPersonality;
/** Line quality for arrow/highlight/scribble-circle/svg-draw (Phase 2). */
stroke: "vector" | "marker" | "brush";
/** Non-color, non-motion visual traits a theme needs and no other field carries. Optional: undefined is a valid, empty `material` for any theme that doesn't need one. */
material?: {
/** 0-1 opacity of the film-grain layer (the `grain` item's default when it gets no explicit `opacity`). */
grain?: number;
/** 0-1 bloom around bright edges, film-look themes only. */
halation?: number;
/** Contact-shadow falloff under a device frame or stage. */
floorShadow?: "hard" | "soft";
};
};
const sans = (family: string) => `${family}, ui-sans-serif, system-ui, sans-serif`;
const serif = (family: string) => `${family}, ui-serif, Georgia, serif`;
const mono = `${fonts.jetbrainsMono}, ui-monospace, SFMono-Regular, monospace`;
export const themes = {
daylight: {
name: "daylight",
colors: {
background: "#EEF0F2",
surface: "#FFFFFF",
foreground: "#121417",
muted: "#5C636E",
border: "#e5e7eb",
accent: "#2F5BFF",
accentForeground: "#ffffff",
highlight: "#FFD23F",
success: "#1E9E5A",
warning: "#B8860B",
danger: "#D6403A",
info: "#2F5BFF",
shadow: "#121417",
},
get fonts() {
return { heading: sans(fonts.schibstedGrotesk), body: sans(fonts.schibstedGrotesk), mono };
},
headingWeight: 700,
radius: 14,
motion: "settle",
stroke: "vector",
material: { floorShadow: "soft" },
},
midnight: {
name: "midnight",
colors: {
background: "#0B0E14",
surface: "#171C28",
foreground: "#E7EAF0",
muted: "#8A93A6",
border: "#2C3346",
accent: "#5F5BFF",
accentForeground: "#FFFFFF",
highlight: "#4FD1FF",
success: "#34D399",
warning: "#FBBF24",
danger: "#FB7185",
info: "#60A5FA",
shadow: "#000000",
},
get fonts() {
return { heading: sans(fonts.spaceGrotesk), body: sans(fonts.inter), mono };
},
headingWeight: 600,
radius: 10,
motion: "snappy",
stroke: "vector",
material: { grain: 0.04 },
},
mono: {
name: "mono",
colors: {
background: "#F5F3EE",
surface: "#FFFFFF",
foreground: "#14140F",
muted: "#6B6A63",
border: "#14140F",
accent: "#C0470A",
accentForeground: "#FFFFFF",
highlight: "#FFD166",
success: "#1F9D55",
warning: "#F5A623",
danger: "#D64545",
info: "#2F6FED",
shadow: "#14140F",
},
get fonts() {
return { heading: sans(fonts.archivoVariable), body: sans(fonts.archivo), mono };
},
headingWeight: 800,
radius: 4,
motion: "snappy",
stroke: "marker",
material: { grain: 0.04, floorShadow: "hard" },
},
ledger: {
name: "ledger",
colors: {
background: "#F7F1E8",
surface: "#FFFFFF",
foreground: "#241F1A",
muted: "#8A7F6E",
border: "#E4D9C8",
accent: "#B5502E",
accentForeground: "#FFFFFF",
highlight: "#7C8A5E",
success: "#4B7A51",
warning: "#C68A2E",
danger: "#A8402C",
info: "#4F6C8A",
shadow: "#241F1A",
},
get fonts() {
return { heading: serif(fonts.fraunces), body: sans(fonts.plusJakartaSans), mono };
},
headingWeight: 600,
radius: 14,
motion: "gentle",
stroke: "brush",
material: { grain: 0.08, floorShadow: "soft" },
},
afterglow: {
name: "afterglow",
colors: {
background: "#F1EEFB",
surface: "#FFFFFF",
foreground: "#201C33",
muted: "#8A84A8",
border: "#DCD6F2",
accent: "#7555FA",
accentForeground: "#FFFFFF",
highlight: "#FF8FB1",
success: "#35B893",
warning: "#F5A93E",
danger: "#E85C6B",
info: "#4FA3F7",
shadow: "#4B3F80",
},
get fonts() {
return { heading: sans(fonts.sora), body: sans(fonts.inter), mono };
},
headingWeight: 600,
radius: 20,
motion: "smooth",
stroke: "vector",
material: { grain: 0.02, floorShadow: "soft" },
},
chromewave: {
name: "chromewave",
colors: {
background: "#E9EAF0",
surface: "#FFFFFF",
foreground: "#14141C",
muted: "#6E7180",
border: "#D3D5E0",
accent: "#C4147A",
accentForeground: "#FFFFFF",
highlight: "#35E0FF",
success: "#2FBE7A",
warning: "#FFC93C",
danger: "#FF4D6D",
info: "#3E8BFF",
shadow: "#14141C",
},
get fonts() {
return { heading: sans(fonts.unbounded), body: sans(fonts.manrope), mono };
},
headingWeight: 700,
radius: 18,
motion: "bouncy",
stroke: "vector",
material: { grain: 0.03, floorShadow: "soft" },
},
} satisfies Record<string, Theme>;
export type ThemeName = keyof typeof themes;
export const themeNames = Object.keys(themes) as ThemeName[];
export type ThemeOverrides = Partial<Omit<Theme, "colors" | "fonts">> & {
colors?: Partial<Theme["colors"]>;
fonts?: Partial<Theme["fonts"]>;
};
/** Build a brand kit on top of a preset: `createTheme("midnight", { colors: { accent: "#ff5a1f" } })`. */
export function createTheme(base: ThemeName | DeprecatedThemeName | Theme, overrides: ThemeOverrides = {}): Theme {
const b: Theme = typeof base === "string" ? themes[resolveThemeName<ThemeName>(base)] : base;
return {
...b,
...overrides,
colors: { ...b.colors, ...overrides.colors },
fonts: { ...b.fonts, ...overrides.fonts },
};
}
const ThemeContext = createContext<Theme>(themes.daylight);
export function ThemeProvider({
theme = "daylight",
children,
}: {
theme?: ThemeName | DeprecatedThemeName | Theme;
children: React.ReactNode;
}) {
const value = typeof theme === "string" ? themes[resolveThemeName<ThemeName>(theme)] : theme;
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export const useTheme = () => useContext(ThemeContext);
/** Any CSS color at a given opacity (0–1). */
export const alpha = (color: string, amount: number) =>
`color-mix(in srgb, ${color} ${Math.round(amount * 100)}%, transparent)`;
/* ───────────────────────────── Viewport ───────────────────────────── */
export type Orientation = "landscape" | "portrait" | "square";
const ViewportContext = createContext<{ width: number; height: number } | null>(null);
/** Make children lay out as if the canvas were this size (picture-in-picture, split screens, contact sheets). */
export function Viewport({ width, height, children }: { width: number; height: number; children: React.ReactNode }) {
return <ViewportContext.Provider value={{ width, height }}>{children}</ViewportContext.Provider>;
}
export type SafeZonePlatform = "generic" | "tiktok" | "reels" | "shorts";
/** Extra right-edge inset in portrait, as a fraction of width, matching `safe-zone-guide`'s own numbers. */
const PORTRAIT_RIGHT_INSET: Record<SafeZonePlatform, number> = {
generic: 0.07,
tiktok: 0.16,
reels: 0.15,
shorts: 0.14,
};
/**
* Canvas size, orientation and design units.
* `u(n)` = n px on a canvas whose short side is 1080px, so one component looks right at 1920×1080, 1080×1920, 1080×1080 and 1280×720.
* `safe` = insets that keep content clear of TikTok / Reels / Shorts UI in portrait, and title-safe margins elsewhere.
* `platform` (default `"generic"`) widens the portrait right inset to match that platform's own action rail; every other value is unchanged.
*/
export function useViewport(platform: SafeZonePlatform = "generic") {
const config = useVideoConfig();
const override = useContext(ViewportContext);
const { width, height } = override ?? config;
const scale = Math.min(width, height) / 1080;
const aspect = width / height;
const orientation: Orientation = aspect > 1.15 ? "landscape" : aspect < 0.87 ? "portrait" : "square";
const safe =
orientation === "portrait"
? { top: height * 0.12, bottom: height * 0.2, x: width * 0.07, right: width * PORTRAIT_RIGHT_INSET[platform] }
: { top: height * 0.08, bottom: height * 0.08, x: width * 0.06, right: width * 0.06 };
return {
width,
height,
scale,
aspect,
orientation,
isPortrait: orientation === "portrait",
isLandscape: orientation === "landscape",
u: (n: number) => n * scale,
safe,
};
}
/* ────────────────────────────── Motion ────────────────────────────── */
export const CLAMP = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
export const easings: Record<Exclude<MotionPreset, "bouncy" | "settle">, (t: number) => number> = {
smooth: Easing.bezier(0.16, 1, 0.3, 1),
snappy: Easing.bezier(0.2, 0.9, 0.1, 1),
gentle: Easing.bezier(0.25, 0.1, 0.25, 1),
linear: Easing.linear,
};
/** Exit curve: strong ease-out that ends at zero velocity, so the last frame of an exit never pops. */
export const exitEasing = Easing.bezier(0.23, 1, 0.32, 1);
/** 0 → 1 over `duration` frames starting at `from`, shaped by a motion preset. `bouncy` overshoots past 1. */
export function tween(
frame: number,
fps: number,
{
from = 0,
duration,
motion = "smooth",
step = 1,
jitter = 0,
seed = "tween",
}: { from?: number; duration: number; motion?: MotionPreset; step?: number; jitter?: number; seed?: string },
): number {
if (duration <= 0) return frame >= from ? 1 : 0;
// wobble in [0, step-1]: holds vary in length, but quantized time never runs backward.
const wobble =
jitter > 0 ? Math.round(random(`${seed}-${Math.floor(frame / step)}`) * Math.min(jitter, 1) * (step - 1)) : 0;
const f = step > 1 ? Math.floor((frame + wobble) / step) * step : frame;
if (motion === "bouncy") {
return spring({
frame: f - from,
fps,
durationInFrames: duration,
config: { damping: 15, stiffness: 170, mass: 0.9 },
});
}
if (motion === "settle") {
const t = Math.min(Math.max((f - from) / duration, 0), 1);
if (t <= 0) return 0;
// Arrive at the 6% overshoot at rest, then ease back: velocity is continuous and it lands on exactly 1.
if (t < 0.6) return interpolate(t, [0, 0.6], [0, 1.06], { ...CLAMP, easing: easings.smooth });
return interpolate(t, [0.6, 1], [1.06, 1], { ...CLAMP, easing: Easing.inOut(Easing.cubic) });
}
return interpolate(f, [from, from + duration], [0, 1], { ...CLAMP, easing: easings[motion] });
}
export type MotionProps = {
/** Frames to wait before entering. */
delay?: number;
/** Enter duration in frames. Defaults to 0.6s. Some items reuse `duration` for an unrelated local timing. See that item's own prop docs. */
duration?: number;
/** Exit at the end of the parent `<Sequence>`. `false` keeps it on screen, a number sets the exit length in frames. */
exit?: boolean | number;
/** Override the theme's motion personality. */
motion?: MotionPersonality;
/** Renders fully entered, with no exit: for a still frame or thumbnail. Defaults to `false`. */
poster?: boolean;
/** Delays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to `0`. */
holdFrames?: number;
};
/**
* Shared enter/exit clock. `enter` goes 0 → 1 (may overshoot with `bouncy`), `exit` goes 0 → 1 during the last frames of the
* parent Sequence, `presence` = clamped enter × (1 − exit).
*/
export function useMotion({
delay = 0,
duration,
exit = true,
motion,
poster = false,
holdFrames = 0,
}: MotionProps = {}) {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
const theme = useTheme();
const personality = quantizeMotion(motion ?? theme.motion);
const preset = personality.preset;
const enterFrames = duration ?? Math.round(fps * 0.6);
if (poster) {
return { frame, fps, durationInFrames, preset, delay, enterFrames, enter: 1, exit: 0, presence: 1 };
}
const exitFrames = typeof exit === "number" ? exit : Math.round(fps * 0.35);
const enter = tween(frame, fps, {
from: delay,
duration: enterFrames,
motion: preset,
step: personality.step,
jitter: personality.jitter,
});
// The last rendered frame is durationInFrames - 1, so the exit has to finish there.
// A single-frame sequence (a Still, a contact-sheet cell) or a zero-length exit has no room to leave,
// and would otherwise ask interpolate for a degenerate range.
const lastFrame = durationInFrames - 1;
const exitEnd = lastFrame - holdFrames;
const out =
exit === false || exitFrames <= 0 || exitEnd <= 0
? 0
: interpolate(frame, [exitEnd - exitFrames, exitEnd], [0, 1], {
...CLAMP,
easing: exitEasing,
});
return {
frame,
fps,
durationInFrames,
preset,
delay,
enterFrames,
enter,
exit: out,
presence: Math.min(enter, 1) * (1 - out),
};
}
/** A locally looped frame number: counts up to `durationInFrames - 1`, holds there for `holdFrames`, then wraps to 0, seamless for a `<Loop>`-wrapped composition. */
export function useLoop({
durationInFrames,
holdFrames = 0,
}: {
durationInFrames: number;
holdFrames?: number;
}): number {
const frame = useCurrentFrame();
const cycle = Math.max(1, durationInFrames + holdFrames);
return Math.min(frame % cycle, durationInFrames - 1);
}
/* ─────────────────────────── Per-role motion ─────────────────────────── */
export type RoleShape = { travel: number; opacityLeadFrames: number };
/** Exit travels 60% as far as enter and finishes its opacity 3 frames ahead of its geometry, so it reads as one quick, clean exit rather than a mirrored entrance. */
export const roleDefaults: { enter: RoleShape; exit: RoleShape } = {
enter: { travel: 1, opacityLeadFrames: 0 },
exit: { travel: 0.6, opacityLeadFrames: 3 },
};
/**
* Splits `useMotion`'s single enter/exit clock into per-role shapes: `opacity` and `geometry` (translate/scale
* progress) can now finish at different times, and `travel(px)` scales a base distance separately for enter and exit.
*/
export function useRoleMotion(
m: ReturnType<typeof useMotion>,
overrides?: { enter?: Partial<RoleShape>; exit?: Partial<RoleShape> },
): { opacity: number; geometry: number; travel: (px: number) => { enterPx: number; exitPx: number } } {
const enterShape = { ...roleDefaults.enter, ...overrides?.enter };
const exitShape = { ...roleDefaults.exit, ...overrides?.exit };
const enter = Math.min(m.enter, 1);
// opacityLeadFrames converted to a share of the exit window, so it stays a frame count regardless of exit length.
// Rescaled rather than offset: 0 at exit start, reaches 1 `lead` share early, with no one-frame step.
const lead = Math.min(0.9, exitShape.opacityLeadFrames / Math.max(m.fps * 0.35, 1));
const exitOpacityLead = Math.min(1, m.exit / (1 - lead));
return {
opacity: enter * (1 - Math.min(exitOpacityLead, 1)),
geometry: enter * (1 - m.exit),
travel: (px: number) => ({ enterPx: px * enterShape.travel, exitPx: px * exitShape.travel }),
};
}
/* ──────────────────────────────── Stagger ─────────────────────────────── */
type SceneClockValue = { delayFor: (index: number, count: number) => number } | null;
const SceneClockContext = createContext<SceneClockValue>(null);
/** Shares one `staggerDelay` timeline with every child that reads it via `useSceneClock`. */
export function SceneClock({
step,
order,
shape,
seed,
jitter,
children,
}: {
step: number;
order?: StaggerOrder;
shape?: StaggerShape;
seed?: string;
jitter?: number;
children: (delayFor: (index: number, count: number) => number) => React.ReactNode;
}) {
const delayFor = (index: number, count: number) => staggerDelay(index, count, { step, order, shape, seed, jitter });
return <SceneClockContext.Provider value={{ delayFor }}>{children(delayFor)}</SceneClockContext.Provider>;
}
/** `null` outside a `SceneClock`, so an item can fall back to its own standalone `step` prop. */
export const useSceneClock = () => useContext(SceneClockContext);
/* ────────────────────────────── Layout ────────────────────────────── */
/** Full-bleed canvas painted with the theme background, text color and body font. */
export function Stage({
children,
background,
style,
className,
}: {
children?: React.ReactNode;
background?: string;
style?: React.CSSProperties;
className?: string;
}) {
const t = useTheme();
return (
<AbsoluteFill
className={className}
style={{
background: background ?? t.colors.background,
color: t.colors.foreground,
fontFamily: t.fonts.body,
...style,
}}
>
{children}
</AbsoluteFill>
);
}
/** Centers children inside the safe zone. */
export function Center({
children,
style,
className,
}: {
children?: React.ReactNode;
style?: React.CSSProperties;
className?: string;
}) {
const { safe } = useViewport();
return (
<AbsoluteFill
className={className}
style={{
alignItems: "center",
justifyContent: "center",
padding: `${safe.top}px ${safe.x}px ${safe.bottom}px`,
...style,
}}
>
{children}
</AbsoluteFill>
);
}
/* ────────────────────────────── Text ────────────────────────────── */
/* ────────────────────────────── Text metrics ────────────────────────────── */
let measureContext: CanvasRenderingContext2D | null = null;
export function measurePx(text: string, font: string): number {
if (typeof document === "undefined") return 0;
if (!measureContext) measureContext = document.createElement("canvas").getContext("2d");
if (!measureContext) return 0;
measureContext.font = font;
return measureContext.measureText(text).width;
}
/** True once `document.fonts` has a face covering this weight for any family in the stack (not just the first). */
function fontFaceReady(stack: string, weight: number): boolean {
if (typeof document === "undefined" || !document.fonts) return false;
const families = stack.split(",").map((f) => f.trim().replace(/["']/g, ""));
let found = false;
document.fonts.forEach((face) => {
const range = face.weight.split(" ");
const covers = Number(range[0]) <= weight && weight <= Number(range[range.length - 1]);
if (covers && families.indexOf(face.family.replace(/["']/g, "")) !== -1) found = true;
});
return found;
}
/** Bounded fallback for `useTextMetrics`, matching the old fit-title cap (`++attempts > 40`, ~2s at one check per frame). */
const FONT_READY_TIMEOUT_MS = 2000;
/**
* Real width of `text` set in `style`, measured against the loaded font, gated on the browser's own font-ready
* signal (`document.fonts.ready` plus a face check, since a font can resolve `ready` before its face is queryable)
* through `delayRender`/`continueRender`, not a fixed-attempt timer.
*/
export function useTextMetrics(
text: string,
style: { fontFamily: string; fontSize: number; fontWeight?: number; letterSpacing?: number },
opts?: {
/** Skips the delayRender/font-ready gate and the measurement itself: for a value that's computed but not always used, like a follow-caret position when `follow` is unset. Always call the hook; vary this flag, never the call itself. */ skip?: boolean;
},
): { width: number; ready: boolean } {
const skip = opts?.skip ?? false;
const { fontFamily, fontSize, fontWeight = 400, letterSpacing = 0 } = style;
const [handle] = useState(() => (skip ? null : delayRender("useTextMetrics font")));
const [ready, setReady] = useState(() => skip || fontFaceReady(fontFamily, fontWeight));
useEffect(() => {
if (skip || ready) return;
let cancelled = false;
document.fonts.ready.then(() => {
if (!cancelled && fontFaceReady(fontFamily, fontWeight)) setReady(true);
});
// Safety net: if the face check never resolves (e.g. document.fonts.ready never settles), proceed
// with whatever measurement is available instead of hanging the render forever.
const timeout = setTimeout(() => {
if (!cancelled) setReady(true);
}, FONT_READY_TIMEOUT_MS);
return () => {
cancelled = true;
clearTimeout(timeout);
};
}, [skip, ready, fontFamily, fontWeight]);
useEffect(() => {
if (!skip && ready && handle !== null) continueRender(handle);
}, [skip, ready, handle]);
const width =
!skip && ready
? measurePx(text, `${fontWeight} ${fontSize}px ${fontFamily}`) + Math.max(text.length - 1, 0) * letterSpacing
: 0;
return { width, ready };
}
export type CaretFollow = { zoom?: number; lag?: number };
/**
* Zooms toward `caret` (design-unit coordinates the caller already measured) when `follow` is set;
* otherwise a no-op passthrough, the opt-in, backward-compatible default every typing item's `follow`
* prop relies on. Clamped inside `useViewport().safe` so the zoomed caret never leaves the frame's safe
* zone.
*/
export function FollowCaret({
follow,
caret,
children,
}: {
follow?: CaretFollow;
caret: { x: number; y: number };
children: React.ReactNode;
}) {
const { width, height, safe } = useViewport();
if (!follow) return <>{children}</>;
const zoom = follow.zoom ?? 2.5;
const clampedX = Math.min(Math.max(caret.x, safe.x), width - safe.x);
const clampedY = Math.min(Math.max(caret.y, safe.top), height - safe.bottom);
return (
<AbsoluteFill style={{ overflow: "hidden" }}>
<AbsoluteFill
style={{
transformOrigin: "0 0",
transform: `translate(${width / 2 - zoom * clampedX}px, ${height / 2 - zoom * clampedY}px) scale(${zoom})`,
}}
>
{children}
</AbsoluteFill>
</AbsoluteFill>
);
}
/* ───────────────────────────────── Paths ───────────────────────────────── */
export type PoseKey = {
frame: number;
x: number;
y: number;
scale?: number;
rotate?: number;
width?: number;
height?: number;
};
/** The pose at `frame`, interpolated through every key with one global Catmull-Rom curve instead of per-segment lines. */
export function useKeyframePath(keys: PoseKey[], opts?: { motion?: MotionPreset }): PoseKey {
const { fps } = useVideoConfig();
const frame = useCurrentFrame();
const sorted = keys.slice().sort((a, b) => a.frame - b.frame);
if (sorted.length === 0) return { frame, x: 0, y: 0, scale: 1, rotate: 0 };
if (sorted.length === 1) return sorted[0];
const clamped = Math.min(Math.max(frame, sorted[0].frame), sorted[sorted.length - 1].frame);
let segment = 0;
while (segment < sorted.length - 2 && clamped >= sorted[segment + 1].frame) segment++;
const at = (i: number) => sorted[Math.min(Math.max(i, 0), sorted.length - 1)];
const p0 = at(segment - 1);
const p1 = at(segment);
const p2 = at(segment + 1);
const p3 = at(segment + 2);
const span = Math.max(p2.frame - p1.frame, 1);
const localT = tween(clamped, fps, { from: p1.frame, duration: span, motion: opts?.motion ?? "linear" });
// With no explicit motion, ease the path's first and last segments so the move starts and stops at rest;
// easeInC1 has v(0)=0, v(1)=1, so it joins a linear middle segment without a kink.
const lastSeg = sorted.length - 2;
const easeInC1 = (t: number) => t * t * (2 - t);
const pathT = opts?.motion
? localT
: segment === 0 && segment === lastSeg
? Easing.inOut(Easing.cubic)(localT)
: segment === 0
? easeInC1(localT)
: segment === lastSeg
? 1 - easeInC1(1 - localT)
: localT;
type Field = "x" | "y" | "scale" | "rotate" | "width" | "height";
const field = (key: PoseKey, name: Field, fallback: number) => key[name] ?? fallback;
const at4 = (name: Field, fallback: number) =>
catmullRom(
field(p0, name, fallback),
field(p1, name, fallback),
field(p2, name, fallback),
field(p3, name, fallback),
pathT,
);
return {
frame: clamped,
x: at4("x", 0),
y: at4("y", 0),
scale: at4("scale", 1),
rotate: at4("rotate", 0),
width: at4("width", 100),
height: at4("height", 100),
};
}
/**
* Folds `steps` (timestamps in seconds) over the current frame: the previous step in array order is
* `from`, the entered step is `state` (the "to"), `progress` is `tween`'s eased 0-1 between them, shaped
* by the theme's own motion personality unless `opts.motion` overrides it. Before the first step's frame,
* or once the last step has fully arrived, `from === state` and `progress` is 1, nothing left to tween.
* A component with no `steps` at all renders its `initial` state, statically (`progress: 1`).
*/
export function useKeyframeState<S>(
steps: Step<S>[] | undefined,
initial: S,
opts?: { motion?: MotionPersonality },
): { state: S; from: S; progress: number } {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const theme = useTheme();
const list = steps && steps.length > 0 ? steps : [{ at: 0, state: initial }];
const personality = quantizeMotion(opts?.motion ?? theme.motion);
const atFrame = (step: Step<S>) => Math.round(step.at * fps);
// The previous step in array order: the last step whose frame has arrived, or the first step if none
// have (spec §3's fold rule: ties resolve by array order, not by numeric proximity).
let prevIndex = 0;
for (let i = 0; i < list.length; i++) {
if (atFrame(list[i]) <= frame) prevIndex = i;
}
const prev = list[prevIndex];
const next = list[prevIndex + 1];
const from = prev.state ?? initial;
if (!next || frame < atFrame(prev)) return { state: from, from, progress: 1 };
const to = next.state ?? from;
const progress = tween(frame, fps, {
from: atFrame(prev),
duration: Math.max(1, atFrame(next) - atFrame(prev)),
motion: personality.preset,
step: personality.step,
jitter: personality.jitter,
});
return { state: to, from, progress };
}
export type StrokeKind = Theme["stroke"];
/**
* Renders `d` (already in a `pathLength={1}`-normalized coordinate space, the convention every stroke
* consumer here already uses) as one of the three line qualities: `vector` is a single clean stroke,
* `marker` adds a wider, blurred, low-opacity duplicate underneath (a feathered double-stroke), `brush`
* splits the path into segments whose width follows strokeWidthProfile (thick mid, thin ends) plus a
* seeded grain filter. `drawn` is the existing 0-1 draw-on progress every consumer already computes
* (`strokeDashoffset={1 - drawn}`); this component owns the visual, not the timing.
*/
export function StrokeOverlay({
d,
kind,
seed,
color,
strokeWidth,
drawn,
extraProps,
}: {
d: string;
kind: StrokeKind;
seed: string;
color: string;
strokeWidth: number;
drawn: number;
/** Passed to every underlying `<path>` (e.g. `strokeLinecap`, `fill`). */
extraProps?: React.SVGProps<SVGPathElement>;
}) {
// Hidden until drawing starts: a round cap on a fully offset dash still paints a dot at the path start.
const dashProps = {
pathLength: 1,
strokeDasharray: 1,
strokeDashoffset: 1 - drawn,
strokeOpacity: drawn > 0 ? 1 : 0,
} as const;
// Mixed into every filter id below so 2+ StrokeOverlay instances sharing a `seed` (e.g. highlight.tsx's
// hardcoded seeds, or Arrow/ScribbleCircle defaults) don't collide on the same <filter id> and end up
// resolving `url(#id)` to each other's filter. `seed` alone still drives noise/random seeding so pixels
// stay deterministic.
const instanceId = useId().replace(/:/g, "");
if (kind === "vector") {
return <path d={d} stroke={color} strokeWidth={strokeWidth} fill="none" {...dashProps} {...extraProps} />;
}
if (kind === "marker") {
const featherId = `stroke-feather-${instanceId}-${seed.replace(/[^a-zA-Z0-9_-]/g, "")}`;
return (
<>
<defs>
<filter id={featherId} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation={strokeWidth * 0.35} />
</filter>
</defs>
<path
d={d}
stroke={color}
strokeWidth={strokeWidth * 1.7}
fill="none"
opacity={0.3}
filter={`url(#${featherId})`}
{...dashProps}
{...extraProps}
/>
<path d={d} stroke={color} strokeWidth={strokeWidth} fill="none" {...dashProps} {...extraProps} />
</>
);
}
// brush: N normalized-length segments, each with its own strokeWidthProfile-driven width, plus a
// shared seeded grain filter so the ribbon reads as textured rather than a flat taper.
const grainId = `stroke-grain-${instanceId}-${seed.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const segments = 10;
return (
<>
<defs>
<filter id={grainId}>
<feTurbulence
type="fractalNoise"
baseFrequency={0.9}
numOctaves={2}
seed={Math.round(random(`${seed}-grain`) * 1000)}
/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0" />
<feComposite operator="in" in2="SourceGraphic" />
</filter>
</defs>
{Array.from({ length: segments }, (_, i) => {
const segLen = 1 / segments;
const t = (i + 0.5) * segLen;
const w = strokeWidth * strokeWidthProfile(t, seed);
return (
<path
key={i}
d={d}
stroke={color}
strokeWidth={w}
fill="none"
pathLength={1}
strokeDasharray={`${segLen} ${1 - segLen}`}
strokeDashoffset={i * -segLen + (1 - drawn)}
filter={`url(#${grainId})`}
{...extraProps}
/>
);
})}
</>
);
}