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