{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "drop-line",
  "title": "Drop Line",
  "description": "Words cut past one card at a time, then the last word sinks out from under the dot of its own i",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/drop-line/index.tsx",
      "content": "// Drop Line · snapcn — https://snapcn.dev/docs/text/drop-line\n\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/Inter\";\nimport type { ReactNode } from \"react\";\nimport { useId } from \"react\";\nimport { AbsoluteFill, useCurrentFrame, useVideoConfig } from \"remotion\";\nimport { type SnapCnTheme, useSnapCnTheme } from \"@/lib/snap-cn-ui\";\nimport { BASELINE, DOT_LINE, DROP, FONT_SIZE, HOLDS, RISE } from \"./data\";\n\nconst { fontFamily: inter } = loadFont(\"normal\", { weights: [\"700\"] });\n\nconst STAGE_W = 1920;\nconst STAGE_H = 1080;\n/** Words may fill this much of the stage width before they scale down to fit. */\nconst MAX_WIDTH = STAGE_W * 0.9;\nconst DOTTED = /[ij]/;\n\n/**\n * Drop Line: words cut in place, the first rising into position, and on the last\n * card the word sinks out from under its own dot. The dot holds its position, so\n * the sentence ends on one mark left hanging. A last word with no \"i\" or \"j\" just\n * drops whole.\n */\nexport interface DropLineProps {\n  /** One word per card; the last one drops. */\n  words?: string[];\n  fontFamily?: string;\n  fontWeight?: number;\n  /** Type size on a 1920x1080 stage. */\n  fontSize?: number;\n  /** Size per word on that stage, for faces that set a card differently. */\n  wordSize?: number[];\n  /** Horizontal nudge per word, in stage px, for optical centring. */\n  wordNudge?: number[];\n  /** Tracking per word in em. */\n  wordTracking?: number[];\n  /** Per-letter offsets per word, in em. */\n  wordKerning?: number[][];\n  /** The card. Defaults to the theme foreground. */\n  ink?: string;\n  /** The type. Defaults to the theme background. */\n  paper?: string;\n  speed?: number;\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n}\n\n/** Sample a measured table at a fractional frame; holds both ends. */\nexport const sample = (table: readonly number[], at: number) => {\n  const x = Math.max(0, Math.min(at, table.length - 1));\n  const i = Math.floor(x);\n  const a = table[i] ?? 0;\n  return a + ((table[i + 1] ?? a) - a) * (x - i);\n};\n\n/** Frame each word cuts in, the frame the last card ends, and when its drop table starts. */\nexport const timeline = (count: number) => {\n  const starts = Array.from({ length: count }, (_, i) =>\n    i === 0 ? 0 : HOLDS.first + (i - 1) * HOLDS.word,\n  );\n  const end = HOLDS.first + Math.max(0, count - 2) * HOLDS.word + HOLDS.last;\n  return { starts, end, drop: end - DROP.length };\n};\n\n/** Which card is up at frame `f`. */\nexport const cardAt = (starts: readonly number[], f: number) =>\n  starts.reduce((at, s, i) => (f >= s ? i : at), 0);\n\nlet measureCtx: CanvasRenderingContext2D | null | undefined;\n\nconst fontMeasure = (font: string) => {\n  if (typeof document === \"undefined\") return null;\n  if (measureCtx === undefined) {\n    measureCtx = document.createElement(\"canvas\").getContext(\"2d\");\n  }\n  if (measureCtx) measureCtx.font = font;\n  return measureCtx ?? null;\n};\n\n/**\n * A line that can sit a fraction of a pixel. Chrome rounds a baseline to a whole\n * device pixel, which turns a slow drop into a staircase, so the type is painted\n * on the two rows around `y` and added by the fraction between them.\n */\nconst Line: React.FC<{\n  y: number;\n  base: number;\n  px: number;\n  children: ReactNode;\n}> = ({ y, base, px, children }) => {\n  const row = Math.floor(y * px);\n  const f = y * px - row;\n  return (\n    <g style={{ isolation: \"isolate\" }}>\n      {[\n        { r: row, w: 1 - f },\n        { r: row + 1, w: f },\n      ].map(({ r, w }) => (\n        <g\n          key={r}\n          transform={`translate(0 ${(r + 1e-3) / px - base})`}\n          style={{ opacity: w, mixBlendMode: \"plus-lighter\" }}\n        >\n          {children}\n        </g>\n      ))}\n    </g>\n  );\n};\n\nexport const DropLine: React.FC<DropLineProps> = ({\n  words = [\"then\", \"ships\", \"the\", \"film\"],\n  fontFamily = inter,\n  fontWeight = 700,\n  fontSize = FONT_SIZE,\n  wordSize,\n  wordNudge,\n  wordTracking,\n  wordKerning,\n  ink,\n  paper,\n  speed = 1,\n  theme,\n  mode,\n}) => {\n  const t = useSnapCnTheme(theme, mode);\n  const uid = useId().replace(/:/g, \"\");\n  const f = useCurrentFrame() * speed;\n  const { width, height } = useVideoConfig();\n  const px = Math.max(width / STAGE_W, height / STAGE_H);\n  const card = ink ?? t.foreground;\n  const type = paper ?? t.background;\n\n  const line = timeline(words.length);\n  const index = cardAt(line.starts, f);\n  const word = words[index] ?? \"\";\n  const isLast = index === words.length - 1;\n  const chars = Array.from(word);\n  const tracking = wordTracking?.[index] ?? -0.015;\n\n  const wanted = wordSize?.[index] ?? fontSize;\n  const ctx = fontMeasure(`${fontWeight} ${wanted}px ${fontFamily}`);\n  const measured =\n    (ctx ? ctx.measureText(word).width : chars.length * wanted * 0.6) +\n    (chars.length - 1) * tracking * wanted;\n  const fit = Math.min(1, MAX_WIDTH / Math.max(1, measured));\n  const size = wanted * fit;\n\n  // Keep the type centred on the stage as `fontSize` moves away from the measured one.\n  const rest = STAGE_H / 2 + (BASELINE - STAGE_H / 2) * (fontSize / FONT_SIZE);\n  const fall = isLast ? sample(DROP, f - line.drop) : 0;\n  const y = rest + (index === 0 ? sample(RISE, f) : 0);\n\n  // The cut between a dot and its stem: just above the x-height, on any face.\n  const dots = isLast && DOTTED.test(word);\n  const xHeight = ctx?.measureText(\"x\").actualBoundingBoxAscent;\n  const dotTop = ctx?.measureText(\"i\").actualBoundingBoxAscent;\n  const dotLine =\n    xHeight && dotTop\n      ? rest - (xHeight + (dotTop - xHeight) * 0.15) * fit\n      : rest - DOT_LINE * size;\n\n  const letters = (show: (ch: string) => boolean) => (\n    <text\n      x={STAGE_W / 2 + (wordNudge?.[index] ?? 0)}\n      y={rest}\n      textAnchor=\"middle\"\n      fill={type}\n      style={{\n        fontFamily,\n        fontWeight,\n        fontSize: size,\n        letterSpacing: `${tracking}em`,\n        textRendering: \"geometricPrecision\",\n      }}\n    >\n      {chars.map((ch, i) => (\n        <tspan\n          // biome-ignore lint/suspicious/noArrayIndexKey: letters of a fixed string\n          key={i}\n          dx={`${wordKerning?.[index]?.[i] ?? 0}em`}\n          fillOpacity={show(ch) ? 1 : 0}\n        >\n          {ch}\n        </tspan>\n      ))}\n    </text>\n  );\n\n  return (\n    <AbsoluteFill style={{ background: card, overflow: \"hidden\" }}>\n      <svg\n        viewBox={`0 0 ${STAGE_W} ${STAGE_H}`}\n        width=\"100%\"\n        height=\"100%\"\n        preserveAspectRatio=\"xMidYMid slice\"\n        style={{ position: \"absolute\", inset: 0 }}\n      >\n        <title>{words.join(\" \")}</title>\n        <defs>\n          <clipPath id={`${uid}a`}>\n            <rect width={STAGE_W} height={dotLine} />\n          </clipPath>\n          <clipPath id={`${uid}b`}>\n            <rect y={dotLine} width={STAGE_W} height={STAGE_H * 2} />\n          </clipPath>\n        </defs>\n        <Line y={y + fall} base={rest} px={px}>\n          {dots ? (\n            <>\n              {letters((ch) => !DOTTED.test(ch))}\n              <g clipPath={`url(#${uid}b)`}>\n                {letters((ch) => DOTTED.test(ch))}\n              </g>\n            </>\n          ) : (\n            letters(() => true)\n          )}\n        </Line>\n        {dots && (\n          <Line y={y} base={rest} px={px}>\n            <g clipPath={`url(#${uid}a)`}>{letters((ch) => DOTTED.test(ch))}</g>\n          </Line>\n        )}\n      </svg>\n    </AbsoluteFill>\n  );\n};\n\nexport default DropLine;\n",
      "type": "registry:component",
      "target": "components/snap-cn/drop-line/index.tsx"
    },
    {
      "path": "registry/snap-cn/drop-line/data.ts",
      "content": "// Drop Line · snapcn — https://snapcn.dev/docs/text/drop-line\n/**\n * Every number here was measured off a 1920x1080 reference, one sample per frame.\n * Sizes and offsets are px on that stage.\n */\n\nexport const FONT_SIZE = 130;\n/** Where the type sits at rest. */\nexport const BASELINE = 574.5;\n\n/** Frames each card holds: the first, every word between, the last. */\nexport const HOLDS = { first: 9, word: 7, last: 15 } as const;\n\n/** The first word lands low and rises into place: px below rest, per frame. */\nexport const RISE = [70, 43, 22.8, 13, 7.2, 3.7, 1.55, 0.45, 0] as const;\n\n/** The last card, frame by frame: how far the word has sunk. The dot stays put. */\nexport const DROP = [\n  0, 0, 0, 0, 0.38, 1.25, 2.54, 4.4, 6.91, 10.27, 14.57, 20.23, 27.57, 37.53,\n  51.55,\n] as const;\n\n/** Between the x-height and the dot, in em above the baseline, where no face is told. */\nexport const DOT_LINE = 0.585;\n",
      "type": "registry:component",
      "target": "components/snap-cn/drop-line/data.ts"
    }
  ],
  "docs": "Docs and props: https://snapcn.dev/docs/text/drop-line?ref=cli — 38 frames at 30fps. — 82 Pro components too — charts, launch scenes, device shots: https://snapcn.dev/docs/pricing?ref=cli-docs",
  "type": "registry:component"
}
