{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "indent-steps",
  "title": "Indent Steps",
  "description": "Three lines step in with growing indents and line numbers like code: 'Write it in React...'.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/indent-steps/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont as loadGeistMono } from \"@remotion/google-fonts/GeistMono\";\nimport { loadFont as loadHanken } from \"@remotion/google-fonts/HankenGrotesk\";\nimport { Fragment, useId } from \"react\";\nimport {\n  AbsoluteFill,\n  Easing,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\nconst hanken = loadHanken(\"normal\", {\n  weights: [\"500\"],\n  subsets: [\"latin\"],\n});\nconst mono = loadGeistMono(\"normal\", {\n  weights: [\"400\"],\n  subsets: [\"latin\"],\n});\n\nexport interface IndentStepsProps {\n  lines?: string[];\n  highlight?: string;\n  lineNumbers?: boolean;\n  exit?: boolean;\n  accent?: string;\n  background?: string;\n  speed?: number;\n  theme?: \"dark\" | \"light\";\n}\n\nconst FLUTED = [\"#1d2a8f\", \"#2f7bff\", \"#ff7a3d\", \"#ffe2c4\"] as const;\n\nconst PALETTES = {\n  dark: {\n    bg: \"#0A0A0B\",\n    fg: \"#F4F3F0\",\n    muted: \"#5E5D64\",\n    guide: \"rgba(244,243,240,0.2)\",\n    highlight: `linear-gradient(90deg, ${FLUTED[1]} 0%, #8f7cf0 30%, ${FLUTED[2]} 62%, ${FLUTED[3]} 100%)`,\n    ground: [\n      `radial-gradient(70% 80% at 104% 108%, color-mix(in oklab, ${FLUTED[0]} 50%, transparent) 0%, color-mix(in oklab, ${FLUTED[0]} 18%, transparent) 45%, transparent 75%)`,\n      `radial-gradient(36% 44% at 100% 104%, color-mix(in oklab, ${FLUTED[2]} 16%, transparent) 0%, transparent 80%)`,\n    ],\n    grain: 0.05,\n  },\n  light: {\n    bg: \"#F5F4F0\",\n    fg: \"#111113\",\n    muted: \"#A8A59E\",\n    guide: \"rgba(17,17,19,0.18)\",\n    highlight: `linear-gradient(90deg, ${FLUTED[0]} 0%, ${FLUTED[1]} 34%, #c05a8a 66%, ${FLUTED[2]} 100%)`,\n    ground: [\n      `radial-gradient(70% 80% at 104% 108%, color-mix(in oklab, ${FLUTED[1]} 14%, transparent) 0%, color-mix(in oklab, ${FLUTED[3]} 40%, transparent) 45%, transparent 75%)`,\n    ],\n    grain: 0.035,\n  },\n} as const;\n\nconst EASE_OUT = Easing.bezier(0.22, 1, 0.36, 1);\nconst EASE_SNAP = Easing.bezier(0.3, 1.25, 0.45, 1);\nconst EASE_IN = Easing.bezier(0.6, 0, 0.9, 0.5);\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nconst HANKEN_500 = [\n  260, 244, 347, 697, 560, 743, 691, 180, 240, 240, 398, 560, 240, 327, 240,\n  419, 560, 560, 560, 560, 560, 560, 560, 560, 560, 560, 240, 240, 560, 560,\n  560, 516, 874, 649, 604, 706, 679, 583, 563, 732, 683, 252, 553, 644, 498,\n  845, 680, 748, 557, 778, 603, 568, 586, 656, 661, 964, 651, 610, 590, 272,\n  454, 272, 577, 434, 299, 542, 583, 519, 583, 540, 339, 517, 553, 232, 232,\n  541, 269, 833, 553, 575, 583, 583, 413, 471, 353, 553, 526, 750, 527, 526,\n  472, 254, 262, 254, 560,\n];\nconst TRACK = -0.035;\nconst ASC = 1.0;\nconst DESC = 0.303;\nconst LINE = 1.16;\n\nfunction textEm(text: string) {\n  let w = 0;\n  for (const ch of text) {\n    const c = ch.charCodeAt(0);\n    w += (c >= 32 && c <= 126 ? HANKEN_500[c - 32] : 600) / 1000 + TRACK;\n  }\n  return w;\n}\n\nfunction Grain({ opacity }: { opacity: number }) {\n  const id = `g${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  return (\n    <svg\n      width=\"100%\"\n      height=\"100%\"\n      style={{ position: \"absolute\", inset: 0, opacity, pointerEvents: \"none\" }}\n      aria-hidden\n    >\n      <filter id={id}>\n        <feTurbulence\n          type=\"fractalNoise\"\n          baseFrequency=\"0.9\"\n          numOctaves={2}\n          seed={5}\n          stitchTiles=\"stitch\"\n        />\n        <feColorMatrix values=\"0 0 0 0 0.5  0 0 0 0 0.5  0 0 0 0 0.5  0 0 0 0.9 0\" />\n      </filter>\n      <rect width=\"100%\" height=\"100%\" filter={`url(#${id})`} />\n    </svg>\n  );\n}\n\ninterface Row {\n  text: string;\n  line: number;\n  sub: number;\n  hl: [number, number] | null;\n}\n\nfunction wrapRows(\n  lines: string[],\n  availEm: (line: number) => number,\n  highlight: string,\n) {\n  const rows: Row[] = [];\n  const needle = highlight.toLowerCase();\n  lines.forEach((text, line) => {\n    const words = text.split(/\\s+/).filter(Boolean);\n    const out: string[] = [];\n    let cur = \"\";\n    for (const w of words) {\n      const next = cur ? `${cur} ${w}` : w;\n      if (cur && textEm(next) > availEm(line)) {\n        out.push(cur);\n        cur = w;\n      } else cur = next;\n    }\n    if (cur || out.length === 0) out.push(cur);\n    out.forEach((t, sub) => {\n      const a = needle ? t.toLowerCase().indexOf(needle) : -1;\n      rows.push({\n        text: t,\n        line,\n        sub,\n        hl: a >= 0 ? [a, a + needle.length] : null,\n      });\n    });\n  });\n  return rows;\n}\n\nexport function IndentSteps({\n  lines = [\"Write it in React.\", \"Render it in Remotion.\", \"Ship it tonight.\"],\n  highlight = \"tonight\",\n  lineNumbers = true,\n  exit = false,\n  accent = \"#3B5BFD\",\n  background,\n  speed = 1,\n  theme = \"dark\",\n}: IndentStepsProps) {\n  const raw = useCurrentFrame();\n  const frame = raw * speed;\n  const { width, height, durationInFrames } = useVideoConfig();\n  const p = PALETTES[theme];\n  const logical = lines.slice(0, 5);\n  const n = Math.max(1, logical.length);\n\n  const portrait = height > width * 1.1;\n  const s = portrait\n    ? (width / 1280) * 1.55\n    : Math.min(width / 1280, height / 720);\n  const padX = width * (portrait ? 0.07 : 0.09);\n  const gutterEm = lineNumbers ? 0.95 : 0;\n  const maxW = width - padX * 2;\n  const maxH = height * (portrait ? 0.7 : 0.78);\n\n  const widths = logical.map(textEm);\n  const baseFs = (portrait ? 104 : 104) * s;\n  const tab = portrait ? 1.1 : 1.5;\n  const needEm = gutterEm + Math.max(...widths.map((w, i) => w + i * tab));\n  const fitNoWrap = maxW / needEm;\n  let fs: number;\n  let rows: Row[];\n  if (!portrait && fitNoWrap >= baseFs * 0.66) {\n    fs = Math.min(baseFs, fitNoWrap);\n    rows = wrapRows(logical, () => Number.POSITIVE_INFINITY, highlight);\n  } else {\n    fs = baseFs * (portrait ? 0.86 : 0.7);\n    rows = [];\n    for (let k = 0; k < 12; k++) {\n      const f = fs;\n      rows = wrapRows(logical, (i) => maxW / f - gutterEm - i * tab, highlight);\n      const longestWord = Math.max(\n        ...logical.flatMap((t, i) =>\n          t.split(/\\s+/).map((w) => textEm(w) + i * tab + gutterEm),\n        ),\n      );\n      if (rows.length * fs * LINE <= maxH && longestWord * fs <= maxW) break;\n      fs *= 0.92;\n    }\n  }\n  const lh = fs * LINE;\n  const blockW =\n    (gutterEm + Math.max(...rows.map((r) => textEm(r.text) + r.line * tab))) *\n    fs;\n  const x0 = (width - blockW) / 2 + gutterEm * fs;\n  const top = (height - rows.length * lh) / 2;\n  const baseline = ((LINE - (ASC + DESC)) / 2 + ASC) * fs;\n  const span = logical.map((_, i) => {\n    const first = rows.findIndex((r) => r.line === i);\n    return { first, count: rows.filter((r) => r.line === i).length };\n  });\n\n  const beat = n > 1 ? Math.min(20, 60 / (n - 1)) : 20;\n  const enter = (i: number) => 4 + i * beat;\n  const tabAt = (i: number) => enter(i) + Math.min(10, beat * 0.5);\n  const done = n > 1 ? tabAt(n - 1) + 8 : enter(0) + 16;\n  const EXIT = 18;\n  const exitStart = durationInFrames - EXIT;\n  const outdent = exit\n    ? interpolate(raw, [exitStart, exitStart + 8], [0, 1], {\n        ...CLAMP,\n        easing: EASE_OUT,\n      })\n    : 0;\n  const leave = (r: number) =>\n    exit\n      ? interpolate(\n          raw,\n          [exitStart + 6 + r * 2, exitStart + 15 + r * 2],\n          [0, 1],\n          {\n            ...CLAMP,\n            easing: EASE_IN,\n          },\n        )\n      : 0;\n  const lightT = interpolate(frame, [done - 4, done + 18], [0, 1], {\n    ...CLAMP,\n    easing: EASE_OUT,\n  });\n  const drift = interpolate(raw, [0, durationInFrames], [0, 1], CLAMP);\n\n  const indent = (i: number) => {\n    if (i === 0) return 0;\n    const snap = interpolate(frame, [tabAt(i), tabAt(i) + 8], [0, 1], {\n      ...CLAMP,\n      easing: EASE_SNAP,\n    });\n    return (i - 1 + snap) * (1 - outdent);\n  };\n  const rise = (row: Row) =>\n    interpolate(\n      frame,\n      [enter(row.line) + row.sub * 2, enter(row.line) + row.sub * 2 + 16],\n      [0, 1],\n      {\n        ...CLAMP,\n        easing: EASE_OUT,\n      },\n    );\n\n  const transparent = background === \"transparent\";\n  const guideW = Math.max(1, 1.5 * s);\n\n  return (\n    <AbsoluteFill\n      style={{\n        background: transparent ? \"transparent\" : (background ?? p.bg),\n        overflow: \"hidden\",\n      }}\n    >\n      {!transparent && background === undefined ? (\n        <>\n          <div\n            style={{\n              position: \"absolute\",\n              inset: 0,\n              background: p.ground.join(\", \"),\n              transform: `translate(${(-drift * 2).toFixed(3)}%, ${(-drift * 1.5).toFixed(3)}%) scale(1.06)`,\n            }}\n          />\n          <Grain opacity={p.grain} />\n        </>\n      ) : null}\n\n      {logical.map((_, i) => {\n        const { first, count } = span[i];\n        if (first < 0) return null;\n        const pos = indent(i);\n        const r = rise(rows[first]) * (1 - leave(first));\n        if (r <= 0) return null;\n        const stops = Math.min(i, Math.ceil(pos - 0.001));\n        return Array.from({ length: stops }, (_, k) => {\n          const grow = Math.min(1, pos - k);\n          const heat =\n            k === i - 1\n              ? interpolate(frame, [tabAt(i) + 2, tabAt(i) + 20], [1, 0], CLAMP)\n              : 0;\n          return (\n            <div\n              key={`${i}-${k}`}\n              style={{\n                position: \"absolute\",\n                left: x0 + k * tab * fs + fs * 0.03,\n                top: top + first * lh - lh * 0.04,\n                width: guideW,\n                height: count * lh * grow,\n                background: heat > 0.01 ? accent : p.guide,\n                opacity: (heat > 0.01 ? 0.35 + 0.65 * heat : 1) * r,\n              }}\n            />\n          );\n        });\n      })}\n\n      {lineNumbers\n        ? logical.map((_, i) => {\n            const { first } = span[i];\n            if (first < 0) return null;\n            const r = rise(rows[first]) * (1 - leave(first));\n            if (r <= 0) return null;\n            const nfs = Math.max(12, fs * 0.22);\n            return (\n              <div\n                key={`n${i}`}\n                style={{\n                  position: \"absolute\",\n                  left: x0 - gutterEm * fs,\n                  width: gutterEm * fs * 0.62,\n                  top: top + first * lh + baseline - nfs * 0.98,\n                  textAlign: \"right\",\n                  fontFamily: mono.fontFamily,\n                  fontWeight: 400,\n                  fontSize: nfs,\n                  lineHeight: 1,\n                  color: p.muted,\n                  fontVariantNumeric: \"tabular-nums\",\n                  opacity: r,\n                }}\n              >\n                {i + 1}\n              </div>\n            );\n          })\n        : null}\n\n      {rows.map((row, ri) => {\n        const r = rise(row);\n        if (r <= 0) return null;\n        const out = leave(ri);\n        const pos = indent(row.line);\n        const parts: [string, boolean][] = row.hl\n          ? [\n              [row.text.slice(0, row.hl[0]), false],\n              [row.text.slice(row.hl[0], row.hl[1]), true],\n              [row.text.slice(row.hl[1]), false],\n            ]\n          : [[row.text, false]];\n        return (\n          <div\n            key={`l${ri}`}\n            style={{\n              position: \"absolute\",\n              left: x0 + pos * tab * fs,\n              top: top + ri * lh,\n              height: lh,\n              overflow: \"hidden\",\n              paddingRight: \"0.1em\",\n              fontFamily: hanken.fontFamily,\n              fontWeight: 500,\n              fontSize: fs,\n              lineHeight: `${lh}px`,\n              letterSpacing: `${TRACK}em`,\n              whiteSpace: \"nowrap\",\n              color: p.fg,\n            }}\n          >\n            <div\n              style={{\n                transform: `translateY(${((1 - r) * 100 - out * 100).toFixed(2)}%)`,\n              }}\n            >\n              {parts.map(([text, hl], k) =>\n                hl ? (\n                  <span key={k} style={{ position: \"relative\" }}>\n                    <span style={{ opacity: 1 - lightT }}>{text}</span>\n                    <span\n                      style={{\n                        position: \"absolute\",\n                        left: 0,\n                        top: 0,\n                        opacity: lightT,\n                        backgroundImage: p.highlight,\n                        backgroundSize: \"160% 100%\",\n                        backgroundPosition: `${(drift * 60).toFixed(2)}% 0%`,\n                        WebkitBackgroundClip: \"text\",\n                        backgroundClip: \"text\",\n                        color: \"transparent\",\n                        whiteSpace: \"nowrap\",\n                      }}\n                    >\n                      {text}\n                    </span>\n                  </span>\n                ) : (\n                  <Fragment key={k}>{text}</Fragment>\n                ),\n              )}\n            </div>\n          </div>\n        );\n      })}\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/indent-steps.tsx"
    }
  ],
  "type": "registry:component"
}
