diff --git a/README.md b/README.md
index 0302b3d..a94910b 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,7 @@ pnpm dlx shadcn@latest add radiumcoders/23rd.dev/gooey-color-picker
| [ASCII Fluid](https://23rd.dev/docs/components/ascii-fluid) | `@23rd/ascii-fluid` | Mouse-trail WebGL fluid quantized to a clean ASCII brightness ramp |
| [ASCII Logo](https://23rd.dev/docs/components/ascii-logo) | `@23rd/ascii-logo` | Interactive ASCII wordmark — hover shove, then click to scatter, drop, and gather |
| [Gooey Color Picker](https://23rd.dev/docs/components/gooey-color-picker) | `@23rd/gooey-color-picker` | Floating swatch → hue wheel, alpha, hex — joined by an SVG gooey filter |
+| [Logo Burst](https://23rd.dev/docs/components/logo-burst) | `@23rd/logo-burst` | Hair-line tentacles explode from the center, then breathe — light and dark |
| [Radiant Lines](https://23rd.dev/docs/components/radiant-lines) | `@23rd/radiant-lines` | Hyperspace starfield background; warp speed driven by scroll |
| [Shader Fire](https://23rd.dev/docs/components/shader-fire) | `@23rd/shader-fire` | Sparse 2D fire wash — tongues rise from the bottom behind a landing hero |
| [Shader Gradient](https://23rd.dev/docs/components/shader-gradient) | `@23rd/shader-gradient` | Quiet WebGL wash behind landing heroes, empty states, and marketing sections |
diff --git a/content/docs/components/logo-burst.mdx b/content/docs/components/logo-burst.mdx
new file mode 100644
index 0000000..451134b
--- /dev/null
+++ b/content/docs/components/logo-burst.mdx
@@ -0,0 +1,137 @@
+---
+title: Logo Burst
+description: Hair-line tentacles explode from the center, then keep a slow inhale. Follows light and dark.
+---
+
+import { LogoBurstDemo } from "@/registry/logo-burst/logo-burst-demo"
+
+A brand-hero burst. Thin filaments detonate out of the center, tip particles lock in, then the field breathes. Click the field — or bump `replayKey` — to explode again. Default `theme="auto"` follows the site. Press `d` to toggle.
+
+
+
+## Installation
+
+
+
+Or install straight from the public GitHub source registry:
+
+
+
+## Usage
+
+Give the parent a size. The canvas is transparent over `bg-background`.
+
+
+
+```tsx
+"use client"
+
+import { LogoBurst } from "@/components/ui/logo-burst"
+
+export function HeroBurst() {
+ return (
+
+
+
+ )
+}
+```
+
+
+```svelte
+
+
+
+
+
+```
+
+
+
+Pass children only if you want a mark in the hole. `coreSize` is then measured from that mark unless you lock it.
+
+
+
+ ```tsx
+
+
+
+ ```
+
+
+ ```svelte
+
+
+
+ ```
+
+
+
+Replay from outside by incrementing `replayKey`.
+
+
+
+```tsx
+"use client"
+
+import { useState } from "react"
+import { LogoBurst } from "@/components/ui/logo-burst"
+
+export function Replayable() {
+ const [key, setKey] = useState(0)
+
+return (
+
+
+
+
+
+)
+}
+
+````
+
+
+```svelte
+
+
+
+
+
+
+````
+
+
+
+
+`prefers-reduced-motion: reduce` draws the settled field and skips the explosion and the inhale.
+
+## Props
+
+| Prop | Type | Default |
+| --------------- | ----------------------------- | -------------------------------------------------------------- |
+| `children` | `ReactNode` / `Snippet` | none |
+| `tentacleCount` | `number` | `260` |
+| `color` | `string` | theme ink / bone |
+| `theme` | `"light" \| "dark" \| "auto"` | `"auto"` |
+| `breathe` | `boolean` | `true` |
+| `coreSize` | `number` | `0` — lines meet at the origin; measured when children are set |
+| `radius` | `number` | `0.48` — outer reach vs the shorter edge |
+| `duration` | `number` | `1.35` |
+| `seed` | `number` | `23` |
+| `particleRatio` | `number` | `0.62` |
+| `replayKey` | `number` | `0` |
+| `replayOnClick` | `boolean` | `true` |
+| `label` | `string` | `"Logo burst"` |
+| `className` | `string` | React only |
+| `class` | `string` | Svelte only |
+
+`color` overrides the theme filament. Omit it and `theme="auto"` picks ink on light, bone on dark.
diff --git a/content/docs/components/meta.json b/content/docs/components/meta.json
index 22328dc..8cb6412 100644
--- a/content/docs/components/meta.json
+++ b/content/docs/components/meta.json
@@ -2,6 +2,7 @@
"title": "Components",
"pages": [
"---Background---",
+ "logo-burst",
"radiant-lines",
"ascii-fluid",
"---Shaders---",
diff --git a/content/docs/getting-started.mdx b/content/docs/getting-started.mdx
index 93abade..b71a7e1 100644
--- a/content/docs/getting-started.mdx
+++ b/content/docs/getting-started.mdx
@@ -13,7 +13,7 @@ Or pull straight from the public GitHub source registry:
-Swap `shader-gradient` for any item name — `shader-fire`, `live-orb`, `ascii-logo`, `radiant-lines`, `dithered-404`, `tangle-footer`, and so on. Svelte installs as `@23rd/-svelte` and lands in `src/lib/components/ui/` so `$lib` imports resolve.
+Swap `shader-gradient` for any item name — `shader-fire`, `live-orb`, `ascii-logo`, `logo-burst`, `radiant-lines`, `dithered-404`, `tangle-footer`, and so on. Svelte installs as `@23rd/-svelte` and lands in `src/lib/components/ui/` so `$lib` imports resolve.
## Use a background or shader
@@ -54,11 +54,11 @@ Shaders such as [Shader Gradient](/docs/components/shader-gradient) and [Shader
## Browse
-| Category | Components |
-| ---------- | ---------------------------------------------------------------------------------------------------- |
-| Background | [Radiant Lines](/docs/components/radiant-lines), [ASCII Fluid](/docs/components/ascii-fluid) |
-| Shaders | [Shader Gradient](/docs/components/shader-gradient), [Shader Fire](/docs/components/shader-fire) |
-| Footers | [Tangle Footer](/docs/components/tangle-footer), [Stretchy Footer](/docs/components/stretchy-footer) |
-| Characters | [Live Orb](/docs/components/live-orb), [ASCII Logo](/docs/components/ascii-logo) |
-| Pages | [Dithered 404](/docs/components/dithered-404) |
-| Components | [Gooey Color Picker](/docs/components/gooey-color-picker) |
+| Category | Components |
+| ---------- | --------------------------------------------------------------------------------------------------------------------------------------- |
+| Background | [Logo Burst](/docs/components/logo-burst), [Radiant Lines](/docs/components/radiant-lines), [ASCII Fluid](/docs/components/ascii-fluid) |
+| Shaders | [Shader Gradient](/docs/components/shader-gradient), [Shader Fire](/docs/components/shader-fire) |
+| Footers | [Tangle Footer](/docs/components/tangle-footer), [Stretchy Footer](/docs/components/stretchy-footer) |
+| Characters | [Live Orb](/docs/components/live-orb), [ASCII Logo](/docs/components/ascii-logo) |
+| Pages | [Dithered 404](/docs/components/dithered-404) |
+| Components | [Gooey Color Picker](/docs/components/gooey-color-picker) |
diff --git a/content/docs/index.mdx b/content/docs/index.mdx
index 978518a..a697362 100644
--- a/content/docs/index.mdx
+++ b/content/docs/index.mdx
@@ -21,5 +21,6 @@ Most component kits hand you every option. 23rd picks a direction: spacing, radi
- [ASCII Logo](/docs/components/ascii-logo) — interactive ASCII wordmark that scatters and falls
- [Shader Gradient](/docs/components/shader-gradient) — quiet wash behind a landing hero
- [Shader Fire](/docs/components/shader-fire) — sparse fire tongues under a landing hero
+- [Logo Burst](/docs/components/logo-burst) — hair-line tentacles explode from the center, then breathe
- [Radiant Lines](/docs/components/radiant-lines) — hyperspace starfield background
- [Dithered 404](/docs/components/dithered-404) — Bayer-pixel 404 that burns under a fireball cursor
diff --git a/public/r/live-orb-svelte.json b/public/r/live-orb-svelte.json
index fecbaba..2c7956d 100644
--- a/public/r/live-orb-svelte.json
+++ b/public/r/live-orb-svelte.json
@@ -2,11 +2,11 @@
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "live-orb-svelte",
"title": "Live Orb",
- "description": "A matte 3D sphere with two capsule eyes that follow the pointer — white, black, webgl, and custom variants.",
+ "description": "An evenly lit sphere with two capsule eyes that follow the pointer — white, black, webgl, and custom variants.",
"files": [
{
"path": "registry/live-orb/live-orb.svelte",
- "content": "\n\n\n\n
\n{/if}\n",
"type": "registry:file",
"target": "src/lib/components/ui/stretchy-footer.svelte"
}
diff --git a/public/r/stretchy-footer.json b/public/r/stretchy-footer.json
index 10010fb..bc00a2a 100644
--- a/public/r/stretchy-footer.json
+++ b/public/r/stretchy-footer.json
@@ -9,7 +9,7 @@
"files": [
{
"path": "registry/stretchy-footer/stretchy-footer.tsx",
- "content": "\"use client\"\n\nimport {\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n type RefObject,\n} from \"react\"\nimport { createPortal } from \"react-dom\"\nimport {\n motion,\n useMotionValue,\n useMotionValueEvent,\n useReducedMotion,\n useSpring,\n useTransform,\n} from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\n\nexport const DEFAULT_COLORS = [\n \"#FF3B30\",\n \"#FF9500\",\n \"#FFCC00\",\n \"#34C759\",\n \"#00C7BE\",\n \"#32ADE6\",\n \"#007AFF\",\n \"#5856D6\",\n \"#AF52DE\",\n \"#FF2D55\",\n]\n\nexport const DEFAULT_COLUMNS = 48\nexport const WHEEL_IDLE_MS = 90\nexport const PULL_GAIN = 0.7\nexport const POP_BOOST = 1.45\n\n/** Docs / demos — dispatch to play the rubber stretch without faking wheel input. */\nexport const STRETCHY_FOOTER_PLAY = \"stretchy-footer:play\"\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 async function scrollToEnd(root?: HTMLElement | null) {\n if (root) {\n const max = Math.max(0, root.scrollHeight - root.clientHeight)\n root.scrollTo({ top: max, behavior: \"smooth\" })\n const started = performance.now()\n while (performance.now() - started < 2000) {\n if (root.scrollTop >= max - 2) break\n await new Promise((r) => window.setTimeout(r, 32))\n }\n root.scrollTo({ top: max })\n return\n }\n\n const max = Math.max(\n 0,\n document.documentElement.scrollHeight - window.innerHeight\n )\n window.scrollTo({ top: max, behavior: \"smooth\" })\n const started = performance.now()\n while (performance.now() - started < 2000) {\n const top = window.scrollY || document.documentElement.scrollTop\n if (top >= max - 2) break\n await new Promise((r) => window.setTimeout(r, 32))\n }\n window.scrollTo({ top: max })\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\n await scrollToEnd(detail.scrollRoot)\n\n window.dispatchEvent(\n new CustomEvent(STRETCHY_FOOTER_PLAY, {\n detail,\n })\n )\n\n const hold = detail.holdMs ?? 700\n // Travel out + hold + spring home\n await new Promise((r) => window.setTimeout(r, hold + 900))\n}\n\nexport function columnScale(index: number, count: number): number {\n if (count <= 1) return 1\n const t = (index / (count - 1)) * 2 - 1\n return Math.exp(-t * t * 2.2)\n}\n\n/** Stable rgba() for SSR — avoids hex/rgb serialization mismatches. */\nexport function hexToRgba(hex: string, alpha: number): string {\n const raw = hex.replace(\"#\", \"\")\n const full =\n raw.length === 3\n ? raw\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : raw.slice(0, 6)\n const r = Number.parseInt(full.slice(0, 2), 16)\n const g = Number.parseInt(full.slice(2, 4), 16)\n const b = Number.parseInt(full.slice(4, 6), 16)\n return `rgba(${r}, ${g}, ${b}, ${alpha})`\n}\n\nexport function applyResistance(current: number, delta: number, max: number): number {\n if (max <= 0) return 0\n const t = Math.min(1, Math.max(0, current / max))\n const falloff = (1 - t) * (1 - t * 0.35)\n const pop = current < 40 ? POP_BOOST : 1\n return Math.min(max, Math.max(0, current + delta * PULL_GAIN * falloff * pop))\n}\n\nexport function elementAtBottom(el: HTMLElement, slack = 1): boolean {\n return el.scrollTop + el.clientHeight >= el.scrollHeight - slack\n}\n\nexport function windowAtBottom(slack = 1): boolean {\n const doc = document.documentElement\n return window.scrollY + window.innerHeight >= doc.scrollHeight - slack\n}\n\nexport type StretchyFooterProps = {\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 listeners to that element.\n * When omitted (and `windowScroll` is false), this component *is* the scroller.\n */\n scrollRef?: RefObject\n /**\n * Bind overscroll to the window and paint a fixed bottom aurora.\n * Use on full pages (e.g. the component docs) 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 /** Peak stretch in px. Default `280`. */\n maxStretch?: number\n /** Spectrum stops across the aurora columns. */\n colors?: string[]\n /** Spring stiffness. Default `380`. */\n stiffness?: number\n /** Spring damping. Default `32`. */\n damping?: number\n /** How many vertical aurora bars. Default `48`. */\n columns?: number\n /** Accessible label for the decorative field. */\n label?: string\n /**\n * Optional id for docs demos. `playStretchyFooterDemo({ target })` only\n * animates footers whose `demoId` matches.\n */\n demoId?: string\n}\n\ntype OverscrollTarget =\n | { kind: \"element\"; el: HTMLElement }\n | { kind: \"window\" }\n\n/**\n * Dia-style stretchy overflow — overscroll past the bottom and a rainbow\n * field scaleY-stretches from the floor while the page lifts with it.\n * GPU transforms only (no height animation) for a clean, snappy rubber band.\n */\nexport function StretchyFooter({\n className,\n children,\n scrollRef,\n windowScroll = false,\n contentSelector = \"[data-stretchy-page]\",\n maxStretch = 280,\n colors = DEFAULT_COLORS,\n stiffness = 380,\n damping = 32,\n columns = DEFAULT_COLUMNS,\n label = \"Stretchy overflow\",\n demoId,\n}: StretchyFooterProps) {\n const reduceMotion = useReducedMotion() ?? false\n const internalRef = useRef(null)\n const pull = useMotionValue(0)\n const stretch = useSpring(pull, {\n stiffness,\n damping,\n mass: 0.35,\n restDelta: 0.2,\n restSpeed: 0.2,\n })\n const touchStartY = useRef(0)\n const touchStretch0 = useRef(0)\n const wheelTimer = useRef | null>(null)\n const demoTimer = useRef | null>(null)\n const demoPlayingRef = useRef(false)\n const rolledEl = useRef(null)\n const [mounted, setMounted] = useState(false)\n /** User-triggered demo — plays even when prefers-reduced-motion is on. */\n const [demoPlaying, setDemoPlaying] = useState(false)\n demoPlayingRef.current = demoPlaying\n // Gate on `mounted` so prefers-reduced-motion can't mismatch SSR HTML.\n const reduce = mounted && reduceMotion && !demoPlaying\n\n useEffect(() => {\n setMounted(true)\n }, [])\n\n // 0 → 1 progress drives scaleY (compositor-friendly).\n const progress = useTransform(stretch, (v) =>\n maxStretch <= 0 ? 0 : Math.min(1, Math.max(0, v / maxStretch))\n )\n const pageY = useTransform(stretch, (v) => -v)\n const fieldOpacity = useTransform(progress, [0, 0.08, 0.2], [0, 1, 1])\n\n const bars = useMemo(() => {\n const count = Math.max(8, Math.min(96, Math.floor(columns)))\n return Array.from({ length: count }, (_, i) => {\n const color = colors[i % colors.length]!\n const heightPct = Math.max(28, columnScale(i, count) * 100).toFixed(2)\n return {\n key: i,\n height: `${heightPct}%`,\n backgroundImage: `linear-gradient(to top, ${hexToRgba(color, 1)} 0%, ${hexToRgba(color, 0.6)} 45%, rgba(0, 0, 0, 0) 100%)`,\n }\n })\n }, [colors, columns])\n\n // Lift the page in lockstep with the stretch — translate only, no 3D.\n useMotionValueEvent(stretch, \"change\", (v) => {\n if (reduceMotion && !demoPlayingRef.current) return\n if (!windowScroll && !scrollRef) return\n\n if (!rolledEl.current?.isConnected) {\n rolledEl.current = document.querySelector(\n contentSelector\n ) as HTMLElement | null\n }\n const el = rolledEl.current\n if (!el) return\n\n const y = -Math.max(0, v)\n el.style.transformOrigin = \"50% 100%\"\n el.style.willChange = \"transform\"\n el.style.transform = `translate3d(0, ${y}px, 0)`\n\n if (v < 0.2) {\n el.style.transform = \"translate3d(0, 0, 0)\"\n el.style.willChange = \"\"\n }\n })\n\n useEffect(() => {\n return () => {\n const el = rolledEl.current\n if (!el) return\n el.style.transform = \"\"\n el.style.transformOrigin = \"\"\n el.style.willChange = \"\"\n }\n }, [])\n\n // Always listen for docs \"Show effect\" — drives pull directly (no fake wheels).\n useEffect(() => {\n const clearDemoTimer = () => {\n if (demoTimer.current) {\n clearTimeout(demoTimer.current)\n demoTimer.current = null\n }\n }\n\n const onPlay = (event: Event) => {\n const detail =\n (event as CustomEvent).detail ?? {}\n if (detail.target != null && detail.target !== demoId) return\n // Scoped footers ignore broadcast plays without a target.\n if (detail.target == null && demoId != null) return\n\n const amount = detail.amount ?? 0.82\n const holdMs = detail.holdMs ?? 700\n\n if (wheelTimer.current) {\n clearTimeout(wheelTimer.current)\n wheelTimer.current = null\n }\n clearDemoTimer()\n demoPlayingRef.current = true\n setDemoPlaying(true)\n pull.set(Math.min(maxStretch, Math.max(0, maxStretch * amount)))\n demoTimer.current = setTimeout(() => {\n demoTimer.current = null\n pull.set(0)\n // Wait for the spring to settle before re-honoring reduced motion.\n window.setTimeout(() => {\n demoPlayingRef.current = false\n setDemoPlaying(false)\n }, 500)\n }, holdMs)\n }\n\n window.addEventListener(STRETCHY_FOOTER_PLAY, onPlay)\n return () => {\n clearDemoTimer()\n window.removeEventListener(STRETCHY_FOOTER_PLAY, onPlay)\n }\n }, [demoId, maxStretch, pull])\n\n useEffect(() => {\n if (reduceMotion) return\n\n let target: OverscrollTarget | null = null\n if (windowScroll) {\n target = { kind: \"window\" }\n } else {\n const el = scrollRef?.current ?? internalRef.current\n if (el) target = { kind: \"element\", el }\n }\n if (!target) return\n\n // Kill browser rubber-band so it doesn't fight ours.\n const prevOverscroll =\n target.kind === \"window\"\n ? document.documentElement.style.overscrollBehaviorY\n : target.el.style.overscrollBehaviorY\n if (target.kind === \"window\") {\n document.documentElement.style.overscrollBehaviorY = \"none\"\n } else {\n target.el.style.overscrollBehaviorY = \"none\"\n }\n\n const isAtBottom = () =>\n target.kind === \"window\" ? windowAtBottom() : elementAtBottom(target.el)\n\n const clearWheelTimer = () => {\n if (wheelTimer.current) {\n clearTimeout(wheelTimer.current)\n wheelTimer.current = null\n }\n }\n\n const pullTo = (next: number) => {\n pull.set(next)\n }\n\n const snapBack = () => {\n clearWheelTimer()\n pull.set(0)\n }\n\n const onWheel = (event: WheelEvent) => {\n if (demoTimer.current) {\n clearTimeout(demoTimer.current)\n demoTimer.current = null\n setDemoPlaying(false)\n }\n const current = pull.get()\n const scrollingDown = event.deltaY > 0\n const scrollingUp = event.deltaY < 0\n\n if (current > 0.5 || stretch.get() > 0.5) {\n event.preventDefault()\n if (scrollingUp) {\n const next = Math.max(0, current + event.deltaY * PULL_GAIN)\n if (next <= 0.5) {\n snapBack()\n return\n }\n pullTo(next)\n } else if (scrollingDown) {\n pullTo(applyResistance(current, event.deltaY, maxStretch))\n }\n clearWheelTimer()\n wheelTimer.current = setTimeout(snapBack, WHEEL_IDLE_MS)\n return\n }\n\n if (scrollingDown && isAtBottom()) {\n event.preventDefault()\n pullTo(applyResistance(0, event.deltaY, maxStretch))\n clearWheelTimer()\n wheelTimer.current = setTimeout(snapBack, WHEEL_IDLE_MS)\n }\n }\n\n const onTouchStart = (event: TouchEvent) => {\n const t = event.touches[0]\n if (!t) return\n if (demoTimer.current) {\n clearTimeout(demoTimer.current)\n demoTimer.current = null\n setDemoPlaying(false)\n }\n touchStartY.current = t.clientY\n touchStretch0.current = pull.get()\n }\n\n const onTouchMove = (event: TouchEvent) => {\n const t = event.touches[0]\n if (!t) return\n const dy = touchStartY.current - t.clientY\n const current = pull.get()\n\n if (current > 0.5 || stretch.get() > 0.5 || (dy > 0 && isAtBottom())) {\n if (event.cancelable) event.preventDefault()\n const boost = current < 40 ? POP_BOOST : 1\n const fromRest = touchStretch0.current + dy * PULL_GAIN * boost\n pullTo(Math.min(maxStretch, Math.max(0, fromRest)))\n }\n }\n\n const onTouchEnd = () => {\n if (pull.get() > 0.5 || stretch.get() > 0.5) snapBack()\n }\n\n const optsWheel = { passive: false } as const\n const optsTouchStart = { passive: true } as const\n const optsTouchMove = { passive: false } as const\n\n if (target.kind === \"window\") {\n window.addEventListener(\"wheel\", onWheel, optsWheel)\n window.addEventListener(\"touchstart\", onTouchStart, optsTouchStart)\n window.addEventListener(\"touchmove\", onTouchMove, optsTouchMove)\n window.addEventListener(\"touchend\", onTouchEnd)\n window.addEventListener(\"touchcancel\", onTouchEnd)\n } else {\n const el = target.el\n el.addEventListener(\"wheel\", onWheel, optsWheel)\n el.addEventListener(\"touchstart\", onTouchStart, optsTouchStart)\n el.addEventListener(\"touchmove\", onTouchMove, optsTouchMove)\n el.addEventListener(\"touchend\", onTouchEnd)\n el.addEventListener(\"touchcancel\", onTouchEnd)\n }\n\n return () => {\n clearWheelTimer()\n if (target.kind === \"window\") {\n document.documentElement.style.overscrollBehaviorY = prevOverscroll\n window.removeEventListener(\"wheel\", onWheel)\n window.removeEventListener(\"touchstart\", onTouchStart)\n window.removeEventListener(\"touchmove\", onTouchMove)\n window.removeEventListener(\"touchend\", onTouchEnd)\n window.removeEventListener(\"touchcancel\", onTouchEnd)\n } else {\n target.el.style.overscrollBehaviorY = prevOverscroll\n const el = target.el\n el.removeEventListener(\"wheel\", onWheel)\n el.removeEventListener(\"touchstart\", onTouchStart)\n el.removeEventListener(\"touchmove\", onTouchMove)\n el.removeEventListener(\"touchend\", onTouchEnd)\n el.removeEventListener(\"touchcancel\", onTouchEnd)\n }\n }\n }, [reduceMotion, scrollRef, windowScroll, maxStretch, pull, stretch])\n\n const aurora = (\n
\n \n {/* Single blur layer — cheaper than per-bar filters */}\n
\n {bars.map((bar) => (\n \n ))}\n
\n\n {/* Soft floor bloom */}\n \n \n
\n )\n\n if (windowScroll) {\n if (!mounted) return null\n\n // Portal to body so `fixed` stays on the viewport — a transformed\n // ancestor (the rolling page) would otherwise pin it to the content.\n return createPortal(\n
\n {aurora}\n
,\n document.body\n )\n }\n\n if (scrollRef) {\n return (\n
\n {aurora}\n
\n )\n }\n\n return (\n
\n \n {children}\n \n {aurora}\n
\n )\n}\n",
+ "content": "\"use client\"\n\nimport {\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n type RefObject,\n} from \"react\"\nimport { createPortal } from \"react-dom\"\nimport {\n motion,\n useMotionValue,\n useMotionValueEvent,\n useReducedMotion,\n useSpring,\n useTransform,\n} from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\n\nexport const DEFAULT_COLORS = [\n \"#FF3B30\",\n \"#FF9500\",\n \"#FFCC00\",\n \"#34C759\",\n \"#00C7BE\",\n \"#32ADE6\",\n \"#007AFF\",\n \"#5856D6\",\n \"#AF52DE\",\n \"#FF2D55\",\n]\n\nexport const DEFAULT_COLUMNS = 48\n/** Gaussian blur on the aurora bars, in px. */\nexport const DEFAULT_BLUR = 14\n/** White floor bloom opacity (0–1). */\nexport const DEFAULT_GLOW = 0.22\nexport const WHEEL_IDLE_MS = 90\nexport const PULL_GAIN = 0.7\nexport const POP_BOOST = 1.45\n\n/** Docs / demos — dispatch to play the rubber stretch without faking wheel input. */\nexport const STRETCHY_FOOTER_PLAY = \"stretchy-footer:play\"\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 async function scrollToEnd(root?: HTMLElement | null) {\n if (root) {\n const max = Math.max(0, root.scrollHeight - root.clientHeight)\n root.scrollTo({ top: max, behavior: \"smooth\" })\n const started = performance.now()\n while (performance.now() - started < 2000) {\n if (root.scrollTop >= max - 2) break\n await new Promise((r) => window.setTimeout(r, 32))\n }\n root.scrollTo({ top: max })\n return\n }\n\n const max = Math.max(\n 0,\n document.documentElement.scrollHeight - window.innerHeight\n )\n window.scrollTo({ top: max, behavior: \"smooth\" })\n const started = performance.now()\n while (performance.now() - started < 2000) {\n const top = window.scrollY || document.documentElement.scrollTop\n if (top >= max - 2) break\n await new Promise((r) => window.setTimeout(r, 32))\n }\n window.scrollTo({ top: max })\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\n await scrollToEnd(detail.scrollRoot)\n\n window.dispatchEvent(\n new CustomEvent(STRETCHY_FOOTER_PLAY, {\n detail,\n })\n )\n\n const hold = detail.holdMs ?? 700\n // Travel out + hold + spring home\n await new Promise((r) => window.setTimeout(r, hold + 900))\n}\n\nexport function columnScale(index: number, count: number): number {\n if (count <= 1) return 1\n const t = (index / (count - 1)) * 2 - 1\n return Math.exp(-t * t * 2.2)\n}\n\n/** Stable rgba() for SSR — avoids hex/rgb serialization mismatches. */\nexport function hexToRgba(hex: string, alpha: number): string {\n const raw = hex.replace(\"#\", \"\")\n const full =\n raw.length === 3\n ? raw\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : raw.slice(0, 6)\n const r = Number.parseInt(full.slice(0, 2), 16)\n const g = Number.parseInt(full.slice(2, 4), 16)\n const b = Number.parseInt(full.slice(4, 6), 16)\n return `rgba(${r}, ${g}, ${b}, ${alpha})`\n}\n\nexport function applyResistance(current: number, delta: number, max: number): number {\n if (max <= 0) return 0\n const t = Math.min(1, Math.max(0, current / max))\n const falloff = (1 - t) * (1 - t * 0.35)\n const pop = current < 40 ? POP_BOOST : 1\n return Math.min(max, Math.max(0, current + delta * PULL_GAIN * falloff * pop))\n}\n\nexport function elementAtBottom(el: HTMLElement, slack = 1): boolean {\n return el.scrollTop + el.clientHeight >= el.scrollHeight - slack\n}\n\nexport function windowAtBottom(slack = 1): boolean {\n const doc = document.documentElement\n return window.scrollY + window.innerHeight >= doc.scrollHeight - slack\n}\n\nexport type StretchyFooterProps = {\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 listeners to that element.\n * When omitted (and `windowScroll` is false), this component *is* the scroller.\n */\n scrollRef?: RefObject\n /**\n * Bind overscroll to the window and paint a fixed bottom aurora.\n * Use on full pages (e.g. the component docs) 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 /** Peak stretch in px. Default `280`. */\n maxStretch?: number\n /** Spectrum stops across the aurora columns. */\n colors?: string[]\n /** Spring stiffness. Default `380`. */\n stiffness?: number\n /** Spring damping. Default `32`. */\n damping?: number\n /** How many vertical aurora bars. Default `48`. */\n columns?: number\n /** Gaussian blur on the aurora bars, in px. Default `14`. */\n blur?: number\n /** White floor bloom opacity (0–1). Default `0.22`. */\n glow?: number\n /** Accessible label for the decorative field. */\n label?: string\n /**\n * Optional id for docs demos. `playStretchyFooterDemo({ target })` only\n * animates footers whose `demoId` matches.\n */\n demoId?: string\n}\n\ntype OverscrollTarget =\n | { kind: \"element\"; el: HTMLElement }\n | { kind: \"window\" }\n\n/**\n * Dia-style stretchy overflow — overscroll past the bottom and a rainbow\n * field scaleY-stretches from the floor while the page lifts with it.\n * GPU transforms only (no height animation) for a clean, snappy rubber band.\n */\nexport function StretchyFooter({\n className,\n children,\n scrollRef,\n windowScroll = false,\n contentSelector = \"[data-stretchy-page]\",\n maxStretch = 280,\n colors = DEFAULT_COLORS,\n stiffness = 380,\n damping = 32,\n columns = DEFAULT_COLUMNS,\n blur = DEFAULT_BLUR,\n glow = DEFAULT_GLOW,\n label = \"Stretchy overflow\",\n demoId,\n}: StretchyFooterProps) {\n const reduceMotion = useReducedMotion() ?? false\n const internalRef = useRef(null)\n const pull = useMotionValue(0)\n const stretch = useSpring(pull, {\n stiffness,\n damping,\n mass: 0.35,\n restDelta: 0.2,\n restSpeed: 0.2,\n })\n const touchStartY = useRef(0)\n const touchStretch0 = useRef(0)\n const wheelTimer = useRef | null>(null)\n const demoTimer = useRef | null>(null)\n const demoPlayingRef = useRef(false)\n const rolledEl = useRef(null)\n const [mounted, setMounted] = useState(false)\n /** User-triggered demo — plays even when prefers-reduced-motion is on. */\n const [demoPlaying, setDemoPlaying] = useState(false)\n demoPlayingRef.current = demoPlaying\n // Gate on `mounted` so prefers-reduced-motion can't mismatch SSR HTML.\n const reduce = mounted && reduceMotion && !demoPlaying\n\n useEffect(() => {\n setMounted(true)\n }, [])\n\n // 0 → 1 progress drives scaleY (compositor-friendly).\n const progress = useTransform(stretch, (v) =>\n maxStretch <= 0 ? 0 : Math.min(1, Math.max(0, v / maxStretch))\n )\n const pageY = useTransform(stretch, (v) => -v)\n const fieldOpacity = useTransform(progress, [0, 0.08, 0.2], [0, 1, 1])\n\n const blurPx = Math.max(0, blur)\n const glowAlpha = Math.min(1, Math.max(0, glow))\n\n const bars = useMemo(() => {\n const count = Math.max(8, Math.min(96, Math.floor(columns)))\n return Array.from({ length: count }, (_, i) => {\n const color = colors[i % colors.length]!\n const heightPct = Math.max(28, columnScale(i, count) * 100).toFixed(2)\n return {\n key: i,\n height: `${heightPct}%`,\n backgroundImage: `linear-gradient(to top, ${hexToRgba(color, 1)} 0%, ${hexToRgba(color, 0.6)} 45%, rgba(0, 0, 0, 0) 100%)`,\n }\n })\n }, [colors, columns])\n\n // Lift the page in lockstep with the stretch — translate only, no 3D.\n useMotionValueEvent(stretch, \"change\", (v) => {\n if (reduceMotion && !demoPlayingRef.current) return\n if (!windowScroll && !scrollRef) return\n\n if (!rolledEl.current?.isConnected) {\n rolledEl.current = document.querySelector(\n contentSelector\n ) as HTMLElement | null\n }\n const el = rolledEl.current\n if (!el) return\n\n const y = -Math.max(0, v)\n el.style.transformOrigin = \"50% 100%\"\n el.style.willChange = \"transform\"\n el.style.transform = `translate3d(0, ${y}px, 0)`\n\n if (v < 0.2) {\n el.style.transform = \"translate3d(0, 0, 0)\"\n el.style.willChange = \"\"\n }\n })\n\n useEffect(() => {\n return () => {\n const el = rolledEl.current\n if (!el) return\n el.style.transform = \"\"\n el.style.transformOrigin = \"\"\n el.style.willChange = \"\"\n }\n }, [])\n\n // Always listen for docs \"Show effect\" — drives pull directly (no fake wheels).\n useEffect(() => {\n const clearDemoTimer = () => {\n if (demoTimer.current) {\n clearTimeout(demoTimer.current)\n demoTimer.current = null\n }\n }\n\n const onPlay = (event: Event) => {\n const detail =\n (event as CustomEvent).detail ?? {}\n if (detail.target != null && detail.target !== demoId) return\n // Scoped footers ignore broadcast plays without a target.\n if (detail.target == null && demoId != null) return\n\n const amount = detail.amount ?? 0.82\n const holdMs = detail.holdMs ?? 700\n\n if (wheelTimer.current) {\n clearTimeout(wheelTimer.current)\n wheelTimer.current = null\n }\n clearDemoTimer()\n demoPlayingRef.current = true\n setDemoPlaying(true)\n pull.set(Math.min(maxStretch, Math.max(0, maxStretch * amount)))\n demoTimer.current = setTimeout(() => {\n demoTimer.current = null\n pull.set(0)\n // Wait for the spring to settle before re-honoring reduced motion.\n window.setTimeout(() => {\n demoPlayingRef.current = false\n setDemoPlaying(false)\n }, 500)\n }, holdMs)\n }\n\n window.addEventListener(STRETCHY_FOOTER_PLAY, onPlay)\n return () => {\n clearDemoTimer()\n window.removeEventListener(STRETCHY_FOOTER_PLAY, onPlay)\n }\n }, [demoId, maxStretch, pull])\n\n useEffect(() => {\n if (reduceMotion) return\n\n let target: OverscrollTarget | null = null\n if (windowScroll) {\n target = { kind: \"window\" }\n } else {\n const el = scrollRef?.current ?? internalRef.current\n if (el) target = { kind: \"element\", el }\n }\n if (!target) return\n\n // Kill browser rubber-band so it doesn't fight ours.\n const prevOverscroll =\n target.kind === \"window\"\n ? document.documentElement.style.overscrollBehaviorY\n : target.el.style.overscrollBehaviorY\n if (target.kind === \"window\") {\n document.documentElement.style.overscrollBehaviorY = \"none\"\n } else {\n target.el.style.overscrollBehaviorY = \"none\"\n }\n\n const isAtBottom = () =>\n target.kind === \"window\" ? windowAtBottom() : elementAtBottom(target.el)\n\n const clearWheelTimer = () => {\n if (wheelTimer.current) {\n clearTimeout(wheelTimer.current)\n wheelTimer.current = null\n }\n }\n\n const pullTo = (next: number) => {\n pull.set(next)\n }\n\n const snapBack = () => {\n clearWheelTimer()\n pull.set(0)\n }\n\n const onWheel = (event: WheelEvent) => {\n if (demoTimer.current) {\n clearTimeout(demoTimer.current)\n demoTimer.current = null\n setDemoPlaying(false)\n }\n const current = pull.get()\n const scrollingDown = event.deltaY > 0\n const scrollingUp = event.deltaY < 0\n\n if (current > 0.5 || stretch.get() > 0.5) {\n event.preventDefault()\n if (scrollingUp) {\n const next = Math.max(0, current + event.deltaY * PULL_GAIN)\n if (next <= 0.5) {\n snapBack()\n return\n }\n pullTo(next)\n } else if (scrollingDown) {\n pullTo(applyResistance(current, event.deltaY, maxStretch))\n }\n clearWheelTimer()\n wheelTimer.current = setTimeout(snapBack, WHEEL_IDLE_MS)\n return\n }\n\n if (scrollingDown && isAtBottom()) {\n event.preventDefault()\n pullTo(applyResistance(0, event.deltaY, maxStretch))\n clearWheelTimer()\n wheelTimer.current = setTimeout(snapBack, WHEEL_IDLE_MS)\n }\n }\n\n const onTouchStart = (event: TouchEvent) => {\n const t = event.touches[0]\n if (!t) return\n if (demoTimer.current) {\n clearTimeout(demoTimer.current)\n demoTimer.current = null\n setDemoPlaying(false)\n }\n touchStartY.current = t.clientY\n touchStretch0.current = pull.get()\n }\n\n const onTouchMove = (event: TouchEvent) => {\n const t = event.touches[0]\n if (!t) return\n const dy = touchStartY.current - t.clientY\n const current = pull.get()\n\n if (current > 0.5 || stretch.get() > 0.5 || (dy > 0 && isAtBottom())) {\n if (event.cancelable) event.preventDefault()\n const boost = current < 40 ? POP_BOOST : 1\n const fromRest = touchStretch0.current + dy * PULL_GAIN * boost\n pullTo(Math.min(maxStretch, Math.max(0, fromRest)))\n }\n }\n\n const onTouchEnd = () => {\n if (pull.get() > 0.5 || stretch.get() > 0.5) snapBack()\n }\n\n const optsWheel = { passive: false } as const\n const optsTouchStart = { passive: true } as const\n const optsTouchMove = { passive: false } as const\n\n if (target.kind === \"window\") {\n window.addEventListener(\"wheel\", onWheel, optsWheel)\n window.addEventListener(\"touchstart\", onTouchStart, optsTouchStart)\n window.addEventListener(\"touchmove\", onTouchMove, optsTouchMove)\n window.addEventListener(\"touchend\", onTouchEnd)\n window.addEventListener(\"touchcancel\", onTouchEnd)\n } else {\n const el = target.el\n el.addEventListener(\"wheel\", onWheel, optsWheel)\n el.addEventListener(\"touchstart\", onTouchStart, optsTouchStart)\n el.addEventListener(\"touchmove\", onTouchMove, optsTouchMove)\n el.addEventListener(\"touchend\", onTouchEnd)\n el.addEventListener(\"touchcancel\", onTouchEnd)\n }\n\n return () => {\n clearWheelTimer()\n if (target.kind === \"window\") {\n document.documentElement.style.overscrollBehaviorY = prevOverscroll\n window.removeEventListener(\"wheel\", onWheel)\n window.removeEventListener(\"touchstart\", onTouchStart)\n window.removeEventListener(\"touchmove\", onTouchMove)\n window.removeEventListener(\"touchend\", onTouchEnd)\n window.removeEventListener(\"touchcancel\", onTouchEnd)\n } else {\n target.el.style.overscrollBehaviorY = prevOverscroll\n const el = target.el\n el.removeEventListener(\"wheel\", onWheel)\n el.removeEventListener(\"touchstart\", onTouchStart)\n el.removeEventListener(\"touchmove\", onTouchMove)\n el.removeEventListener(\"touchend\", onTouchEnd)\n el.removeEventListener(\"touchcancel\", onTouchEnd)\n }\n }\n }, [reduceMotion, scrollRef, windowScroll, maxStretch, pull, stretch])\n\n const aurora = (\n
\n \n {/* Single blur layer — cheaper than per-bar filters */}\n
\n {bars.map((bar) => (\n \n ))}\n
\n\n {/* Soft floor bloom */}\n \n \n
\n )\n\n if (windowScroll) {\n if (!mounted) return null\n\n // Portal to body so `fixed` stays on the viewport — a transformed\n // ancestor (the rolling page) would otherwise pin it to the content.\n return createPortal(\n
\n {aurora}\n
,\n document.body\n )\n }\n\n if (scrollRef) {\n return (\n