{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "echo-stack",
  "title": "Echo Stack",
  "description": "A word focuses in at the center while outline copies of it unfold above and below and keep drifting, like the one clear line on a spinning drum.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/echo-stack/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/Inter\";\nimport { useId } from \"react\";\nimport { Easing, interpolate, useCurrentFrame, useVideoConfig } from \"remotion\";\n\nconst { fontFamily: INTER } = loadFont(\"normal\", {\n  weights: [\"100\", \"900\"],\n  subsets: [\"latin\"],\n});\n\nexport type EchoStackDirection = \"up\" | \"down\";\n\nexport interface EchoStackProps {\n  text?: string;\n  fontSize?: number;\n  fontWeight?: number;\n  fontFamily?: string;\n  color?: string;\n  echoColor?: string;\n  echoes?: number;\n  echoOpacity?: number;\n  strokeWidth?: number;\n  direction?: EchoStackDirection;\n  cycle?: number;\n  speed?: number;\n  className?: string;\n}\n\nexport const echoStackDefaults = {\n  text: \"Bold.\",\n  fontSize: 170,\n  fontWeight: 800,\n  fontFamily: INTER,\n  color: \"#fafafa\",\n  echoColor: \"\",\n  echoes: 3,\n  echoOpacity: 0.35,\n  strokeWidth: 1.5,\n  direction: \"up\",\n  cycle: 60,\n  speed: 1,\n} satisfies Required<Omit<EchoStackProps, \"className\">>;\n\n/** Frame where the entrance has finished; the drift keeps running after it. */\nexport const echoStackLength = 26;\n\n/** Distance between echo rows, in em. */\nexport const echoStackPitch = 1.05;\n\n/** Echoes closer to the center than this, in pitches, hide behind the word. */\nconst HIDDEN = 0.55;\nconst MAIN = [0, 14] as const;\nconst UNFOLD = [8, 26] as const;\nconst FADE_IN = [8, 24] as const;\nconst START_SPREAD = 0.6;\nconst MAX_BLUR = 12;\n\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nconst easeOutCubic = Easing.out(Easing.cubic);\n\nfunction finite(value: number | undefined, fallback: number) {\n  return typeof value === \"number\" && Number.isFinite(value) ? value : fallback;\n}\n\nfunction smoothstep(x: number) {\n  const c = Math.min(1, Math.max(0, x));\n  return c * c * (3 - 2 * c);\n}\n\n/** Maps a composition frame onto the 30 fps timeline, scaled by `speed`. */\nexport function getEchoStackTime(\n  frame: number,\n  options: { fps?: number; speed?: number } = {},\n) {\n  const fps = Math.max(1, finite(options.fps, 30));\n  const speed = Math.max(0, finite(options.speed, 1));\n  return (Math.max(0, finite(frame, 0)) * 30 * speed) / fps;\n}\n\n/** The main word focusing in. Blur is in reference px. */\nexport function getEchoStackMain(t: number) {\n  const k = interpolate(t, [...MAIN], [0, 1], {\n    ...CLAMP,\n    easing: easeOutCubic,\n  });\n  return { blur: MAX_BLUR * (1 - k), opacity: k, scale: 1.04 - 0.04 * k };\n}\n\n/** Echo visibility at a distance from the center, in pitches, before `echoOpacity`. */\nexport function getEchoStackFade(distance: number, echoes: number) {\n  const d = Math.abs(distance);\n  if (d < HIDDEN) return 0;\n  if (d < 1) return smoothstep((d - HIDDEN) / (1 - HIDDEN));\n  const reach = Math.max(1, echoes + 0.5);\n  return Math.max(0, 1 - (d - 1) / (reach - 1));\n}\n\nexport interface EchoStackRow {\n  /** Signed distance from the center in pitches, negative above. */\n  offset: number;\n  /** Row position in pitches after the unfold, negative above. */\n  position: number;\n  opacity: number;\n}\n\nexport function getEchoStackRows(\n  t: number,\n  options: {\n    echoes?: number;\n    echoOpacity?: number;\n    cycle?: number;\n    direction?: EchoStackDirection;\n  } = {},\n): EchoStackRow[] {\n  const echoes = Math.max(0, Math.round(finite(options.echoes, 3)));\n  const echoOpacity = Math.max(0, finite(options.echoOpacity, 0.35));\n  const cycle = Math.max(1, finite(options.cycle, 60));\n  const sign = options.direction === \"down\" ? 1 : -1;\n  const phase = sign * ((t / cycle) % 1);\n  const spread = interpolate(t, [...UNFOLD], [START_SPREAD, 1], {\n    ...CLAMP,\n    easing: easeOutCubic,\n  });\n  const fadeIn = interpolate(t, [...FADE_IN], [0, 1], {\n    ...CLAMP,\n    easing: easeOutCubic,\n  });\n  const rows: EchoStackRow[] = [];\n  const reach = echoes + 2;\n  for (let k = -reach; k <= reach; k++) {\n    const offset = k + phase;\n    const opacity = echoOpacity * fadeIn * getEchoStackFade(offset, echoes);\n    if (opacity <= 0) continue;\n    rows.push({ offset, position: offset * spread, opacity });\n  }\n  return rows;\n}\n\nexport function EchoStack({\n  text = echoStackDefaults.text,\n  fontSize = echoStackDefaults.fontSize,\n  fontWeight = echoStackDefaults.fontWeight,\n  fontFamily = echoStackDefaults.fontFamily,\n  color = echoStackDefaults.color,\n  echoColor = echoStackDefaults.echoColor,\n  echoes = echoStackDefaults.echoes,\n  echoOpacity = echoStackDefaults.echoOpacity,\n  strokeWidth = echoStackDefaults.strokeWidth,\n  direction = echoStackDefaults.direction,\n  cycle = echoStackDefaults.cycle,\n  speed = echoStackDefaults.speed,\n  className,\n}: EchoStackProps) {\n  const frame = useCurrentFrame();\n  const { fps, height } = useVideoConfig();\n  const unit = height / 720;\n  const size = fontSize * unit;\n  const t = getEchoStackTime(frame, { fps, speed });\n  const main = getEchoStackMain(t);\n  const rows = getEchoStackRows(t, { echoes, echoOpacity, cycle, direction });\n  const pitch = echoStackPitch * size;\n  const outlineId = `echo-stack-${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n\n  const type = {\n    fontFamily,\n    fontSize: size,\n    lineHeight: 1,\n    fontWeight,\n    fontVariationSettings: `\"wght\" ${fontWeight}`,\n    whiteSpace: \"pre\",\n  } as const;\n\n  return (\n    <div\n      className={className}\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        display: \"flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        overflow: \"hidden\",\n      }}\n    >\n      {/* Outline from the filled silhouette: the variable font's overlapping\n          contours would show through a text stroke. */}\n      <svg width={0} height={0} style={{ position: \"absolute\" }} aria-hidden>\n        <defs>\n          <filter id={outlineId}>\n            <feMorphology\n              in=\"SourceAlpha\"\n              operator=\"erode\"\n              radius={strokeWidth * unit}\n              result=\"inner\"\n            />\n            <feComposite\n              in=\"SourceAlpha\"\n              in2=\"inner\"\n              operator=\"out\"\n              result=\"ring\"\n            />\n            <feFlood floodColor={echoColor || color} />\n            <feComposite in2=\"ring\" operator=\"in\" />\n          </filter>\n        </defs>\n      </svg>\n      <div style={{ position: \"relative\" }}>\n        {rows.map((row, i) => (\n          <div\n            key={i}\n            aria-hidden\n            style={{\n              ...type,\n              position: \"absolute\",\n              left: \"50%\",\n              top: \"50%\",\n              color: echoColor || color,\n              filter: `url(#${outlineId})`,\n              opacity: row.opacity,\n              transform: `translate(-50%, calc(-50% + ${row.position * pitch}px))`,\n            }}\n          >\n            {text}\n          </div>\n        ))}\n        <div\n          style={{\n            ...type,\n            position: \"relative\",\n            color,\n            opacity: main.opacity,\n            filter:\n              main.blur > 0.05 ? `blur(${main.blur * unit}px)` : undefined,\n            transform: `scale(${main.scale})`,\n          }}\n        >\n          {text}\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/echo-stack.tsx"
    }
  ],
  "type": "registry:component"
}
