snapcn
componenttype-morphsource: ok · hit
Type Morph
Typewriter effect: a headline types with a blinking caret, then morphs letter by letter. A headline that types itself under a glowing caret, sheds its lead, morphs letter by letter, and ends under a colour flood.
npx shadcn add "https://snapcn.dev/r/type-morph.json"free
Interaction
text-animation
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightremotion@remotion/google-fonts
Registry dependencies
https://snapcn.dev/r/snap-cn-ui.jsonhttps://snapcn.dev/r/text-reveal.json
Files2 · source cached 10/8/2026, 6:50:00 PM
registry/snap-cn/type-morph/index.tsx
// Type Morph · snapcn — https://snapcn.dev/docs/text/type-morph
"use client";
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
import { type CSSProperties, useLayoutEffect, useRef, useState } from "react";
import {
AbsoluteFill,
continueRender,
delayRender,
Easing,
getRemotionEnvironment,
interpolate,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { TextReveal } from "@/components/snap-cn/text-reveal";
import {
mixOklch,
resolveFont,
type SnapCnTheme,
useSnapCnTheme,
} from "@/lib/snap-cn-ui";
import {
CUT_AT,
CUT2_AT,
EXIT_COEFF,
EXIT_FROM,
EXIT_RATIO,
FLOOD_STAGE_FRAMES,
MORPH_FROM,
MORPH_TO,
SLIDE_BEZIER,
SLIDE_FROM,
SLIDE_TO,
} from "./type-morph-timeline";
const { fontFamily: SANS } = loadInter("normal", {
weights: ["400", "700"],
subsets: ["latin"],
});
export interface TypeMorphProps {
/**
* The line as it is typed. Everything up to and including `lead` is cut away
* once typing finishes, which is what leaves the emphasis alone on screen.
*/
lead?: string;
/** The emphasised tail of the typed line — this is what survives the cut. */
emphasis?: string;
/** What the emphasis morphs into, letter by letter. */
morphTo?: string;
/**
* What is left after the second cut. Empty ends the beat on the bare flood.
*/
finally_?: string;
/**
* Whether the final phrase survives into the flood.
*
* The reference carries it across; off is the cleaner ending, and it is the
* default because a word sitting on a full-bleed colour for four frames reads
* as a leftover rather than as a beat.
*/
wordOnFlood?: boolean;
/** Accepted, not drawn: the caret and fresh glyphs take the ink colour. */
accent?: string;
/**
* Two flat colours to flood the frame with at the end, or `false` to end on
* paper — the default.
*
* The reference floods; off is the quieter ending and the one that composes,
* because a beat that hands the next scene a full-bleed colour has decided
* what that scene opens on.
*
* Two flat colours rather than a ramp: the reference arrives at 99% frame
* coverage inside a single frame and then steps once. A crossfade between them
* would put a half-transparent wash over the type for several frames, and
* there is no reading of that except the screen dimming.
*/
flood?: [string, string] | false;
/**
* The page.
*
* Defaults to the theme's background so the component belongs in whatever
* project installs it. The reference's paper is a plain near-white (#f7f7f7 to
* #fafafa across the beat), not the warm off-white the token carries, so the
* preview passes its own.
*/
background?: string;
/** The type. Defaults to the theme's foreground. */
ink?: string;
/**
* The opening line is rendered by `text-reveal` itself.
*
* Not a reimplementation of it, and not its helper functions rebuilt into a
* second animator — the component, mounted. Everything in `reveal` is passed
* to it untouched, so its lead-word push, its recede and its assemble are
* exactly what that component does anywhere else, and there is nothing here
* that can drift away from it.
*/
reveal?: Omit<
React.ComponentProps<typeof TextReveal>,
"text" | "theme" | "mode" | "color" | "fontSize"
>;
/**
* Cap-to-descender height as a fraction of the frame height.
*
* 0.1256, not the 0.139 the reference's raw ink height suggests: measured back
* from the rendered line width against the reference's at the same frame, mine
* came out 12.7% wide at 0.139 and 4% narrow at 0.1233, because ink height
* includes the descender and the face's
* own metrics differ from the reference's.
*/
size?: number;
theme?: Partial<SnapCnTheme>;
mode?: "light" | "dark";
/**
* The face this scene paints its words in — a label from `fonts.ts`
* ("Inter", "Space Grotesk", "Instrument Serif") or a CSS family you have
* loaded yourself. Unset, the scene keeps the face it was designed around.
*
* Overrides `theme.fontFamily`, which is how a brand kit re-skins a whole
* timeline from one value.
*/
fontFamily?: string;
}
/**
* `will-change` is right for the Player and wrong for the render — a render
* draws each frame in a different tab, and a promoted layer hands that tab a
* stale raster from whatever scale it last drew.
*/
const promoted = getRemotionEnvironment().isRendering
? null
: ({ willChange: "transform" } as const);
/**
* Hinting bends each glyph so its stems land on whole pixels, so as anything
* moves the letterforms re-snap and boil. This also forces sub-pixel glyph
* positioning, which Blink otherwise only enables above a device scale factor of
* 1 — and a render is exactly 1, so without it every glyph in the morph would
* land on a whole pixel and the travel would stutter.
*/
const CRISP: CSSProperties = {
textRendering: "geometricPrecision",
WebkitFontSmoothing: "antialiased",
};
/** Moderate decelerate. Quint/expo spend frames below half a pixel, which rasterise identically. */
const EASE = Easing.bezier(0.2, 0.6, 0.35, 1);
/**
* Pair each glyph of `to` with a glyph of `from` that can travel to it.
*
* Greedy nearest-unused match on the character itself. It is not the optimal
* assignment and does not need to be — what the eye reads is *continuity*, that
* some letters persisted rather than the whole line being replaced, and a
* nearest match delivers that while an optimal one costs a matrix and looks the
* same. Unmatched targets fade up in place; unmatched sources fade out in place.
*/
function pairGlyphs(from: string, to: string): (number | null)[] {
const used = new Set<number>();
return [...to].map((ch, ti) => {
let best: number | null = null;
let bestDist = Infinity;
for (let fi = 0; fi < from.length; fi += 1) {
if (used.has(fi) || from[fi] !== ch) continue;
const dist = Math.abs(fi - ti);
if (dist < bestDist) {
bestDist = dist;
best = fi;
}
}
if (best !== null) used.add(best);
return best;
});
}
/** Per-glyph x offsets of a string, measured in the real face at the real size. */
function useGlyphOffsets(strings: string[], fontSize: number, face: string) {
const ref = useRef<HTMLDivElement>(null);
const [offsets, setOffsets] = useState<number[][] | null>(null);
// Every frame of a render is a fresh mount, so a plain effect + state lets the
// frame be captured *before* the measurement has re-rendered — the glyphs are
// then placed with no offsets at all and the frame comes out blank or short.
// `delayRender` holds the capture until the second render has happened.
const [handle] = useState(() => delayRender("type-morph: measuring glyphs"));
useLayoutEffect(() => {
const root = ref.current;
if (!root) return;
// `offsetLeft` is layout px and unaffected by transforms — which is exactly
// why it is the right tool for measuring a line that is about to be
// transformed, which is exactly why it is the right tool for measuring a
// line that is about to be transformed.
setOffsets(
[...root.children].map((line) =>
[...(line as HTMLElement).children].map(
(g) => (g as HTMLElement).offsetLeft,
),
),
);
}, []);
// Only once the measured offsets are actually in the tree.
useLayoutEffect(() => {
if (offsets) continueRender(handle);
}, [offsets, handle]);
const probe = (
<div
ref={ref}
aria-hidden
style={{
position: "absolute",
visibility: "hidden",
pointerEvents: "none",
whiteSpace: "pre",
fontFamily: face,
fontSize,
letterSpacing: "-0.035em",
...CRISP,
}}
>
{strings.map((s) => (
<div key={s} style={{ whiteSpace: "pre" }}>
{[...s].map((ch, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: glyph position is the identity here
<span key={i} style={{ display: "inline-block" }}>
{ch === " " ? " " : ch}
</span>
))}
</div>
))}
</div>
);
return { probe, offsets };
}
/**
* A headline that types itself, sheds its lead, morphs letter by letter, and
* ends under a colour flood.
*
* Four mechanics on one timeline, each measured off a reference frame by frame
* (see `timeline.ts` for the signals and the numbers they produced):
*
* 1. **Typing.** The line is centre-anchored, so it grows in both directions
* rather than running to the right. Freshly landed glyphs arrive in the
* accent colour and cool to ink over four frames, which is what gives the
* typing a moving warm edge instead of a hard cursor.
* 2. **The cut.** The lead is removed in a single frame, not animated out. What
* *is* animated is the slide back to centre that follows it.
* 3. **The morph.** Glyphs shared between the two phrases travel from where they
* were to where they belong; the rest cross-fade in place.
* 4. **The flood.** Two flat colours, four frames each.
*
* Every glyph position is measured in the real face at the real size behind a
* hidden probe, because a morph built on estimated advance widths lands each
* letter a pixel or two off and reads as a wobble.
*/
export function TypeMorph({
lead = "Not just ",
emphasis = "communicator.",
morphTo = "something more.",
finally_ = "more.",
wordOnFlood = false,
background,
ink,
reveal,
flood = false,
size = 0.1256,
theme,
mode,
fontFamily,
}: TypeMorphProps) {
const frame = useCurrentFrame();
const { height, width } = useVideoConfig();
const t = useSnapCnTheme(theme, mode);
const face = resolveFont(fontFamily ?? t.fontFamily) ?? SANS;
const paper = background ?? t.background;
const type = ink ?? t.foreground;
const fontSize = height * size;
const typed = lead + emphasis;
const { probe, offsets } = useGlyphOffsets(
[typed, emphasis, morphTo, finally_],
fontSize,
face,
);
// ── the timeline ──────────────────────────────────────────────────────────
//
// Absolute frame numbers, measured, not derived from each other. Deriving them
// meant one wrong constant early shifted everything after it.
const floodAt = CUT2_AT + 1;
const floodBAt = floodAt + FLOOD_STAGE_FRAMES;
const phase =
flood && frame >= floodAt
? "flood"
: frame >= CUT2_AT
? "final"
: frame >= MORPH_FROM
? "morph"
: frame >= CUT_AT
? "slide"
: "type";
const floodColor =
flood && frame >= floodAt
? frame >= floodBAt
? flood[1]
: flood[0]
: null;
const centre = width / 2;
/**
* The line's horizontal offset — ONE continuous function of frame.
*
* The reference does not cut and then move; it moves continuously and the cut
* only changes what is drawn. A single 16-frame cubic-bezier fits all 17
* frames of the re-centring, straight through the cut, to 0.067px. Modelling
* it as "drift, cut, slide" fits the same curve twice and puts a seam where
* the reference has none.
*
* The travel is not a fixed distance — it is however far off-centre the
* emphasis happens to sit inside the typed line, which the measured probe
* already knows. On the reference that works out to 184.4px right of centre on
* a 1400px frame; on any other phrase it is whatever that phrase needs.
*/
const slideProgress = interpolate(frame, [SLIDE_FROM, SLIDE_TO], [0, 1], {
easing: Easing.bezier(...SLIDE_BEZIER),
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
/** The exit: geometric per-frame velocity. Persists past the cut it precedes. */
//
// It stops at the cut. The reference carries the drifted position into what
// the cut leaves behind, but the final phrase here is centred by choice, and a
// drift still applied to it would drag a centred phrase off to the left — 300px
// by the last frame.
const exitDrift =
frame <= EXIT_FROM || frame >= CUT2_AT
? 0
: (-EXIT_COEFF * (EXIT_RATIO ** (frame - EXIT_FROM) - 1) * width) / 1400;
/** One glyph, absolutely placed, so nothing reflows as the line changes. */
const glyph = (
key: string,
ch: string,
x: number,
lineWidth: number,
style: CSSProperties,
) => (
<span
key={key}
style={{
position: "absolute",
left: centre - lineWidth / 2 + x,
top: 0,
whiteSpace: "pre",
...style,
}}
>
{ch === " " ? " " : ch}
</span>
);
let content: React.ReactNode = null;
if (offsets) {
const [typedX, emphX, morphX, finalX] = offsets;
const widthOf = (xs: number[], str: string) =>
(xs[str.length - 1] ?? 0) + fontSize * 0.34;
const wTyped = widthOf(typedX, typed);
const wEmph = widthOf(emphX, emphasis);
const wMorph = widthOf(morphX, morphTo);
// Where the emphasis sits inside the typed line. The slide carries the whole
// line, so both the typed phase and the post-cut phase read the same offset.
const emphInTyped = typedX[lead.length] - wTyped / 2 + wEmph / 2;
if (phase === "type") {
// `text-reveal` owns these frames entirely — see the render below.
content = null;
} else if (phase === "slide") {
// From the cut to the end of the slide, this component draws the line —
// `text-reveal` has already delivered it, centred, and the emphasis now
// slides on the measured bezier.
const w = widthOf(emphX, emphasis);
const dx = -emphInTyped * (1 - slideProgress);
content = [...emphasis].map((ch, i) =>
glyph(`e${i}`, ch, emphX[i] + dx, w, {
color: type,
fontWeight: 700,
}),
);
} else if (phase === "morph") {
const p = interpolate(frame, [MORPH_FROM, MORPH_TO], [0, 1], {
easing: EASE,
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const w = interpolate(p, [0, 1], [wEmph, wMorph]);
const pairs = pairGlyphs(emphasis, morphTo);
const matched = new Set(pairs.filter((x): x is number => x !== null));
content = [
...[...emphasis].map((ch, i) =>
matched.has(i)
? null
: glyph(`x${i}`, ch, emphX[i] + (wEmph - w) / 2, w, {
color: type,
fontWeight: 700,
opacity: interpolate(p, [0, 0.45], [1, 0], {
extrapolateRight: "clamp",
}),
}),
),
...[...morphTo].map((ch, i) => {
const src = pairs[i];
const to = morphX[i] + (wMorph - w) / 2;
if (src === null) {
return glyph(`n${i}`, ch, to, w, {
color: type,
fontWeight: 700,
opacity: interpolate(p, [0.55, 1], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
}),
});
}
const fromX = emphX[src] + (wEmph - w) / 2;
return glyph(`m${i}`, ch, interpolate(p, [0, 1], [fromX, to]), w, {
color: type,
fontWeight: 700,
});
}),
].filter(Boolean);
} else {
// The final phrase, CENTRED — it does not inherit the drifted position.
const w = widthOf(finalX, finally_);
const hidden = phase === "flood" && !wordOnFlood;
content = hidden
? []
: [...finally_].map((ch, i) =>
glyph(`f${i}`, ch, finalX[i], w, {
fontWeight: 700,
color: floodColor
? mixOklch(floodColor, t.background, 0.92)
: type,
opacity: 1,
}),
);
}
}
return (
<AbsoluteFill
style={{
background: floodColor ?? paper,
fontFamily: face,
...CRISP,
}}
>
{probe}
{frame < CUT_AT ? (
<TextReveal
text={typed}
fontSize={fontSize}
color={type}
theme={theme}
mode={mode}
{...reveal}
/>
) : null}
<div
style={{
position: "absolute",
top: "50%",
left: 0,
right: 0,
height: 0,
fontSize,
lineHeight: 1,
letterSpacing: "-0.035em",
transform: `translate(${exitDrift.toFixed(2)}px, ${-fontSize * 0.36}px)`,
...promoted,
}}
>
{content}
</div>
</AbsoluteFill>
);
}
export default TypeMorph;
registry/snap-cn/type-morph/type-morph-timeline.ts
// Type Morph · snapcn — https://snapcn.dev/docs/text/type-morph
/**
* Every number here was measured off the reference recording, frame by frame.
*
* Source: a 30fps screen recording, 89 frames, 1400x762. Frames were cut at
* 30fps and each reduced to signals — the bounding box of ink (a green-channel
* threshold isolates glyph cores and rejects the violet bloom, which a mean-RGB
* threshold does not), the centroid and count of chromatic pixels, and mean
* luminance. Curves were then fitted against exponential, polynomial and
* cubic-bezier families and the residuals compared, rather than a shape being
* assumed.
*
* Frame numbers below are 0-based composition frames. The source's `g001.png` is
* frame 0.
*/
export const FPS = 30;
// ── The opening ──────────────────────────────────────────────────────────────
/**
* Frames 0 to `CUT_AT` belong to `text-reveal`, which this component mounts
* rather than reimplements — so its schedule lives there, not here.
*
* What the reference does in those frames is a *typewriter*: a constant
* 1.5467 frames per character with the final full stop held back five frames,
* each glyph landing at 1.178x and settling over five, arriving near-black with
* a violet cast (#292541) rather than at the caret's own light violet. That is
* documented because it was measured and is worth keeping, not because anything
* below consumes it — `text-reveal` assembles word by word instead, which is a
* different animation and a deliberate choice.
*/
// ── The slide ────────────────────────────────────────────────────────────────
/**
* The re-centring move is ONE continuous curve, and it starts before the cut.
*
* This is the correction that mattered most. The obvious reading is "the lead is
* cut, then what is left slides back to centre", which makes the motion start at
* the cut. It does not. The line is already moving five frames earlier, and one
* 16-frame cubic-bezier fits all 17 frames of it — through the cut — to a mean
* error of 0.067px, max 0.23px.
*
* Alternatives on the same data: exponential decay 8.76px, a Remotion spring
* with five free parameters 2.07px, degree-6 polynomial 1.60px, cubic 6.94px.
* The bezier is better by two orders of magnitude, so this is not a close call.
*
* The consequence for the implementation: **the cut is a visibility change, not
* a position change.** Residuals across it are +0.080 (before) and -0.108
* (after) — nothing jumps. Earlier versions modelled a "pre-cut drift" and a
* separate slide, which is the same curve described as two and fitted twice.
*/
export const SLIDE_FROM = 39;
export const SLIDE_TO = 55;
export const SLIDE_BEZIER = [0.3438, 0.0294, 0.032, 0.9269] as const;
/** The lead stops being drawn here. Position is unaffected — see `SLIDE_BEZIER`. */
export const CUT_AT = 44;
// ── The morph ────────────────────────────────────────────────────────────────
export const MORPH_FROM = 56;
export const MORPH_TO = 67;
// ── The exit ─────────────────────────────────────────────────────────────────
/**
* The exit drift: geometric per-frame velocity, i.e. an exponential ease-in.
*
* Starts at frame 72 — the frames before it are static to within 0.005px, and
* frame 73 is the first displaced one at 0.73px. Sub-pixel displacements are
* 0.73, 1.99, 3.70, 6.05, 9.76, 16.28, 30.03, 54.2; the integer bounding box
* reads those as 1,2,3,7,9,16,29,54 because motion blur erodes a thresholded
* edge, which is why an earlier hand-fit had both the start frame and the first
* step wrong.
*
* Fit: 0.36px mean error. Quadratic is 8.60px, cubic 3.42px, easeInQuart 1.72px.
*/
export const EXIT_FROM = 72;
export const EXIT_RATIO = 1.76456;
export const EXIT_COEFF = 1.3194;
/** The cut to the final phrase. */
export const CUT2_AT = 81;
// ── The ending ───────────────────────────────────────────────────────────────
/** Frames the final phrase is held when there is no flood. */
export const FINAL_HOLD = 12;
/** Each flood stage. Both are single-frame cuts to a flat colour, not ramps. */
export const FLOOD_STAGE_FRAMES = 4;
/**
* The reference's own palette. Component defaults come from the design system;
* these are here so the doc and the preview can quote what was measured.
*
* The page is #f7f7f7 for the whole non-flood run and never changes — the
* per-channel median is exactly 247 on 79 of 82 frames, reading 249 only where
* a grain overlay peaks.
*/
export const REFERENCE = {
page: "#f7f7f7",
ink: "#1f1f1f",
caret: "#b3a9f9",
floodA: "#8575fa",
floodB: "#4931f2",
} as const;
/** Total frames, end to end. */
export function totalFrames(hasFlood = false): number {
return CUT2_AT + 1 + (hasFlood ? FLOOD_STAGE_FRAMES * 2 : FINAL_HOLD);
}