{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-footer",
  "type": "registry:ui",
  "dependencies": [
    "gsap",
    "next-themes"
  ],
  "files": [
    {
      "path": "components/ui/animated-footer.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useEffect, useMemo, useRef } from \"react\";\nimport gsap from \"gsap\";\nimport { useTheme } from \"next-themes\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Animated Footer\n *\n * A cinematic, reveal-on-scroll footer: two source images are re-drawn as\n * live ASCII art on <canvas>, light up in little clusters around the cursor,\n * and drift with a soft parallax. When the footer scrolls into view the\n * display headings unmask character-by-character, the links and copy slide\n * up behind masks, and the ASCII \"hands\" glide in from the edges.\n *\n * Ported from the vanilla \"LukeBaffait Animated Footer\" (GSAP + canvas) into a\n * single, self-contained, prop-driven React component. No global smooth-scroll\n * or SplitText plugin required — the reveal is driven by an IntersectionObserver\n * and text is split in JSX.\n */\n\nexport interface AnimatedFooterLink {\n  label: string;\n  href: string;\n}\n\nexport interface AnimatedFooterProps {\n  /** The large display words along the bottom edge. Defaults to [\"VengeanceUI\"]. */\n  headingLines?: string[];\n  /** Left image URL, sampled into ASCII art. Must be same-origin or CORS-enabled. */\n  leftImage?: string;\n  /** Right image URL, sampled into ASCII art. Must be same-origin or CORS-enabled. */\n  rightImage?: string;\n\n  /** Footer background color. Defaults to \"#0f0f0f\". */\n  background?: string;\n  /** Text color for links, copy and headings. Defaults to \"#ffffff\". */\n  textColor?: string;\n\n  /** Character ramp, ordered dark → light, used to render the ASCII art. */\n  asciiChars?: string;\n  /** Color of the ASCII glyphs. Defaults to \"#803500\". */\n  charColor?: string;\n  /** Fill color of a highlighted (hovered) cell. Defaults to \"#ff6a00\". */\n  hoverColor?: string;\n  /** Glyph color inside a highlighted cell. Defaults to \"#0f0f0f\". */\n  hoverCharColor?: string;\n  /** Number of columns each image is sampled to. Defaults to 80. */\n  columns?: number;\n  /** Pixel size of each ASCII cell. Defaults to 20. */\n  cellSize?: number;\n  /** Font size (px) of the ASCII glyphs. Defaults to 18. */\n  fontSize?: number;\n\n  /** Pointer parallax strength in px; set to 0 to disable. Defaults to 20. */\n  parallaxStrength?: number;\n  /** Cursor influence radius, in cells, for the hover highlight. Defaults to 8. */\n  hoverRadius?: number;\n\n  /** Play the reveal when the footer scrolls into view (else it shows immediately). Defaults to true. */\n  revealOnScroll?: boolean;\n  /**\n   * Controlled reveal. When set, the footer ignores its own scroll observer and\n   * plays in (`true`) / out (`false`) to match this value — drive it from your\n   * own ScrollTrigger, sentinel or state to reveal it from behind other content.\n   */\n  revealed?: boolean;\n\n  /** Extra class names for the root element. */\n  className?: string;\n}\n\nconst DEFAULT_ASCII_CHARS = \"........:::=+xX#0369\";\n\nconst HIGHLIGHT_LIFETIME = 300; // ms a hovered cell stays lit\nconst CLUSTER_SIZE = 10; // max cells a hover ripple spreads across\nconst PARALLAX_EASE = 0.05;\n\ninterface Cell {\n  col: number;\n  row: number;\n  char: string;\n  highlightEndTime: number;\n}\n\ninterface Hand {\n  canvas: HTMLCanvasElement;\n  ctx: CanvasRenderingContext2D;\n  cells: Map<string, Cell>;\n  cellList: Cell[];\n  rows: number;\n  columns: number;\n  cellSize: number;\n  baselineOffset: number;\n  direction: 1 | -1; // slide-in direction for the reveal curtain\n}\n\n/** Build the ASCII cell grid for one image by sampling its brightness. */\nfunction buildHandCells(\n  image: HTMLImageElement,\n  columns: number,\n  asciiChars: string,\n): { rows: number; cells: Map<string, Cell> } {\n  const rows = Math.max(\n    1,\n    Math.round(columns / (image.naturalWidth / image.naturalHeight || 1)),\n  );\n\n  const sampler = document.createElement(\"canvas\");\n  sampler.width = columns;\n  sampler.height = rows;\n  const sampleCtx = sampler.getContext(\"2d\");\n  const cells = new Map<string, Cell>();\n  if (!sampleCtx) return { rows, cells };\n\n  sampleCtx.drawImage(image, 0, 0, columns, rows);\n  const pixels = sampleCtx.getImageData(0, 0, columns, rows).data;\n  const backgroundCharIndex = asciiChars.lastIndexOf(\".\");\n\n  for (let row = 0; row < rows; row++) {\n    for (let col = 0; col < columns; col++) {\n      const offset = (row * columns + col) * 4;\n      const brightness =\n        (pixels[offset] * 0.299 +\n          pixels[offset + 1] * 0.587 +\n          pixels[offset + 2] * 0.114) /\n        255;\n      const charIndex = Math.min(\n        asciiChars.length - 1,\n        Math.floor((1 - brightness) * asciiChars.length),\n      );\n      if (charIndex <= backgroundCharIndex) continue;\n\n      cells.set(`${col},${row}`, {\n        col,\n        row,\n        char: asciiChars[charIndex],\n        highlightEndTime: 0,\n      });\n    }\n  }\n\n  return { rows, cells };\n}\n\n/** Light up a wandering cluster of cells starting from `startCell`. */\nfunction highlightCluster(cells: Map<string, Cell>, startCell: Cell) {\n  const now = Date.now();\n  startCell.highlightEndTime = now + HIGHLIGHT_LIFETIME;\n\n  const steps = Math.floor(Math.random() * CLUSTER_SIZE) + 1;\n  const litCells = [startCell];\n  let current = startCell;\n\n  for (let step = 0; step < steps; step++) {\n    const neighbours: Cell[] = [];\n    for (let dy = -1; dy <= 1; dy++) {\n      for (let dx = -1; dx <= 1; dx++) {\n        if (dx === 0 && dy === 0) continue;\n        const neighbour = cells.get(`${current.col + dx},${current.row + dy}`);\n        if (neighbour && !litCells.includes(neighbour)) neighbours.push(neighbour);\n      }\n    }\n    if (neighbours.length === 0) break;\n\n    const next = neighbours[Math.floor(Math.random() * neighbours.length)];\n    next.highlightEndTime = now + HIGHLIGHT_LIFETIME + step * 10;\n    litCells.push(next);\n    current = next;\n  }\n}\n\n/** Nearest scrollable ancestor — used as the reveal's IntersectionObserver root. */\nfunction getScrollParent(node: HTMLElement | null): HTMLElement | null {\n  let el = node?.parentElement ?? null;\n  while (el) {\n    const overflowY = getComputedStyle(el).overflowY;\n    if (overflowY === \"auto\" || overflowY === \"scroll\" || overflowY === \"overlay\") return el;\n    el = el.parentElement;\n  }\n  return null;\n}\n\nexport function AnimatedFooter({\n  headingLines = [\"VengeanceUI\"],\n  leftImage = \"/animated-footer/hand-left.jpg\",\n  rightImage = \"/animated-footer/hand-right.jpg\",\n  background,\n  textColor,\n  charColor,\n  hoverColor,\n  hoverCharColor,\n  asciiChars = DEFAULT_ASCII_CHARS,\n  columns = 80,\n  cellSize = 20,\n  fontSize = 18,\n  parallaxStrength = 20,\n  hoverRadius = 8,\n  revealOnScroll = true,\n  revealed,\n  className,\n}: AnimatedFooterProps) {\n  const rootRef = useRef<HTMLElement>(null);\n  const leftWrapRef = useRef<HTMLDivElement>(null);\n  const rightWrapRef = useRef<HTMLDivElement>(null);\n  const leftCanvasRef = useRef<HTMLCanvasElement>(null);\n  const rightCanvasRef = useRef<HTMLCanvasElement>(null);\n\n  // Reveal animations, published by the main effect so the controlled-`revealed`\n  // effect below can play them without rebuilding the ASCII scene.\n  const animateInRef = useRef<() => void>(() => {});\n  const animateOutRef = useRef<() => void>(() => {});\n\n  const { resolvedTheme } = useTheme();\n  const isDark = resolvedTheme === \"dark\";\n\n  const cc = charColor ?? (isDark ? \"#803500\" : \"#e6b093\");\n  const hc = hoverColor ?? \"#ff6a00\";\n  const hcc = hoverCharColor ?? (isDark ? \"#0f0f0f\" : \"#ffffff\");\n\n  // Live-tunable values read inside the animation loop, so tweaking a color or\n  // the parallax strength never tears down and rebuilds the ASCII scene.\n  const liveRef = useRef({ charColor: cc, hoverColor: hc, hoverCharColor: hcc, parallaxStrength, hoverRadius });\n  useEffect(() => {\n    liveRef.current = { charColor: cc, hoverColor: hc, hoverCharColor: hcc, parallaxStrength, hoverRadius };\n  }, [cc, hc, hcc, parallaxStrength, hoverRadius]);\n\n  // A signature of the structural inputs — the scene rebuilds only when one of\n  // these changes (images, grid resolution, content, reveal mode).\n  const sig = useMemo(\n    () =>\n      JSON.stringify({\n        leftImage,\n        rightImage,\n        columns,\n        cellSize,\n        fontSize,\n        asciiChars,\n        revealOnScroll,\n        headingLines,\n      }),\n    [leftImage, rightImage, columns, cellSize, fontSize, asciiChars, revealOnScroll, headingLines],\n  );\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const leftWrap = leftWrapRef.current;\n    const rightWrap = rightWrapRef.current;\n    if (!root || !leftWrap || !rightWrap) return;\n\n    const hands: Hand[] = [];\n    const wrappers = [leftWrap, rightWrap];\n\n    // ── ASCII hands ──────────────────────────────────────────────────────\n    const setupHand = (\n      image: HTMLImageElement,\n      canvas: HTMLCanvasElement,\n      direction: 1 | -1,\n    ) => {\n      const { rows, cells } = buildHandCells(image, columns, asciiChars);\n      if (cells.size === 0) return;\n\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = columns * cellSize * dpr;\n      canvas.height = rows * cellSize * dpr;\n\n      const ctx = canvas.getContext(\"2d\");\n      if (!ctx) return;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.font = `${fontSize}px monospace`;\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"alphabetic\";\n\n      const metrics = ctx.measureText(\"X\");\n      const glyphHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;\n      const baselineOffset = cellSize / 2 + glyphHeight / 2 - metrics.actualBoundingBoxDescent;\n\n      hands.push({\n        canvas,\n        ctx,\n        cells,\n        cellList: [...cells.values()],\n        rows,\n        columns,\n        cellSize,\n        baselineOffset,\n        direction,\n      });\n    };\n\n    const loadHand = (src: string, canvas: HTMLCanvasElement, direction: 1 | -1) => {\n      if (!src) return;\n      const image = new Image();\n      image.crossOrigin = \"anonymous\";\n      let initialized = false;\n      const init = () => {\n        if (initialized) return;\n        initialized = true;\n        setupHand(image, canvas, direction);\n      };\n      image.onload = init;\n      image.src = src;\n      if (image.complete && image.naturalWidth) init();\n    };\n    loadHand(leftImage, leftCanvasRef.current!, 1);\n    loadHand(rightImage, rightCanvasRef.current!, -1);\n\n    const renderHand = (hand: Hand, now: number) => {\n      const { ctx, cellList, cellSize: cs, baselineOffset, columns: cols, rows } = hand;\n      const { charColor: cc, hoverColor: hc, hoverCharColor: hcc } = liveRef.current;\n      ctx.clearRect(0, 0, cols * cs, rows * cs);\n\n      for (const cell of cellList) {\n        const x = cell.col * cs;\n        const y = cell.row * cs;\n        const isHighlighted = cell.highlightEndTime > now;\n\n        if (isHighlighted) {\n          ctx.fillStyle = hc;\n          ctx.fillRect(x, y, cs, cs);\n        }\n        ctx.fillStyle = isHighlighted ? hcc : cc;\n        ctx.fillText(cell.char, x + cs / 2, y + baselineOffset);\n      }\n    };\n\n    // ── Pointer: hover highlight + parallax target ───────────────────────\n    const pointer = { x: 0, y: 0 };\n    const drift = { x: 0, y: 0 };\n    // Reveal \"curtain\": hands start pushed off the edges and slide to 0.\n    const curtain = { offset: revealOnScroll ? 125 : 0 };\n\n    const hoverHand = (hand: Hand, clientX: number, clientY: number) => {\n      const rect = hand.canvas.getBoundingClientRect();\n      if (rect.width === 0 || rect.height === 0) return;\n      const mouseCol = ((clientX - rect.left) / rect.width) * hand.columns;\n      const mouseRow = ((clientY - rect.top) / rect.height) * hand.rows;\n\n      let closest: Cell | null = null;\n      let closestDist = Infinity;\n      for (const cell of hand.cellList) {\n        const dx = mouseCol - cell.col;\n        const dy = mouseRow - cell.row;\n        const dist = Math.sqrt(dx * dx + dy * dy);\n        if (dist < closestDist) {\n          closestDist = dist;\n          closest = cell;\n        }\n      }\n      if (closest && closestDist <= liveRef.current.hoverRadius) {\n        highlightCluster(hand.cells, closest);\n      }\n    };\n\n    const onMouseMove = (event: MouseEvent) => {\n      const strength = liveRef.current.parallaxStrength;\n      const rect = root.getBoundingClientRect();\n      const w = rect.width || 1;\n      const h = rect.height || 1;\n      pointer.x = ((event.clientX - rect.left) / w - 0.5) * strength * 2;\n      pointer.y = ((event.clientY - rect.top) / h - 0.5) * strength * 2;\n      for (const hand of hands) hoverHand(hand, event.clientX, event.clientY);\n    };\n    window.addEventListener(\"mousemove\", onMouseMove);\n\n    // ── Unified render loop: ASCII + parallax + reveal curtain ───────────\n    let rafId = 0;\n    const frame = () => {\n      const now = Date.now();\n      for (const hand of hands) renderHand(hand, now);\n\n      drift.x += (pointer.x - drift.x) * PARALLAX_EASE;\n      drift.y += (pointer.y - drift.y) * PARALLAX_EASE;\n      const strength = liveRef.current.parallaxStrength;\n      const scale = 1 + (strength * 2) / 200;\n\n      wrappers.forEach((wrapper, i) => {\n        const dir = i === 0 ? 1 : -1;\n        const revealX = i === 0 ? -curtain.offset : curtain.offset;\n        const x = drift.x * dir || 0;\n        const y = -drift.y || 0;\n        // Apply reveal via translateX, then apply parallax via translate, avoiding calc() mixed-unit bugs\n        wrapper.style.transform = `translateX(${revealX}%) translate(${x}px, ${y}px) scale(${scale})`;\n      });\n\n      rafId = requestAnimationFrame(frame);\n    };\n    rafId = requestAnimationFrame(frame);\n\n    // ── Reveal (chars + curtain) ─────────────────────────\n    const chars = gsap.utils.toArray<HTMLElement>(root.querySelectorAll(\"[data-af-char]\"));\n\n    const animateIn = () => {\n      gsap.to(curtain, { offset: 0, duration: 1, ease: \"power3.out\", overwrite: true });\n      gsap.to(chars, {\n        yPercent: 0,\n        duration: 1,\n        ease: \"power3.out\",\n        stagger: { each: 0.04, from: \"center\" },\n        overwrite: true,\n      });\n    };\n\n    const animateOut = () => {\n      gsap.to(curtain, { offset: 125, duration: 0.4, ease: \"power2.in\", overwrite: true });\n      gsap.to(chars, {\n        yPercent: 125,\n        duration: 0.4,\n        ease: \"power2.in\",\n        stagger: { each: 0.01, from: \"center\" },\n        overwrite: true,\n      });\n    };\n\n    // Publish for the controlled-`revealed` effect.\n    animateInRef.current = animateIn;\n    animateOutRef.current = animateOut;\n\n    const maskAll = () => {\n      gsap.set(chars, { yPercent: 125 });\n    };\n    const showAll = () => {\n      gsap.set(chars, { yPercent: 0 });\n    };\n\n    let observer: IntersectionObserver | null = null;\n\n    if (revealed !== undefined) {\n      // Controlled: the `revealed` effect below drives the reveal. Set the\n      // initial state to match, and never attach the scroll observer.\n      curtain.offset = revealed ? 0 : 125;\n      if (revealed) showAll();\n      else maskAll();\n    } else if (revealOnScroll) {\n      // Start fully masked — nothing shows until the footer is scrolled into view.\n      maskAll();\n\n      // Drive the reveal purely from scroll position, relative to the nearest\n      // scrollable ancestor (the page in real use, or the preview's scroll\n      // container in the docs). Plays in when the footer crosses into view and\n      // reverses when you scroll back up.\n      let isRevealed = false;\n      observer = new IntersectionObserver(\n        (entries) => {\n          for (const entry of entries) {\n            if (entry.isIntersecting && !isRevealed) {\n              isRevealed = true;\n              animateIn();\n            } else if (!entry.isIntersecting && isRevealed) {\n              isRevealed = false;\n              animateOut();\n            }\n          }\n        },\n        { root: getScrollParent(root), threshold: 0.35 },\n      );\n      observer.observe(root);\n    } else {\n      showAll();\n    }\n\n    // ── Cleanup ──────────────────────────────────────────────────────────\n    return () => {\n      cancelAnimationFrame(rafId);\n      window.removeEventListener(\"mousemove\", onMouseMove);\n      observer?.disconnect();\n      gsap.killTweensOf([curtain, ...chars]);\n    };\n    // Rebuild only when a structural input changes; live values flow via liveRef.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [sig]);\n\n  // Controlled reveal: play in/out to match the `revealed` prop without\n  // rebuilding the scene. Ignored entirely when `revealed` is undefined.\n  useEffect(() => {\n    if (revealed === undefined) return;\n    if (revealed) animateInRef.current();\n    else animateOutRef.current();\n  }, [revealed]);\n\n  // Whether the content starts masked on first paint (avoids a flash before the\n  // effect runs): hidden unless it's meant to be shown immediately.\n  const startsHidden = revealed !== undefined ? !revealed : revealOnScroll;\n  const offEdge = startsHidden ? 125 : 0;\n\n  return (\n    <footer\n      ref={rootRef}\n      className={cn(\n        \"relative h-full w-full overflow-hidden\",\n        !background && \"bg-white dark:bg-black\",\n        !textColor && \"text-black dark:text-white\",\n        className\n      )}\n      style={{ backgroundColor: background, color: textColor, containerType: \"inline-size\" }}\n    >\n      {/* ASCII hands */}\n      <div className=\"pointer-events-none absolute inset-0 flex items-center justify-between\">\n        <div\n          ref={leftWrapRef}\n          className=\"relative w-2/5 min-w-[200px] will-change-transform\"\n          style={{ transform: `translateX(-${offEdge}%)` }}\n        >\n          <canvas ref={leftCanvasRef} className=\"block h-auto w-full\" />\n        </div>\n        <div\n          ref={rightWrapRef}\n          className=\"relative w-2/5 min-w-[200px] will-change-transform\"\n          style={{ transform: `translateX(${offEdge}%)` }}\n        >\n          <canvas ref={rightCanvasRef} className=\"block h-auto w-full\" />\n        </div>\n      </div>\n\n      {/* Display headings */}\n      <div className=\"absolute inset-x-0 bottom-0 flex items-end justify-center gap-4 p-8\">\n        {headingLines.map((word, wi) => (\n          <h2\n            key={`${word}-${wi}`}\n            aria-label={word}\n            className=\"overflow-hidden font-medium leading-none tracking-tight pb-[0.15em] -mb-[0.15em]\"\n            style={{ fontSize: \"clamp(2rem, 13cqw, 11rem)\" }}\n          >\n            {Array.from(word).map((ch, ci) => (\n              <span\n                key={ci}\n                data-af-char\n                aria-hidden=\"true\"\n                className=\"inline-block\"\n              >\n                {ch === \" \" ? \" \" : ch}\n              </span>\n            ))}\n          </h2>\n        ))}\n      </div>\n    </footer>\n  );\n}\n\nexport default AnimatedFooter;\n",
      "type": "registry:ui",
      "target": "components/ui/animated-footer.tsx"
    }
  ]
}
