{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stretchy-footer",
  "title": "Stretchy Footer",
  "description": "Dia-style rubber overscroll: rainbow aurora stretches up past the bottom, then snaps back.",
  "files": [
    {
      "path": "registry/stretchy-footer/stretchy-footer.tsx",
      "content": "\"use client\"\n\nimport {\n  useEffect,\n  useRef,\n  useSyncExternalStore,\n  type ReactNode,\n  type RefObject,\n} from \"react\"\nimport { createPortal } from \"react-dom\"\nimport { cn } from \"@/lib/utils\"\n\n/** Gradient stops, top to bottom: magenta through sunlight to deep navy. */\nexport const DEFAULT_COLORS = [\n  \"#FF1AE6\",\n  \"#FF2E5E\",\n  \"#FF8A1A\",\n  \"#FFC81F\",\n  \"#E2E6EC\",\n  \"#8DB6E6\",\n  \"#2F74DC\",\n  \"#1459E3\",\n  \"#1C2566\",\n]\n\nexport const DEFAULT_MAX_STRETCH = 280\nexport const DEFAULT_COLUMNS = 9\n/** Gaussian blur on the aurora, in px. */\nexport const DEFAULT_BLUR = 16\n/** White floor bloom opacity (0–1). Off by default. */\nexport const DEFAULT_GLOW = 0\nexport const DEFAULT_STIFFNESS = 380\nexport const DEFAULT_DAMPING = 32\n\n/** Docs / demos: dispatch to play the rubber stretch without faking wheel input. */\nexport const STRETCHY_FOOTER_PLAY = \"stretchy-footer:play\"\n\n/** Stretch per px of overscroll at rest; it falls off toward `maxStretch`. */\nconst RUBBER = 0.9\n/** Wheel silence that counts as letting go, in ms. */\nconst RELEASE_MS = 110\n/** After letting go, wheel events closer together than this are momentum. */\nconst MOMENTUM_GAP_MS = 180\n/** Consecutive shrinking wheel deltas that read as a trackpad coasting. */\nconst COAST_EVENTS = 6\nconst MASS = 0.35\nconst REST = 0.15\n/** Edge columns stand this share of the centre one; the rest step evenly. */\nconst EDGE_COLUMN = 0.55\n/** The top of each column fades in over this share of its height. */\nconst TOP_FADE = 0.12\n/** Field opacity ramps in over this share of `maxStretch`. */\nconst FADE_IN = 0.08\n/** Physics substep, in seconds. */\nconst STEP = 1 / 120\n\nexport type StretchyFooterOptions = {\n  /** Peak stretch in px. Default `280`. */\n  maxStretch?: number\n  /**\n   * Gradient stops from the top of each column down to the floor, any CSS\n   * colors. Every column carries the whole gradient, squeezed to its height.\n   */\n  colors?: string[]\n  /** Spring stiffness. Default `380`. */\n  stiffness?: number\n  /** Spring damping. Default `32`. */\n  damping?: number\n  /** How many aurora columns, stepping down from the middle. Default `9`. */\n  columns?: number\n  /** Gaussian blur on the aurora, in px. Default `16`. */\n  blur?: number\n  /** White floor bloom opacity (0–1). Default `0`. */\n  glow?: number\n  /**\n   * Invert the pyramid: the edge columns stand tallest and the middle\n   * dips, so the wobble ripples in from the sides. Default `false`.\n   */\n  flip?: boolean\n  /**\n   * `180` turns the aurora upside down in place: the columns hang from the\n   * lifted page's edge and the gradient runs floor to top. Default `0`.\n   */\n  rotate?: 0 | 180\n  /**\n   * Optional id for docs demos. `playStretchyFooterDemo({ target })` only\n   * plays footers whose `demoId` matches.\n   */\n  demoId?: string\n}\n\nexport type StretchyFooterMount = {\n  /** Canvas the aurora paints into. Its parent sets the width. */\n  canvas: HTMLCanvasElement\n  /** Where overscroll is read: a scroll container, or `window`. */\n  scroller: HTMLElement | Window\n  /** Lifts with the stretch: an element, or a selector looked up when needed. */\n  content?: HTMLElement | string | null\n}\n\nexport type StretchyFooterPlayDetail = {\n  /** Peak pull as a fraction of `maxStretch`. Default `0.82`. */\n  amount?: number\n  /** How long to hold the stretch before snapping back, in ms. Default `700`. */\n  holdMs?: number\n  /** Only footers with this `demoId` respond. */\n  target?: string\n  /** Scroll this element (or the window) to the end before playing. */\n  scrollRoot?: HTMLElement | null\n}\n\nexport type StretchyFooterInstance = {\n  setOptions: (options: Partial<StretchyFooterOptions>) => void\n  /** Stretch and snap back, even under `prefers-reduced-motion`. */\n  play: (detail?: Pick<StretchyFooterPlayDetail, \"amount\" | \"holdMs\">) => void\n  destroy: () => void\n}\n\nexport async function scrollToEnd(root?: HTMLElement | null) {\n  const el = root ?? document.documentElement\n  const max = () => Math.max(0, el.scrollHeight - el.clientHeight)\n  const top = () => (root ? root.scrollTop : window.scrollY)\n  const scroll = (behavior: ScrollBehavior) =>\n    (root ?? window).scrollTo({ top: max(), behavior })\n  scroll(\"smooth\")\n  const started = performance.now()\n  while (performance.now() - started < 2000 && top() < max() - 2) {\n    await new Promise<void>((r) => window.setTimeout(r, 32))\n  }\n  scroll(\"instant\")\n}\n\n/** Scroll to the end, then play the stretch on matching footers. */\nexport async function playStretchyFooterDemo(\n  detail: StretchyFooterPlayDetail = {}\n) {\n  if (typeof window === \"undefined\") return\n  await scrollToEnd(detail.scrollRoot)\n  window.dispatchEvent(\n    new CustomEvent<StretchyFooterPlayDetail>(STRETCHY_FOOTER_PLAY, { detail })\n  )\n  // Travel out, hold, spring home.\n  await new Promise<void>((r) =>\n    window.setTimeout(r, (detail.holdMs ?? 700) + 900)\n  )\n}\n\n/**\n * Stepped pyramid: the middle column is tallest, each step out is shorter.\n * Flipped, the edges are tallest and each step in is shorter.\n */\nexport function columnScale(\n  index: number,\n  count: number,\n  flip = false\n): number {\n  if (count <= 1) return 1\n  const t = Math.abs((index / (count - 1)) * 2 - 1)\n  return 1 - (1 - EDGE_COLUMN) * (flip ? 1 - t : t)\n}\n\n/** Overscroll distance → stretch. Linear at first, never past `max`. */\nexport function rubberBand(distance: number, max: number): number {\n  if (max <= 0 || distance <= 0) return 0\n  return max * (1 - 1 / ((distance * RUBBER) / max + 1))\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, value))\n}\n\nfunction numberOr(value: unknown, fallback: number, min: number, max: number) {\n  if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n  return clamp(value, min, max)\n}\n\n/** Wheel delta in px, whatever unit the device reports in. */\nfunction wheelPixels(event: WheelEvent) {\n  if (event.deltaMode === 1) return event.deltaY * 16\n  if (event.deltaMode === 2) return event.deltaY * window.innerHeight\n  return event.deltaY\n}\n\n/**\n * True when something between `target` and the scroller can still scroll\n * in that direction: a code block or a menu gets the wheel, not us.\n */\nfunction nestedCanScroll(\n  target: EventTarget | null,\n  root: Element,\n  dy: number\n) {\n  let el = target instanceof Element ? target : null\n  while (el && el !== root && el !== document.documentElement) {\n    if (el.scrollHeight > el.clientHeight + 1) {\n      const overflow = getComputedStyle(el).overflowY\n      if (overflow === \"auto\" || overflow === \"scroll\") {\n        const room =\n          dy > 0\n            ? el.scrollHeight - el.clientHeight - el.scrollTop\n            : el.scrollTop\n        if (room > 1) return true\n      }\n    }\n    el = el.parentElement\n  }\n  return false\n}\n\nconst STRIP = 256\n\n/**\n * One column's gradient, `colors` spread from just under the top to the\n * floor, with the top fading in. Columns stretch it to their own height.\n */\nfunction makeStrip(colors: string[]) {\n  const strip = document.createElement(\"canvas\")\n  strip.width = 1\n  strip.height = STRIP\n  const g = strip.getContext(\"2d\")\n  if (!g) return strip\n  const bands = g.createLinearGradient(0, 0, 0, STRIP)\n  colors.forEach((color, i) => {\n    const at = colors.length > 1 ? i / (colors.length - 1) : 1\n    bands.addColorStop(TOP_FADE * 0.8 + (1 - TOP_FADE * 0.8) * at, color)\n  })\n  g.fillStyle = bands\n  g.fillRect(0, 0, 1, STRIP)\n  const fade = g.createLinearGradient(0, 0, 0, STRIP * TOP_FADE)\n  fade.addColorStop(0, \"rgba(0,0,0,0)\")\n  fade.addColorStop(1, \"rgba(0,0,0,1)\")\n  g.globalCompositeOperation = \"destination-in\"\n  g.fillStyle = fade\n  g.fillRect(0, 0, 1, STRIP)\n  return strip\n}\n\n/** Half a white radial glow, flat side down, for the floor bloom. */\nfunction makeBloom() {\n  const bloom = document.createElement(\"canvas\")\n  bloom.width = 128\n  bloom.height = 64\n  const g = bloom.getContext(\"2d\")\n  if (!g) return bloom\n  const grad = g.createRadialGradient(64, 64, 0, 64, 64, 64)\n  grad.addColorStop(0, \"rgba(255,255,255,1)\")\n  grad.addColorStop(1, \"rgba(255,255,255,0)\")\n  g.fillStyle = grad\n  g.fillRect(0, 0, 128, 64)\n  return bloom\n}\n\ntype Spring = { x: number; v: number; target: number }\n\nfunction stepSpring(s: Spring, k: number, c: number, dt: number) {\n  const accel = (-k * (s.x - s.target) - c * s.v) / MASS\n  s.v += accel * dt\n  s.x += s.v * dt\n}\n\nconst atRest = (s: Spring) =>\n  Math.abs(s.x - s.target) < REST && Math.abs(s.v) < REST\n\n/**\n * Dia-style rubber overscroll. Pull past the bottom of `scroller` and the\n * content lifts on a spring while a stepped pyramid of blurred gradient\n * columns stretches up from the floor (a valley with `flip`, hanging from\n * the page with `rotate: 180`); each column\n * has its own spring, so the edge wobbles as it snaps back. Idle unless\n * stretched.\n */\nexport function createStretchyFooter(\n  mount: StretchyFooterMount,\n  initial: StretchyFooterOptions = {}\n): StretchyFooterInstance | null {\n  const { canvas, scroller } = mount\n  const ctx = canvas.getContext(\"2d\")\n  if (!ctx) return null\n\n  let options: StretchyFooterOptions = { ...initial }\n  const maxOf = () => numberOr(options.maxStretch, DEFAULT_MAX_STRETCH, 0, 4000)\n  const blurOf = () => numberOr(options.blur, DEFAULT_BLUR, 0, 64)\n  const glowOf = () => numberOr(options.glow, DEFAULT_GLOW, 0, 1)\n  const stiffnessOf = () =>\n    numberOr(options.stiffness, DEFAULT_STIFFNESS, 10, 4000)\n  const dampingOf = () => numberOr(options.damping, DEFAULT_DAMPING, 0, 400)\n  const countOf = () =>\n    Math.round(numberOr(options.columns, DEFAULT_COLUMNS, 3, 96))\n  /** Overscroll the user has put in, in px. The stretch is its rubber band. */\n  let distance = 0\n  /** `play()` holds the stretch here, overriding the gesture. */\n  let forced: number | null = null\n  const lift: Spring = { x: 0, v: 0, target: 0 }\n  let columns: (Spring & { share: number })[] = []\n  let strip = document.createElement(\"canvas\")\n  const bloom = makeBloom()\n\n  const rebuildColumns = () => {\n    const count = countOf()\n    const target = lift.target\n    columns = Array.from({ length: count }, (_, i) => {\n      const share = columnScale(i, count, options.flip)\n      return { x: lift.x * share, v: 0, target: target * share, share }\n    })\n  }\n  const rebuildStrip = () => {\n    strip = makeStrip(options.colors?.length ? options.colors : DEFAULT_COLORS)\n  }\n\n  const size = { w: 0, pad: 0, scale: 1 }\n  const layout = () => {\n    const parent = canvas.parentElement\n    const blur = blurOf()\n    const max = maxOf()\n    // Room past the edges, so the blur doesn't fade the sides and floor.\n    const pad = Math.ceil(blur * 2)\n    // Blurred anyway, so half resolution is plenty and half the fill.\n    const scale = blur >= 6 ? 0.5 : Math.min(window.devicePixelRatio || 1, 2)\n    size.w = (parent?.clientWidth ?? 0) + pad * 2\n    size.pad = pad\n    size.scale = scale\n    canvas.width = Math.max(1, Math.ceil(size.w * scale))\n    canvas.height = Math.max(1, Math.ceil((max + pad) * scale))\n    Object.assign(canvas.style, {\n      position: \"absolute\",\n      left: `${-pad}px`,\n      bottom: `${-pad}px`,\n      width: `${size.w}px`,\n      height: `${max + pad}px`,\n      filter: blur > 0 ? `blur(${blur}px)` : \"\",\n      pointerEvents: \"none\",\n    })\n    draw()\n  }\n\n  // The page element that rides the stretch.\n  let lifted: HTMLElement | null = null\n  let liftedTransform = \"\"\n  const contentEl = () => {\n    const content = mount.content\n    if (!content) return null\n    if (typeof content !== \"string\") return content\n    if (lifted?.isConnected && lifted.matches(content)) return lifted\n    const scope = scroller instanceof HTMLElement ? scroller : document\n    return (\n      scope.querySelector<HTMLElement>(content) ??\n      document.querySelector<HTMLElement>(content)\n    )\n  }\n  const liftContent = (y: number) => {\n    const el = contentEl()\n    if (el !== lifted) {\n      if (lifted) lifted.style.transform = liftedTransform\n      lifted = el\n      liftedTransform = el?.style.transform ?? \"\"\n    }\n    if (!lifted) return\n    if (y > 0.2) {\n      lifted.style.transform = `translate3d(0, ${-y}px, 0)`\n      lifted.style.willChange = \"transform\"\n    } else {\n      lifted.style.transform = liftedTransform\n      lifted.style.willChange = \"\"\n    }\n  }\n\n  function draw() {\n    const stretch = Math.max(0, lift.x)\n    liftContent(stretch)\n    const max = maxOf()\n    const visible = stretch > 0.2 || columns.some((column) => column.x > 0.2)\n    canvas.style.visibility = visible ? \"visible\" : \"hidden\"\n    if (!visible) return\n\n    const { w, pad, scale } = size\n    const floor = max * scale\n    const bottom = canvas.height\n    ctx!.setTransform(1, 0, 0, 1, 0, 0)\n    ctx!.clearRect(0, 0, canvas.width, canvas.height)\n    ctx!.globalAlpha = clamp(stretch / Math.max(1, max * FADE_IN), 0, 1)\n    // Rotated, spin the drawing 180° about the gap between the lifted page\n    // and the floor, so the columns hang from the page's edge.\n    const rotated = options.rotate === 180\n    if (rotated) {\n      ctx!.setTransform(-1, 0, 0, -1, canvas.width, 2 * floor - stretch * scale)\n    }\n\n    // Columns span the visible width; the outer two run on into the pad.\n    const count = columns.length\n    const inner = (w - pad * 2) * scale\n    const edge = (i: number) =>\n      i <= 0\n        ? 0\n        : i >= count\n          ? canvas.width\n          : Math.round(pad * scale + (inner * i) / count)\n    for (let i = 0; i < count; i++) {\n      const h = Math.max(0, columns[i]!.x) * scale\n      if (h < 0.5) continue\n      const x0 = edge(i)\n      const x1 = edge(i + 1)\n      ctx!.drawImage(strip, x0, floor - h, x1 - x0, h)\n      // The run-off under the floor would land on the page once rotated.\n      if (rotated) continue\n      ctx!.drawImage(\n        strip,\n        0,\n        STRIP - 1,\n        1,\n        1,\n        x0,\n        floor,\n        x1 - x0,\n        bottom - floor\n      )\n    }\n\n    const glow = glowOf()\n    if (glow > 0) {\n      const rx = inner * 0.49\n      const ry = stretch * 0.35 * scale\n      ctx!.globalAlpha *= glow\n      ctx!.drawImage(bloom, w * scale * 0.5 - rx, floor - ry, rx * 2, ry)\n    }\n    ctx!.globalAlpha = 1\n    ctx!.setTransform(1, 0, 0, 1, 0, 0)\n  }\n\n  let raf = 0\n  let last = 0\n  const retarget = () => {\n    const max = maxOf()\n    const target = forced ?? rubberBand(distance, max)\n    lift.target = target\n    for (const column of columns) column.target = target * column.share\n    if (!raf) {\n      last = performance.now()\n      raf = requestAnimationFrame(frame)\n    }\n  }\n\n  function frame(now: number) {\n    raf = 0\n    const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n    last = now\n    const k = stiffnessOf()\n    const c = dampingOf()\n    const steps = Math.max(1, Math.ceil(dt / STEP))\n    for (let n = 0; n < steps; n++) {\n      stepSpring(lift, k, c, dt / steps)\n      for (const column of columns) {\n        // Short edge columns are softer and bouncier than the middle, so the\n        // top edge ripples out from the centre.\n        const ck = k * (0.6 + 0.4 * column.share)\n        stepSpring(column, ck, c * 0.5 * Math.sqrt(ck / k), dt / steps)\n      }\n    }\n    const settled = atRest(lift) && columns.every(atRest)\n    if (settled) {\n      lift.x = lift.target\n      lift.v = 0\n      for (const column of columns) {\n        column.x = column.target\n        column.v = 0\n      }\n    }\n    draw()\n    if (!settled) raf = requestAnimationFrame(frame)\n  }\n\n  // Gestures.\n  const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n  let reduce = mqReduce.matches\n  let releaseTimer = 0\n  let holdTimer = 0\n  let lastWheel = 0\n  let lastDelta = 0\n  let coasting = 0\n  let cooling = false\n  let lastTouchY = 0\n\n  const root =\n    scroller instanceof HTMLElement ? scroller : document.documentElement\n  const atBottom = () =>\n    root.scrollTop + root.clientHeight >= root.scrollHeight - 2\n\n  const release = () => {\n    window.clearTimeout(releaseTimer)\n    releaseTimer = 0\n    if (distance <= 0) return\n    distance = 0\n    cooling = true\n    retarget()\n  }\n  const stopPlay = () => {\n    if (forced === null) return\n    window.clearTimeout(holdTimer)\n    forced = null\n    retarget()\n  }\n\n  const onWheel = (event: WheelEvent) => {\n    if (reduce || event.ctrlKey) return\n    const dy = wheelPixels(event)\n    if (dy === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return\n    stopPlay()\n    const now = performance.now()\n    const gap = now - lastWheel\n    lastWheel = now\n\n    if (distance > 0) {\n      event.preventDefault()\n      // A trackpad coasting out: deltas shrink every event. Treat it as\n      // letting go instead of holding the stretch until momentum ends.\n      coasting = dy > 0 && Math.abs(dy) < Math.abs(lastDelta) ? coasting + 1 : 0\n      lastDelta = dy\n      if (coasting >= COAST_EVENTS) {\n        release()\n        return\n      }\n      distance = Math.max(0, distance + dy)\n      retarget()\n      window.clearTimeout(releaseTimer)\n      if (distance > 0) releaseTimer = window.setTimeout(release, RELEASE_MS)\n      return\n    }\n\n    // Momentum still arriving after a release shouldn't pull again.\n    if (cooling) {\n      if (gap < MOMENTUM_GAP_MS) return\n      cooling = false\n    }\n    if (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy)) {\n      event.preventDefault()\n      distance = dy\n      lastDelta = dy\n      coasting = 0\n      retarget()\n      releaseTimer = window.setTimeout(release, RELEASE_MS)\n    }\n  }\n\n  const onTouchStart = (event: TouchEvent) => {\n    const touch = event.touches[0]\n    if (!touch) return\n    stopPlay()\n    cooling = false\n    lastTouchY = touch.clientY\n  }\n  const onTouchMove = (event: TouchEvent) => {\n    const touch = event.touches[0]\n    if (!touch || reduce) return\n    const dy = lastTouchY - touch.clientY\n    lastTouchY = touch.clientY\n    if (\n      distance > 0 ||\n      (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy))\n    ) {\n      if (event.cancelable) event.preventDefault()\n      distance = Math.max(0, distance + dy)\n      retarget()\n    }\n  }\n  const onTouchEnd = () => release()\n\n  const onPlayEvent = (event: Event) => {\n    const detail = (event as CustomEvent<StretchyFooterPlayDetail>).detail ?? {}\n    // Scoped footers only answer their own demo; unscoped ones, broadcasts.\n    if ((detail.target ?? null) !== (options.demoId ?? null)) return\n    instance.play(detail)\n  }\n\n  const onReduce = () => {\n    reduce = mqReduce.matches\n    if (reduce) release()\n  }\n\n  const target: HTMLElement | Window = scroller\n  const prevOverscroll = root.style.overscrollBehaviorY\n  // The browser's own rubber band would fight ours.\n  root.style.overscrollBehaviorY = \"none\"\n  target.addEventListener(\"wheel\", onWheel as EventListener, { passive: false })\n  target.addEventListener(\"touchstart\", onTouchStart as EventListener, {\n    passive: true,\n  })\n  target.addEventListener(\"touchmove\", onTouchMove as EventListener, {\n    passive: false,\n  })\n  target.addEventListener(\"touchend\", onTouchEnd)\n  target.addEventListener(\"touchcancel\", onTouchEnd)\n  window.addEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent)\n  mqReduce.addEventListener(\"change\", onReduce)\n  const ro = new ResizeObserver(layout)\n  if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n  rebuildStrip()\n  rebuildColumns()\n  layout()\n\n  const instance: StretchyFooterInstance = {\n    setOptions(next) {\n      const prevColors = options.colors\n      const prevMax = maxOf()\n      const prevBlur = blurOf()\n      const prevFlip = Boolean(options.flip)\n      options = { ...options, ...next }\n      if (options.colors !== prevColors) rebuildStrip()\n      if (countOf() !== columns.length || Boolean(options.flip) !== prevFlip) {\n        rebuildColumns()\n      }\n      if (maxOf() !== prevMax || blurOf() !== prevBlur) layout()\n      if (forced !== null) forced = Math.min(forced, maxOf())\n      retarget()\n    },\n    play({ amount = 0.82, holdMs = 700 } = {}) {\n      window.clearTimeout(releaseTimer)\n      window.clearTimeout(holdTimer)\n      distance = 0\n      forced = maxOf() * clamp(amount, 0, 1)\n      retarget()\n      holdTimer = window.setTimeout(stopPlay, holdMs)\n    },\n    destroy() {\n      cancelAnimationFrame(raf)\n      raf = 0\n      window.clearTimeout(releaseTimer)\n      window.clearTimeout(holdTimer)\n      root.style.overscrollBehaviorY = prevOverscroll\n      target.removeEventListener(\"wheel\", onWheel as EventListener)\n      target.removeEventListener(\"touchstart\", onTouchStart as EventListener)\n      target.removeEventListener(\"touchmove\", onTouchMove as EventListener)\n      target.removeEventListener(\"touchend\", onTouchEnd)\n      target.removeEventListener(\"touchcancel\", onTouchEnd)\n      window.removeEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent)\n      mqReduce.removeEventListener(\"change\", onReduce)\n      ro.disconnect()\n      if (lifted) {\n        lifted.style.transform = liftedTransform\n        lifted.style.willChange = \"\"\n      }\n    },\n  }\n  return instance\n}\n\nexport type StretchyFooterProps = StretchyFooterOptions & {\n  className?: string\n  /**\n   * Page content inside the overflow scroller.\n   * Ignored when `scrollRef` / `windowScroll` paint overlay-only.\n   */\n  children?: ReactNode\n  /**\n   * External scroll container. When set, this component only paints the\n   * aurora overlay and binds overscroll to that element.\n   * When omitted (and `windowScroll` is false), this component *is* the scroller.\n   */\n  scrollRef?: RefObject<HTMLElement | null>\n  /**\n   * Bind overscroll to the window and paint a fixed bottom aurora.\n   * Use on full pages instead of a nested scroller.\n   */\n  windowScroll?: boolean\n  /**\n   * Element that lifts with the stretch (CSS selector).\n   * Used with `windowScroll` / `scrollRef`. Default `[data-stretchy-page]`.\n   */\n  contentSelector?: string\n  /** Accessible label for the scroll region. */\n  label?: string\n}\n\nconst noopSubscribe = () => () => {}\n\n/**\n * Dia-style stretchy overflow: overscroll past the bottom and a rainbow\n * aurora stretches up from the floor while the page lifts with it, then\n * springs back with a wobble. Honors `prefers-reduced-motion`.\n */\nexport function StretchyFooter({\n  className,\n  children,\n  scrollRef,\n  windowScroll = false,\n  contentSelector = \"[data-stretchy-page]\",\n  maxStretch = DEFAULT_MAX_STRETCH,\n  colors = DEFAULT_COLORS,\n  stiffness = DEFAULT_STIFFNESS,\n  damping = DEFAULT_DAMPING,\n  columns = DEFAULT_COLUMNS,\n  blur = DEFAULT_BLUR,\n  glow = DEFAULT_GLOW,\n  flip = false,\n  rotate = 0,\n  label = \"Stretchy overflow\",\n  demoId,\n}: StretchyFooterProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null)\n  const scrollerRef = useRef<HTMLDivElement>(null)\n  const contentRef = useRef<HTMLDivElement>(null)\n  const instanceRef = useRef<StretchyFooterInstance | null>(null)\n  // The window aurora portals to <body>, which only exists on the client.\n  const onClient = useSyncExternalStore(\n    noopSubscribe,\n    () => true,\n    () => false\n  )\n  const portalHost = windowScroll && onClient ? document.body : null\n\n  useEffect(() => {\n    const canvas = canvasRef.current\n    const scroller = windowScroll\n      ? window\n      : (scrollRef?.current ?? scrollerRef.current)\n    if (!canvas || !scroller) return\n    const content =\n      windowScroll || scrollRef ? contentSelector : contentRef.current\n    instanceRef.current = createStretchyFooter(\n      { canvas, scroller, content },\n      {\n        maxStretch,\n        colors,\n        stiffness,\n        damping,\n        columns,\n        blur,\n        glow,\n        flip,\n        rotate,\n        demoId,\n      }\n    )\n    return () => {\n      instanceRef.current?.destroy()\n      instanceRef.current = null\n    }\n    // Options stream in through setOptions; only the wiring remounts.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [windowScroll, scrollRef, contentSelector, portalHost])\n\n  useEffect(() => {\n    instanceRef.current?.setOptions({\n      maxStretch,\n      colors,\n      stiffness,\n      damping,\n      columns,\n      blur,\n      glow,\n      flip,\n      rotate,\n      demoId,\n    })\n  }, [\n    maxStretch,\n    colors,\n    stiffness,\n    damping,\n    columns,\n    blur,\n    glow,\n    flip,\n    rotate,\n    demoId,\n  ])\n\n  const aurora = (\n    <div\n      aria-hidden\n      className=\"pointer-events-none absolute inset-x-0 bottom-0 z-20 overflow-hidden\"\n      style={{ height: maxStretch }}\n    >\n      <canvas ref={canvasRef} className=\"invisible absolute\" />\n    </div>\n  )\n\n  if (windowScroll) {\n    if (!portalHost) return null\n    // Portal so `fixed` stays on the viewport; the lifted page is\n    // transformed and would otherwise pin it to the content.\n    return createPortal(\n      <div\n        data-slot=\"stretchy-footer\"\n        className={cn(\n          \"pointer-events-none fixed inset-x-0 bottom-0 z-50\",\n          className\n        )}\n        style={{ height: maxStretch }}\n      >\n        {aurora}\n      </div>,\n      portalHost\n    )\n  }\n\n  if (scrollRef) {\n    return (\n      <div\n        data-slot=\"stretchy-footer\"\n        className={cn(\n          \"pointer-events-none absolute inset-x-0 bottom-0\",\n          className\n        )}\n        style={{ height: maxStretch }}\n      >\n        {aurora}\n      </div>\n    )\n  }\n\n  // The aurora sits beside the scroller, not in it, so it stays on the\n  // viewport floor instead of scrolling away with the content.\n  return (\n    <div\n      data-slot=\"stretchy-footer\"\n      className={cn(\"relative isolate overflow-hidden\", className)}\n    >\n      <div\n        ref={scrollerRef}\n        role=\"region\"\n        aria-label={label}\n        className=\"h-full overflow-x-hidden overflow-y-auto\"\n      >\n        <div ref={contentRef} className=\"relative z-10 min-h-full\">\n          {children}\n        </div>\n      </div>\n      {aurora}\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/stretchy-footer.tsx"
    }
  ],
  "type": "registry:ui"
}