From 873bac19e3f4fc384d6b77a05a8a84035ec4aa1a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 11:47:08 +0000 Subject: [PATCH 1/4] feat: add Logo Burst component for React and Svelte Hair-line tentacles explode from a centered mark and settle with tip particles. Shared canvas engine, React/Svelte wrappers, docs demo with live controls, and registry publish for both ports. Co-authored-by: Jay Sharma --- README.md | 1 + content/docs/components/logo-burst.mdx | 119 +++++++ content/docs/components/meta.json | 1 + content/docs/getting-started.mdx | 18 +- content/docs/index.mdx | 1 + public/r/live-orb-svelte.json | 4 +- public/r/live-orb.json | 4 +- public/r/logo-burst-svelte.json | 15 + public/r/logo-burst.json | 15 + public/r/registry.json | 26 ++ public/r/stretchy-footer-svelte.json | 2 +- public/r/stretchy-footer.json | 2 +- registry.json | 1 + registry/logo-burst/logo-burst-demo.tsx | 141 ++++++++ registry/logo-burst/logo-burst-vanilla.ts | 396 ++++++++++++++++++++++ registry/logo-burst/logo-burst.svelte | 165 +++++++++ registry/logo-burst/logo-burst.tsx | 186 ++++++++++ registry/logo-burst/registry.json | 31 ++ 18 files changed, 1113 insertions(+), 15 deletions(-) create mode 100644 content/docs/components/logo-burst.mdx create mode 100644 public/r/logo-burst-svelte.json create mode 100644 public/r/logo-burst.json create mode 100644 registry/logo-burst/logo-burst-demo.tsx create mode 100644 registry/logo-burst/logo-burst-vanilla.ts create mode 100644 registry/logo-burst/logo-burst.svelte create mode 100644 registry/logo-burst/logo-burst.tsx create mode 100644 registry/logo-burst/registry.json diff --git a/README.md b/README.md index 0302b3d..7cfd0a8 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 a centered mark, then settle with tip particles | | [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..0a9520a --- /dev/null +++ b/content/docs/components/logo-burst.mdx @@ -0,0 +1,119 @@ +--- +title: Logo Burst +description: Hair-line tentacles explode from a centered mark, then settle with tip particles — drop any logo in the middle. +--- + +import { LogoBurstDemo } from "@/registry/logo-burst/logo-burst-demo" + +A brand-hero burst. Thin filaments detonate out of the center, tip particles lock in, and the mark stays put. Click the plate — or bump `replayKey` — to explode again. Sit it on a dark surface. + + + +## Installation + + + +Or install straight from the public GitHub source registry: + + + +## Usage + +Give the parent a size and a dark background. The canvas is transparent. Pass children to swap the default chevron plate. + + + +```tsx +"use client" + +import { LogoBurst } from "@/components/ui/logo-burst" + +export function HeroMark() { + return ( +
+ + + +
+ ) +} +``` +
+ +```svelte + + +
+ + + +
+``` +
+
+ +Omit children to keep the built-in plate. The hole under the mark tracks the core size — pass `coreSize` only when you want to lock it. + +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. + +## Props + +| Prop | Type | Default | +| --------------- | ----------------------- | ---------------------------------------- | +| `children` | `ReactNode` / `Snippet` | chevron plate | +| `tentacleCount` | `number` | `260` | +| `color` | `string` | `#D6D2CA` | +| `coreSize` | `number` | measured from the mark | +| `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 | 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..a5c3714 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 a mark - [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 !hasGl}\n \n \n \n \n {/if}\n \n\n", + "content": "\n\n\n\n\n {#if !hasGl}\n \n \n \n \n {/if}\n \n\n", "type": "registry:file", "target": "src/lib/components/ui/live-orb.svelte" } diff --git a/public/r/live-orb.json b/public/r/live-orb.json index 78e44ce..4372cf8 100644 --- a/public/r/live-orb.json +++ b/public/r/live-orb.json @@ -2,11 +2,11 @@ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "live-orb", "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.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type LiveOrbVariant = \"white\" | \"black\" | \"webgl\" | \"custom\"\n\nexport type LiveOrbOptions = {\n /** Material preset. Default `\"white\"`. */\n variant?: LiveOrbVariant\n /** Body hex — used when `variant=\"custom\"`. */\n color?: string\n /** Eye hex — used when `variant=\"custom\"`. */\n eyeColor?: string\n /** Iridescent stops — used when `variant=\"webgl\"`. */\n colors?: string[]\n /** Eyes follow the pointer. Default `true`. */\n interactive?: boolean\n /** Occasional blink. Default `true`. */\n blink?: boolean\n /** Fires when WebGL is ready (`true`) or torn down (`false`). */\n onHasGl?: (ok: boolean) => void\n}\n\nexport type LiveOrbInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nexport const WHITE = { color: \"#F4F4F5\", eyeColor: \"#09090B\" } as const\nexport const BLACK = { color: \"#18181B\", eyeColor: \"#F4F4F5\" } as const\nexport const CUSTOM_DEFAULT = { color: \"#7C5CFF\", eyeColor: \"#FAFAFA\" } as const\n/** Violet / foam / dust-rose — stock webgl interior. */\nexport const WEBGL_COLORS = [\"#7C6AF7\", \"#7DD3C7\", \"#E8B4D4\"]\n\nconst VERT = `\nattribute vec2 a_position;\nvoid main() {\n gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`\n\nconst FRAG = `\nprecision highp float;\n\nuniform vec2 u_resolution;\nuniform float u_time;\nuniform float u_speed;\nuniform vec2 u_look;\nuniform float u_blink;\nuniform float u_mode;\nuniform vec3 u_body;\nuniform vec3 u_eye;\nuniform vec3 u_c1;\nuniform vec3 u_c2;\nuniform vec3 u_c3;\n\nfloat hash(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat noise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n float a = hash(i);\n float b = hash(i + vec2(1.0, 0.0));\n float c = hash(i + vec2(0.0, 1.0));\n float d = hash(i + vec2(1.0, 1.0));\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;\n}\n\nfloat fbm(vec2 p) {\n float v = 0.0;\n float a = 0.5;\n mat2 m = mat2(1.6, 1.2, -1.2, 1.6);\n for (int i = 0; i < 5; i++) {\n v += a * noise(p);\n p = m * p;\n a *= 0.5;\n }\n return v;\n}\n\nvec3 orient(vec3 p, vec2 look) {\n float yaw = look.x * 0.92;\n float pitch = -look.y * 0.78;\n float cy = cos(yaw);\n float sy = sin(yaw);\n float cp = cos(pitch);\n float sp = sin(pitch);\n vec3 q = vec3(p.x, p.y * cp - p.z * sp, p.y * sp + p.z * cp);\n return vec3(q.x * cy + q.z * sy, q.y, -q.x * sy + q.z * cy);\n}\n\nfloat eyeMask(vec3 n, vec3 e, vec3 right, vec3 up, float halfH, float rad) {\n float facing = dot(n, e);\n float x = dot(n, right) - dot(e, right);\n float y = dot(n, up) - dot(e, up);\n y -= clamp(y, -halfH, halfH);\n float d = length(vec2(x, y)) - rad;\n float fill = 1.0 - smoothstep(-0.01, 0.01, d);\n return fill * smoothstep(0.12, 0.32, facing);\n}\n\nvec3 matteShade(vec3 n, vec3 body) {\n vec3 light = normalize(vec3(-0.22, 0.62, 0.72));\n float wrap = dot(n, light) * 0.42 + 0.58;\n vec3 halfV = normalize(light + vec3(0.0, 0.0, 1.0));\n float spec = pow(max(dot(n, halfV), 0.0), 56.0) * 0.16;\n float rim = pow(1.0 - n.z, 1.55);\n vec3 col = body * wrap;\n col *= mix(1.0, 0.78, rim);\n col += vec3(spec);\n return col;\n}\n\nvec3 webglShade(vec3 n, float t) {\n vec2 q = n.xy * 2.2 + n.z * 0.85;\n vec2 w1 = vec2(\n fbm(q * 1.4 + vec2(t * 0.22, t * 0.18)),\n fbm(q * 1.4 + vec2(-t * 0.16, t * 0.24) + 4.1)\n );\n q += (w1 - 0.5) * 0.72;\n float f = fbm(q * 2.1 + vec2(0.0, t * 0.12));\n float g = fbm(q * 4.6 - vec2(t * 0.2, 0.0));\n vec3 interior = mix(u_c1, u_c2, smoothstep(0.28, 0.72, f));\n interior = mix(interior, u_c3, pow(smoothstep(0.42, 0.9, g), 1.4));\n vec3 light = normalize(vec3(-0.18, 0.55, 0.8));\n float wrap = dot(n, light) * 0.28 + 0.72;\n float fres = pow(1.0 - n.z, 2.1);\n vec3 col = interior * wrap;\n col += fres * mix(u_c2, u_c3, 0.45) * 0.55;\n col += pow(max(dot(n, light), 0.0), 40.0) * 0.22;\n return col;\n}\n\nvoid main() {\n vec2 uv = (gl_FragCoord.xy / u_resolution.xy) * 2.0 - 1.0;\n float aspect = u_resolution.x / max(u_resolution.y, 1.0);\n uv.x *= aspect;\n\n float radius = 0.86;\n vec2 p = uv / radius;\n float r2 = dot(p, p);\n float edge = 1.0 - smoothstep(0.985, 1.012, sqrt(max(r2, 0.0)));\n if (edge <= 0.001) {\n gl_FragColor = vec4(0.0);\n return;\n }\n\n float z = sqrt(max(1.0 - r2, 0.0));\n vec3 n = normalize(vec3(p, z));\n\n vec2 look = u_look;\n float lm = length(look);\n if (lm > 1.0) look /= lm;\n\n vec3 right = orient(vec3(1.0, 0.0, 0.0), look);\n vec3 up = orient(vec3(0.0, 1.0, 0.0), look);\n vec3 eL = orient(normalize(vec3(-0.32, 0.08, 1.0)), look);\n vec3 eR = orient(normalize(vec3(0.32, 0.08, 1.0)), look);\n\n float halfH = mix(0.128, 0.012, u_blink);\n float rad = mix(0.054, 0.062, u_blink);\n float eyes = max(\n eyeMask(n, eL, right, up, halfH, rad),\n eyeMask(n, eR, right, up, halfH, rad)\n );\n\n vec3 body = u_mode > 0.5\n ? webglShade(n, u_time * u_speed)\n : matteShade(n, u_body);\n\n vec3 col = mix(body, u_eye, clamp(eyes, 0.0, 1.0));\n col += (hash(gl_FragCoord.xy + fract(u_time * 0.01)) - 0.5) * 0.012;\n\n gl_FragColor = vec4(clamp(col, 0.0, 1.0), edge);\n}\n`\n\nfunction isDev() {\n return (\n typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\n )\n}\n\nfunction hexToRgb(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return [0.96, 0.96, 0.96]\n return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n}\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n if (!shader) return null\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n if (isDev()) {\n console.warn(\n \"LiveOrb: shader failed to compile\\n\",\n gl.getShaderInfoLog(shader)\n )\n }\n gl.deleteShader(shader)\n return null\n }\n return shader\n}\n\nfunction mixHex(hex: string, toward: string, t: number) {\n const a = hexToRgb(hex)\n const b = hexToRgb(toward)\n const m = (i: number) =>\n Math.round((a[i]! * (1 - t) + b[i]! * t) * 255)\n .toString(16)\n .padStart(2, \"0\")\n return `#${m(0)}${m(1)}${m(2)}`\n}\n\nexport function resolveVariant(\n variant: LiveOrbVariant,\n color?: string,\n eyeColor?: string,\n colors?: string[]\n) {\n let body: string = WHITE.color\n let eye: string = WHITE.eyeColor\n let mode = 0\n let palette: string[] = WEBGL_COLORS\n\n if (variant === \"black\") {\n body = BLACK.color\n eye = BLACK.eyeColor\n } else if (variant === \"webgl\") {\n body = WHITE.color\n eye = \"#0C0C10\"\n mode = 1\n palette = colors && colors.length > 0 ? colors : WEBGL_COLORS\n } else if (variant === \"custom\") {\n body = color ?? CUSTOM_DEFAULT.color\n eye = eyeColor ?? CUSTOM_DEFAULT.eyeColor\n }\n\n const highlight =\n mode === 1\n ? mixHex(palette[1] ?? WEBGL_COLORS[1]!, \"#FFFFFF\", 0.35)\n : mixHex(body, \"#FFFFFF\", 0.42)\n const shade =\n mode === 1\n ? mixHex(palette[0] ?? WEBGL_COLORS[0]!, \"#0A0A0B\", 0.45)\n : mixHex(body, \"#09090B\", 0.22)\n\n return { body, eye, mode, palette, highlight, shade }\n}\n\n/**\n * Matte (or iridescent) sphere with two capsule eyes that follow the pointer.\n * The orb stays put — only the gaze moves.\n */\nexport function createLiveOrb(\n canvas: HTMLCanvasElement,\n initial: LiveOrbOptions = {}\n): LiveOrbInstance | null {\n let options: Required<\n Pick\n > &\n LiveOrbOptions = {\n variant: \"white\",\n interactive: true,\n blink: true,\n ...initial,\n }\n\n const look = { x: 0, y: 0.08 }\n const targetLook = { x: 0, y: 0.08 }\n let reduce = false\n\n const gl = canvas.getContext(\"webgl\", {\n alpha: true,\n antialias: false,\n depth: false,\n stencil: false,\n premultipliedAlpha: false,\n powerPreference: \"high-performance\",\n })\n if (!gl) return null\n\n const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)\n if (!vs || !fs) return null\n\n const program = gl.createProgram()\n if (!program) return null\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"LiveOrb: program failed to link\\n\",\n gl.getProgramInfoLog(program)\n )\n }\n return null\n }\n gl.useProgram(program)\n options.onHasGl?.(true)\n\n const buf = gl.createBuffer()\n gl.bindBuffer(gl.ARRAY_BUFFER, buf)\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n gl.STATIC_DRAW\n )\n const loc = gl.getAttribLocation(program, \"a_position\")\n gl.enableVertexAttribArray(loc)\n gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n\n const uResolution = gl.getUniformLocation(program, \"u_resolution\")\n const uTime = gl.getUniformLocation(program, \"u_time\")\n const uSpeed = gl.getUniformLocation(program, \"u_speed\")\n const uLook = gl.getUniformLocation(program, \"u_look\")\n const uBlink = gl.getUniformLocation(program, \"u_blink\")\n const uMode = gl.getUniformLocation(program, \"u_mode\")\n const uBody = gl.getUniformLocation(program, \"u_body\")\n const uEye = gl.getUniformLocation(program, \"u_eye\")\n const uC1 = gl.getUniformLocation(program, \"u_c1\")\n const uC2 = gl.getUniformLocation(program, \"u_c2\")\n const uC3 = gl.getUniformLocation(program, \"u_c3\")\n\n gl.enable(gl.BLEND)\n gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)\n\n let raf = 0\n let running = true\n const start = performance.now()\n let nextBlink = start + 1800 + Math.random() * 2400\n let blinkAt = -10_000\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w <= 0 || h <= 0) return\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n gl.viewport(0, 0, canvas.width, canvas.height)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const onMove = (e: PointerEvent) => {\n if (!options.interactive) return\n const parent = canvas.parentElement\n if (!parent) return\n const rect = parent.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2)\n const dy = (rect.top + rect.height / 2 - e.clientY) / (rect.height / 2)\n targetLook.x = Math.min(1, Math.max(-1, dx))\n targetLook.y = Math.min(1, Math.max(-1, dy))\n }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n const tick = (now: number) => {\n if (!running) return\n\n const resolved = resolveVariant(\n options.variant,\n options.color,\n options.eyeColor,\n options.colors\n )\n const time = (now - start) / 1000\n\n if (options.interactive) {\n look.x += (targetLook.x - look.x) * 0.16\n look.y += (targetLook.y - look.y) * 0.16\n } else {\n look.x += (0 - look.x) * 0.12\n look.y += (0.08 - look.y) * 0.12\n }\n\n if (!reduce && options.blink && now >= nextBlink) {\n blinkAt = now\n nextBlink = now + 2200 + Math.random() * 3800\n }\n const bt = (now - blinkAt) / 1000\n let b = 0\n if (!reduce && options.blink) {\n if (bt < 0.055) b = bt / 0.055\n else if (bt < 0.1) b = 1\n else if (bt < 0.18) b = 1 - (bt - 0.1) / 0.08\n }\n const body = hexToRgb(resolved.body)\n const eye = hexToRgb(resolved.eye)\n const palette = [0, 1, 2].map((i) =>\n hexToRgb(\n resolved.palette[i % resolved.palette.length] ?? WEBGL_COLORS[i]!\n )\n )\n\n gl.clearColor(0, 0, 0, 0)\n gl.clear(gl.COLOR_BUFFER_BIT)\n gl.uniform2f(uResolution, canvas.width, canvas.height)\n gl.uniform1f(uTime, reduce ? 0 : time)\n gl.uniform1f(uSpeed, reduce ? 0 : 0.55)\n gl.uniform2f(uLook, look.x, look.y)\n gl.uniform1f(uBlink, b)\n gl.uniform1f(uMode, resolved.mode)\n gl.uniform3f(uBody, body[0], body[1], body[2])\n gl.uniform3f(uEye, eye[0], eye[1], eye[2])\n gl.uniform3f(uC1, palette[0]![0], palette[0]![1], palette[0]![2])\n gl.uniform3f(uC2, palette[1]![0], palette[1]![1], palette[1]![2])\n gl.uniform3f(uC3, palette[2]![0], palette[2]![1], palette[2]![2])\n\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onMove)\n options.onHasGl?.(false)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n },\n }\n}\n\nexport type LiveOrbProps = Omit & {\n className?: string\n /** Edge length in CSS pixels. Default `280`. */\n size?: number\n}\n\n/**\n * Matte (or iridescent) sphere with two capsule eyes that follow the pointer.\n * The orb stays put — only the gaze moves.\n */\nexport function LiveOrb({\n className,\n size = 280,\n variant = \"white\",\n color,\n eyeColor,\n colors,\n interactive = true,\n blink = true,\n}: LiveOrbProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [hasGl, setHasGl] = useState(false)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createLiveOrb(canvas, {\n variant,\n color,\n eyeColor,\n colors,\n interactive,\n blink,\n onHasGl: setHasGl,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n variant,\n color,\n eyeColor,\n colors,\n interactive,\n blink,\n onHasGl: setHasGl,\n })\n }, [variant, color, eyeColor, colors, interactive, blink])\n\n const resolved = resolveVariant(variant, color, eyeColor, colors)\n\n return (\n \n {!hasGl ? (\n \n \n \n \n ) : null}\n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type LiveOrbVariant = \"white\" | \"black\" | \"webgl\" | \"custom\"\n\nexport type LiveOrbOptions = {\n /** Material preset. Default `\"white\"`. */\n variant?: LiveOrbVariant\n /** Body hex — used when `variant=\"custom\"`. */\n color?: string\n /** Eye hex — used when `variant=\"custom\"`. */\n eyeColor?: string\n /** Unlit wash stops — used when `variant=\"webgl\"`. */\n colors?: string[]\n /** Eyes follow the pointer. Default `true`. */\n interactive?: boolean\n /** Occasional blink. Default `true`. */\n blink?: boolean\n /** Fires when WebGL is ready (`true`) or torn down (`false`). */\n onHasGl?: (ok: boolean) => void\n}\n\nexport type LiveOrbInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nexport const WHITE = { color: \"#F4F4F5\", eyeColor: \"#09090B\" } as const\nexport const BLACK = { color: \"#18181B\", eyeColor: \"#F4F4F5\" } as const\nexport const CUSTOM_DEFAULT = { color: \"#7C5CFF\", eyeColor: \"#FAFAFA\" } as const\n/** Violet / foam / dust-rose — stock unlit wash. */\nexport const WEBGL_COLORS = [\"#7C6AF7\", \"#7DD3C7\", \"#E8B4D4\"]\n\nconst VERT = `\nattribute vec2 a_position;\nvoid main() {\n gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`\n\nconst FRAG = `\nprecision highp float;\n\nuniform vec2 u_resolution;\nuniform float u_time;\nuniform float u_speed;\nuniform vec2 u_look;\nuniform float u_blink;\nuniform float u_mode;\nuniform vec3 u_body;\nuniform vec3 u_eye;\nuniform vec3 u_c1;\nuniform vec3 u_c2;\nuniform vec3 u_c3;\n\nfloat hash(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat noise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n float a = hash(i);\n float b = hash(i + vec2(1.0, 0.0));\n float c = hash(i + vec2(0.0, 1.0));\n float d = hash(i + vec2(1.0, 1.0));\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;\n}\n\nfloat fbm(vec2 p) {\n float v = 0.0;\n float a = 0.5;\n mat2 m = mat2(1.6, 1.2, -1.2, 1.6);\n for (int i = 0; i < 5; i++) {\n v += a * noise(p);\n p = m * p;\n a *= 0.5;\n }\n return v;\n}\n\nvec3 orient(vec3 p, vec2 look) {\n float yaw = look.x * 0.92;\n float pitch = -look.y * 0.78;\n float cy = cos(yaw);\n float sy = sin(yaw);\n float cp = cos(pitch);\n float sp = sin(pitch);\n vec3 q = vec3(p.x, p.y * cp - p.z * sp, p.y * sp + p.z * cp);\n return vec3(q.x * cy + q.z * sy, q.y, -q.x * sy + q.z * cy);\n}\n\nfloat eyeMask(vec3 n, vec3 e, vec3 right, vec3 up, float halfH, float rad) {\n float facing = dot(n, e);\n float x = dot(n, right) - dot(e, right);\n float y = dot(n, up) - dot(e, up);\n y -= clamp(y, -halfH, halfH);\n float d = length(vec2(x, y)) - rad;\n float fill = 1.0 - smoothstep(-0.01, 0.01, d);\n return fill * smoothstep(0.12, 0.32, facing);\n}\n\nvec3 wash(vec3 n, float t) {\n vec2 q = n.xy * 2.2 + n.z * 0.85;\n vec2 w1 = vec2(\n fbm(q * 1.4 + vec2(t * 0.22, t * 0.18)),\n fbm(q * 1.4 + vec2(-t * 0.16, t * 0.24) + 4.1)\n );\n q += (w1 - 0.5) * 0.72;\n float f = fbm(q * 2.1 + vec2(0.0, t * 0.12));\n float g = fbm(q * 4.6 - vec2(t * 0.2, 0.0));\n vec3 col = mix(u_c1, u_c2, smoothstep(0.28, 0.72, f));\n return mix(col, u_c3, pow(smoothstep(0.42, 0.9, g), 1.4));\n}\n\nvoid main() {\n vec2 uv = (gl_FragCoord.xy / u_resolution.xy) * 2.0 - 1.0;\n float aspect = u_resolution.x / max(u_resolution.y, 1.0);\n uv.x *= aspect;\n\n float radius = 0.94;\n vec2 p = uv / radius;\n float r2 = dot(p, p);\n float edge = 1.0 - smoothstep(0.985, 1.012, sqrt(max(r2, 0.0)));\n if (edge <= 0.001) {\n gl_FragColor = vec4(0.0);\n return;\n }\n\n float z = sqrt(max(1.0 - r2, 0.0));\n vec3 n = normalize(vec3(p, z));\n\n vec2 look = u_look;\n float lm = length(look);\n if (lm > 1.0) look /= lm;\n\n vec3 right = orient(vec3(1.0, 0.0, 0.0), look);\n vec3 up = orient(vec3(0.0, 1.0, 0.0), look);\n vec3 eL = orient(normalize(vec3(-0.32, 0.08, 1.0)), look);\n vec3 eR = orient(normalize(vec3(0.32, 0.08, 1.0)), look);\n\n float halfH = mix(0.128, 0.012, u_blink);\n float rad = mix(0.054, 0.062, u_blink);\n float eyes = max(\n eyeMask(n, eL, right, up, halfH, rad),\n eyeMask(n, eR, right, up, halfH, rad)\n );\n\n vec3 body = u_mode > 0.5 ? wash(n, u_time * u_speed) : u_body;\n vec3 col = mix(body, u_eye, clamp(eyes, 0.0, 1.0));\n\n gl_FragColor = vec4(clamp(col, 0.0, 1.0), edge);\n}\n`\n\nfunction isDev() {\n return (\n typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\n )\n}\n\nfunction hexToRgb(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return [0.96, 0.96, 0.96]\n return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n}\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n if (!shader) return null\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n if (isDev()) {\n console.warn(\n \"LiveOrb: shader failed to compile\\n\",\n gl.getShaderInfoLog(shader)\n )\n }\n gl.deleteShader(shader)\n return null\n }\n return shader\n}\n\nexport function resolveVariant(\n variant: LiveOrbVariant,\n color?: string,\n eyeColor?: string,\n colors?: string[]\n) {\n let body: string = WHITE.color\n let eye: string = WHITE.eyeColor\n let mode = 0\n let palette: string[] = WEBGL_COLORS\n\n if (variant === \"black\") {\n body = BLACK.color\n eye = BLACK.eyeColor\n } else if (variant === \"webgl\") {\n palette = colors && colors.length > 0 ? colors : WEBGL_COLORS\n body = palette[1] ?? WEBGL_COLORS[1]!\n eye = \"#0C0C10\"\n mode = 1\n } else if (variant === \"custom\") {\n body = color ?? CUSTOM_DEFAULT.color\n eye = eyeColor ?? CUSTOM_DEFAULT.eyeColor\n }\n\n return { body, eye, mode, palette }\n}\n\nexport function fallbackFaceStyle(\n resolved: ReturnType\n): {\n backgroundColor?: string\n backgroundImage?: string\n} {\n if (resolved.mode === 1) {\n const a = resolved.palette[0] ?? WEBGL_COLORS[0]\n const b = resolved.palette[1] ?? WEBGL_COLORS[1]\n const c = resolved.palette[2] ?? WEBGL_COLORS[2]\n return {\n backgroundImage: `linear-gradient(135deg, ${a}, ${b}, ${c})`,\n }\n }\n return { backgroundColor: resolved.body }\n}\n\n/**\n * Evenly lit sphere with two capsule eyes that follow the pointer.\n * The orb stays put — only the gaze moves.\n */\nexport function createLiveOrb(\n canvas: HTMLCanvasElement,\n initial: LiveOrbOptions = {}\n): LiveOrbInstance | null {\n let options: Required<\n Pick\n > &\n LiveOrbOptions = {\n variant: \"white\",\n interactive: true,\n blink: true,\n ...initial,\n }\n\n const look = { x: 0, y: 0.08 }\n const targetLook = { x: 0, y: 0.08 }\n let reduce = false\n\n const gl = canvas.getContext(\"webgl\", {\n alpha: true,\n antialias: false,\n depth: false,\n stencil: false,\n premultipliedAlpha: false,\n powerPreference: \"high-performance\",\n })\n if (!gl) return null\n\n const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)\n if (!vs || !fs) return null\n\n const program = gl.createProgram()\n if (!program) return null\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"LiveOrb: program failed to link\\n\",\n gl.getProgramInfoLog(program)\n )\n }\n return null\n }\n gl.useProgram(program)\n options.onHasGl?.(true)\n\n const buf = gl.createBuffer()\n gl.bindBuffer(gl.ARRAY_BUFFER, buf)\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n gl.STATIC_DRAW\n )\n const loc = gl.getAttribLocation(program, \"a_position\")\n gl.enableVertexAttribArray(loc)\n gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n\n const uResolution = gl.getUniformLocation(program, \"u_resolution\")\n const uTime = gl.getUniformLocation(program, \"u_time\")\n const uSpeed = gl.getUniformLocation(program, \"u_speed\")\n const uLook = gl.getUniformLocation(program, \"u_look\")\n const uBlink = gl.getUniformLocation(program, \"u_blink\")\n const uMode = gl.getUniformLocation(program, \"u_mode\")\n const uBody = gl.getUniformLocation(program, \"u_body\")\n const uEye = gl.getUniformLocation(program, \"u_eye\")\n const uC1 = gl.getUniformLocation(program, \"u_c1\")\n const uC2 = gl.getUniformLocation(program, \"u_c2\")\n const uC3 = gl.getUniformLocation(program, \"u_c3\")\n\n gl.enable(gl.BLEND)\n gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)\n\n let raf = 0\n let running = true\n const start = performance.now()\n let nextBlink = start + 1800 + Math.random() * 2400\n let blinkAt = -10_000\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w <= 0 || h <= 0) return\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n gl.viewport(0, 0, canvas.width, canvas.height)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const onMove = (e: PointerEvent) => {\n if (!options.interactive) return\n const parent = canvas.parentElement\n if (!parent) return\n const rect = parent.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2)\n const dy = (rect.top + rect.height / 2 - e.clientY) / (rect.height / 2)\n targetLook.x = Math.min(1, Math.max(-1, dx))\n targetLook.y = Math.min(1, Math.max(-1, dy))\n }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n const tick = (now: number) => {\n if (!running) return\n\n const resolved = resolveVariant(\n options.variant,\n options.color,\n options.eyeColor,\n options.colors\n )\n const time = (now - start) / 1000\n\n if (options.interactive) {\n look.x += (targetLook.x - look.x) * 0.16\n look.y += (targetLook.y - look.y) * 0.16\n } else {\n look.x += (0 - look.x) * 0.12\n look.y += (0.08 - look.y) * 0.12\n }\n\n if (!reduce && options.blink && now >= nextBlink) {\n blinkAt = now\n nextBlink = now + 2200 + Math.random() * 3800\n }\n const bt = (now - blinkAt) / 1000\n let b = 0\n if (!reduce && options.blink) {\n if (bt < 0.055) b = bt / 0.055\n else if (bt < 0.1) b = 1\n else if (bt < 0.18) b = 1 - (bt - 0.1) / 0.08\n }\n const body = hexToRgb(resolved.body)\n const eye = hexToRgb(resolved.eye)\n const palette = [0, 1, 2].map((i) =>\n hexToRgb(\n resolved.palette[i % resolved.palette.length] ?? WEBGL_COLORS[i]!\n )\n )\n\n gl.clearColor(0, 0, 0, 0)\n gl.clear(gl.COLOR_BUFFER_BIT)\n gl.uniform2f(uResolution, canvas.width, canvas.height)\n gl.uniform1f(uTime, reduce ? 0 : time)\n gl.uniform1f(uSpeed, reduce ? 0 : 0.55)\n gl.uniform2f(uLook, look.x, look.y)\n gl.uniform1f(uBlink, b)\n gl.uniform1f(uMode, resolved.mode)\n gl.uniform3f(uBody, body[0], body[1], body[2])\n gl.uniform3f(uEye, eye[0], eye[1], eye[2])\n gl.uniform3f(uC1, palette[0]![0], palette[0]![1], palette[0]![2])\n gl.uniform3f(uC2, palette[1]![0], palette[1]![1], palette[1]![2])\n gl.uniform3f(uC3, palette[2]![0], palette[2]![1], palette[2]![2])\n\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onMove)\n options.onHasGl?.(false)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n },\n }\n}\n\nexport type LiveOrbProps = Omit & {\n className?: string\n /** Edge length in CSS pixels. Default `280`. */\n size?: number\n}\n\n/**\n * Evenly lit sphere with two capsule eyes that follow the pointer.\n * The orb stays put — only the gaze moves.\n */\nexport function LiveOrb({\n className,\n size = 280,\n variant = \"white\",\n color,\n eyeColor,\n colors,\n interactive = true,\n blink = true,\n}: LiveOrbProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [hasGl, setHasGl] = useState(false)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createLiveOrb(canvas, {\n variant,\n color,\n eyeColor,\n colors,\n interactive,\n blink,\n onHasGl: setHasGl,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n variant,\n color,\n eyeColor,\n colors,\n interactive,\n blink,\n onHasGl: setHasGl,\n })\n }, [variant, color, eyeColor, colors, interactive, blink])\n\n const resolved = resolveVariant(variant, color, eyeColor, colors)\n\n return (\n \n {!hasGl ? (\n \n \n \n \n ) : null}\n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/live-orb.tsx" } diff --git a/public/r/logo-burst-svelte.json b/public/r/logo-burst-svelte.json new file mode 100644 index 0000000..99fff43 --- /dev/null +++ b/public/r/logo-burst-svelte.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "logo-burst-svelte", + "title": "Logo Burst", + "description": "Hair-line tentacles explode from a centered mark, then settle with tip particles — drop any logo in the middle.", + "files": [ + { + "path": "registry/logo-burst/logo-burst.svelte", + "content": "\n\n\n\n{#snippet mark()}\n {#if children}\n {@render children()}\n {:else}\n \n \n \n \n \n {/if}\n{/snippet}\n\n\n \n \n {#if replayOnClick}\n \n {@render mark()}\n \n {:else}\n
\n {@render mark()}\n
\n {/if}\n \n\n", + "type": "registry:file", + "target": "src/lib/components/ui/logo-burst.svelte" + } + ], + "type": "registry:ui" +} \ No newline at end of file diff --git a/public/r/logo-burst.json b/public/r/logo-burst.json new file mode 100644 index 0000000..eb80592 --- /dev/null +++ b/public/r/logo-burst.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "logo-burst", + "title": "Logo Burst", + "description": "Hair-line tentacles explode from a centered mark, then settle with tip particles — drop any logo in the middle.", + "files": [ + { + "path": "registry/logo-burst/logo-burst.tsx", + "content": "\"use client\"\n\nimport { useEffect, useRef, type ReactNode } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type LogoBurstOptions = {\n /** Hair-line count. Default `260`. */\n tentacleCount?: number\n /** Filament + particle color. Default `#D6D2CA`. */\n color?: string\n /**\n * Hole for the centered mark, in CSS pixels.\n * Lines start just under this disc so they tuck behind the logo.\n * Default `80`.\n */\n coreSize?: number\n /**\n * Outer reach as a fraction of the shorter canvas edge.\n * Default `0.48`.\n */\n radius?: number\n /** Burst duration in seconds. Default `1.35`. */\n duration?: number\n /** Deterministic tentacle layout. Default `23`. */\n seed?: number\n /** Share of tentacles that grow a tip particle (0–1). Default `0.62`. */\n particleRatio?: number\n}\n\nexport type LogoBurstInstance = {\n setOptions: (options: Partial) => void\n replay: () => void\n destroy: () => void\n}\n\nexport const DEFAULT_COLOR = \"#D6D2CA\"\nexport const DEFAULT_TENTACLE_COUNT = 260\nexport const DEFAULT_CORE_SIZE = 80\nexport const DEFAULT_RADIUS = 0.48\nexport const DEFAULT_DURATION = 1.35\nexport const DEFAULT_SEED = 23\nexport const DEFAULT_PARTICLE_RATIO = 0.62\n\ntype Tentacle = {\n angle: number\n length: number\n width: number\n alpha: number\n delay: number\n grow: number\n tip: boolean\n tipSize: number\n tipOvershoot: number\n mids: Array<{ t: number; size: number; alpha: number }>\n}\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction tentacleCountOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_TENTACLE_COUNT\n }\n return Math.round(clamp(value, 24, 900))\n}\n\nfunction radiusOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_RADIUS\n }\n return clamp(value, 0.12, 0.95)\n}\n\nfunction durationOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_DURATION\n }\n return clamp(value, 0.2, 6)\n}\n\nfunction coreSizeOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_CORE_SIZE\n }\n return clamp(value, 8, 480)\n}\n\nfunction seedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_SEED\n }\n return Math.floor(value)\n}\n\nfunction particleRatioOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_PARTICLE_RATIO\n }\n return clamp(value, 0, 1)\n}\n\nfunction colorOf(value?: string) {\n return value && value.trim() ? value.trim() : DEFAULT_COLOR\n}\n\nfunction parseRgb(color: string): [number, number, number] {\n const raw = color.trim()\n if (raw.startsWith(\"#\")) {\n const hex = raw.slice(1)\n const full =\n hex.length === 3\n ? hex\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : hex.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return [214, 210, 202]\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255]\n }\n const rgb = raw.match(/rgba?\\(\\s*([.\\d]+)\\s*,\\s*([.\\d]+)\\s*,\\s*([.\\d]+)/i)\n if (rgb) {\n return [\n Number.parseFloat(rgb[1]!),\n Number.parseFloat(rgb[2]!),\n Number.parseFloat(rgb[3]!),\n ]\n }\n return [214, 210, 202]\n}\n\nfunction rgba(rgb: [number, number, number], alpha: number) {\n return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(alpha, 0, 1)})`\n}\n\nfunction easeOutQuart(t: number) {\n const p = 1 - clamp(t, 0, 1)\n return 1 - p * p * p * p\n}\n\nfunction createTentacles(\n count: number,\n seed: number,\n particleRatio: number\n): Tentacle[] {\n const rand = mulberry32(seed)\n const tentacles: Tentacle[] = []\n\n for (let i = 0; i < count; i++) {\n const base = (i / count) * Math.PI * 2\n const angle = base + (rand() - 0.5) * (Math.PI * 2 * 0.018)\n const roll = rand()\n // Power curve: more mid-length rays, a few long outliers, some stubs.\n const length =\n roll < 0.12\n ? 0.22 + rand() * 0.18\n : roll < 0.78\n ? 0.48 + rand() * 0.32\n : 0.78 + rand() * 0.22\n const tip = rand() < particleRatio\n const midCount = rand() < 0.22 ? 2 : rand() < 0.48 ? 1 : 0\n const mids: Tentacle[\"mids\"] = []\n for (let m = 0; m < midCount; m++) {\n mids.push({\n t: 0.28 + rand() * 0.52,\n size: 0.55 + rand() * 0.7,\n alpha: 0.28 + rand() * 0.38,\n })\n }\n\n tentacles.push({\n angle,\n length: clamp(length, 0.16, 1),\n width: 0.35 + rand() * 0.85,\n alpha: 0.22 + rand() * 0.55,\n delay: rand() * 0.42 + (i / count) * 0.08,\n grow: 0.55 + rand() * 0.7,\n tip,\n tipSize: 0.85 + rand() * 1.35,\n tipOvershoot: tip && rand() < 0.35 ? 0.018 + rand() * 0.05 : 0,\n mids,\n })\n }\n\n return tentacles\n}\n\n/**\n * Hair-line tentacle burst — filaments explode from a centered mark, then\n * settle into a still starfield with tip particles. Transparent canvas;\n * put it on a dark surface.\n */\nexport function createLogoBurst(\n canvas: HTMLCanvasElement,\n initial: LogoBurstOptions = {}\n): LogoBurstInstance | null {\n let options: Required = {\n tentacleCount: tentacleCountOf(initial.tentacleCount),\n color: colorOf(initial.color),\n coreSize: coreSizeOf(initial.coreSize),\n radius: radiusOf(initial.radius),\n duration: durationOf(initial.duration),\n seed: seedOf(initial.seed),\n particleRatio: particleRatioOf(initial.particleRatio),\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n let tentacles = createTentacles(\n options.tentacleCount,\n options.seed,\n options.particleRatio\n )\n const size = { w: 0, h: 0 }\n let reduce = false\n let raf = 0\n let running = true\n let startedAt = performance.now()\n let settled = false\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n if (reduce) settled = true\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const progressOf = (now: number, tentacle: Tentacle) => {\n if (reduce || settled) return 1\n const elapsed = (now - startedAt) / 1000\n const span = options.duration * tentacle.grow\n const t = (elapsed - tentacle.delay * options.duration) / span\n return easeOutQuart(t)\n }\n\n const paint = (now: number) => {\n if (!running) return\n\n const { w, h } = size\n if (w === 0 || h === 0) {\n raf = requestAnimationFrame(paint)\n return\n }\n\n const cx = w / 2\n const cy = h / 2\n const maxR = Math.min(w, h) * options.radius\n const inner = Math.min(maxR * 0.92, options.coreSize * 0.42)\n const rgb = parseRgb(options.color)\n const twinkle = reduce ? 0 : Math.sin(now * 0.0016) * 0.08\n\n ctx.clearRect(0, 0, w, h)\n ctx.lineCap = \"round\"\n\n let allDone = true\n\n for (let i = 0; i < tentacles.length; i++) {\n const tentacle = tentacles[i]!\n const p = progressOf(now, tentacle)\n if (p < 1) allDone = false\n\n const reach = inner + (maxR - inner) * tentacle.length * p\n if (reach <= inner + 0.4) continue\n\n const cos = Math.cos(tentacle.angle)\n const sin = Math.sin(tentacle.angle)\n const x0 = cx + cos * inner\n const y0 = cy + sin * inner\n const x1 = cx + cos * reach\n const y1 = cy + sin * reach\n\n ctx.beginPath()\n ctx.moveTo(x0, y0)\n ctx.lineTo(x1, y1)\n ctx.strokeStyle = rgba(rgb, tentacle.alpha * (0.55 + p * 0.45))\n ctx.lineWidth = tentacle.width\n ctx.stroke()\n\n for (const mid of tentacle.mids) {\n if (p < mid.t) continue\n const mr = inner + (maxR - inner) * tentacle.length * mid.t\n ctx.beginPath()\n ctx.arc(cx + cos * mr, cy + sin * mr, mid.size, 0, Math.PI * 2)\n ctx.fillStyle = rgba(rgb, mid.alpha + twinkle * 0.4)\n ctx.fill()\n }\n\n if (tentacle.tip && p > 0.92) {\n const tipR =\n inner + (maxR - inner) * tentacle.length * (1 + tentacle.tipOvershoot)\n const appear = clamp((p - 0.92) / 0.08, 0, 1)\n ctx.beginPath()\n ctx.arc(\n cx + cos * tipR,\n cy + sin * tipR,\n tentacle.tipSize,\n 0,\n Math.PI * 2\n )\n ctx.fillStyle = rgba(rgb, (0.72 + twinkle) * appear)\n ctx.fill()\n }\n }\n\n if (!reduce && allDone) settled = true\n\n if (running && (!settled || !reduce)) {\n raf = requestAnimationFrame(paint)\n }\n }\n\n raf = requestAnimationFrame(paint)\n\n const replay = () => {\n if (reduce) {\n settled = true\n raf = requestAnimationFrame(paint)\n return\n }\n settled = false\n startedAt = performance.now()\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n\n return {\n setOptions(next) {\n const prevCount = options.tentacleCount\n const prevSeed = options.seed\n const prevRatio = options.particleRatio\n options = {\n tentacleCount: tentacleCountOf(\n next.tentacleCount ?? options.tentacleCount\n ),\n color: colorOf(next.color ?? options.color),\n coreSize: coreSizeOf(next.coreSize ?? options.coreSize),\n radius: radiusOf(next.radius ?? options.radius),\n duration: durationOf(next.duration ?? options.duration),\n seed: seedOf(next.seed ?? options.seed),\n particleRatio: particleRatioOf(\n next.particleRatio ?? options.particleRatio\n ),\n }\n const rebuilt =\n options.tentacleCount !== prevCount ||\n options.seed !== prevSeed ||\n options.particleRatio !== prevRatio\n if (rebuilt) {\n tentacles = createTentacles(\n options.tentacleCount,\n options.seed,\n options.particleRatio\n )\n replay()\n } else if (settled) {\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n },\n replay,\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n },\n }\n}\n\nexport type LogoBurstProps = LogoBurstOptions & {\n className?: string\n /** Centered mark. Defaults to a rounded chevron plate. */\n children?: ReactNode\n /**\n * Increment to replay the explosion. Mount already plays once.\n */\n replayKey?: number\n /** Click / Enter on the mark replays the burst. Default `true`. */\n replayOnClick?: boolean\n /** Accessible name. Default `\"Logo burst\"`. */\n label?: string\n}\n\n/** Default centered plate — swap via `children`. */\nexport function LogoBurstMark({ className }: { className?: string }) {\n return (\n \n \n \n \n \n )\n}\n\n/**\n * Hair-line tentacles explode from a centered mark, then settle with tip\n * particles. Transparent canvas — sit it on a dark surface.\n */\nexport function LogoBurst({\n className,\n children,\n tentacleCount = DEFAULT_TENTACLE_COUNT,\n color = DEFAULT_COLOR,\n coreSize,\n radius = DEFAULT_RADIUS,\n duration = DEFAULT_DURATION,\n seed = DEFAULT_SEED,\n particleRatio = DEFAULT_PARTICLE_RATIO,\n replayKey = 0,\n replayOnClick = true,\n label = \"Logo burst\",\n}: LogoBurstProps) {\n const canvasRef = useRef(null)\n const coreRef = useRef(null)\n const instanceRef = useRef(null)\n const replayKeyRef = useRef(replayKey)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createLogoBurst(canvas, {\n tentacleCount,\n color,\n coreSize: coreSize ?? DEFAULT_CORE_SIZE,\n radius,\n duration,\n seed,\n particleRatio,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n tentacleCount,\n color,\n coreSize,\n radius,\n duration,\n seed,\n particleRatio,\n })\n }, [tentacleCount, color, coreSize, radius, duration, seed, particleRatio])\n\n useEffect(() => {\n const core = coreRef.current\n if (!core || coreSize !== undefined) return\n const sync = () => {\n const box = core.getBoundingClientRect()\n const measured = Math.max(box.width, box.height)\n if (measured > 0) {\n instanceRef.current?.setOptions({ coreSize: measured })\n }\n }\n sync()\n const ro = new ResizeObserver(sync)\n ro.observe(core)\n return () => ro.disconnect()\n }, [coreSize, children])\n\n useEffect(() => {\n if (replayKeyRef.current === replayKey) return\n replayKeyRef.current = replayKey\n instanceRef.current?.replay()\n }, [replayKey])\n\n function replay() {\n instanceRef.current?.replay()\n }\n\n const mark = children ?? \n\n return (\n \n \n
\n {replayOnClick ? (\n {\n coreRef.current = node\n }}\n type=\"button\"\n aria-label=\"Replay burst\"\n onClick={replay}\n className=\"pointer-events-auto relative cursor-pointer rounded-[22%] border-0 bg-transparent p-0 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none\"\n >\n {mark}\n \n ) : (\n {\n coreRef.current = node\n }}\n className=\"pointer-events-auto relative\"\n >\n {mark}\n
\n )}\n \n \n )\n}\n", + "type": "registry:ui", + "target": "components/ui/logo-burst.tsx" + } + ], + "type": "registry:ui" +} \ No newline at end of file diff --git a/public/r/registry.json b/public/r/registry.json index 1709411..956bbf6 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -137,6 +137,32 @@ ], "type": "registry:ui" }, + { + "name": "logo-burst", + "title": "Logo Burst", + "description": "Hair-line tentacles explode from a centered mark, then settle with tip particles — drop any logo in the middle.", + "files": [ + { + "path": "registry/logo-burst/logo-burst.tsx", + "type": "registry:ui", + "target": "components/ui/logo-burst.tsx" + } + ], + "type": "registry:ui" + }, + { + "name": "logo-burst-svelte", + "title": "Logo Burst", + "description": "Hair-line tentacles explode from a centered mark, then settle with tip particles — drop any logo in the middle.", + "files": [ + { + "path": "registry/logo-burst/logo-burst.svelte", + "type": "registry:file", + "target": "src/lib/components/ui/logo-burst.svelte" + } + ], + "type": "registry:ui" + }, { "name": "radiant-lines", "title": "Radiant Lines", diff --git a/public/r/stretchy-footer-svelte.json b/public/r/stretchy-footer-svelte.json index d111e36..275dcbc 100644 --- a/public/r/stretchy-footer-svelte.json +++ b/public/r/stretchy-footer-svelte.json @@ -7,7 +7,7 @@ "files": [ { "path": "registry/stretchy-footer/stretchy-footer.svelte", - "content": "\n\n\n\n{#snippet aurora()}\n \n \n \n {#each bars as bar (bar.key)}\n \n {/each}\n \n\n \n \n \n{/snippet}\n\n{#if windowScroll}\n {#if mounted}\n \n {@render aurora()}\n \n {/if}\n{:else if scrollEl}\n \n {@render aurora()}\n \n{:else}\n \n \n {@render children?.()}\n \n {@render aurora()}\n \n{/if}\n", + "content": "\n\n\n\n{#snippet aurora()}\n \n \n \n {#each bars as bar (bar.key)}\n \n {/each}\n \n\n \n \n \n{/snippet}\n\n{#if windowScroll}\n {#if mounted}\n \n {@render aurora()}\n \n {/if}\n{:else if scrollEl}\n \n {@render aurora()}\n \n{:else}\n \n \n {@render children?.()}\n \n {@render aurora()}\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 \n {aurora}\n \n )\n }\n\n return (\n \n \n {children}\n \n {aurora}\n \n )\n}\n", "type": "registry:ui", "target": "components/ui/stretchy-footer.tsx" } diff --git a/registry.json b/registry.json index d405f01..772b587 100644 --- a/registry.json +++ b/registry.json @@ -8,6 +8,7 @@ "registry/dithered-404/registry.json", "registry/gooey-color-picker/registry.json", "registry/live-orb/registry.json", + "registry/logo-burst/registry.json", "registry/radiant-lines/registry.json", "registry/shader-fire/registry.json", "registry/shader-gradient/registry.json", diff --git a/registry/logo-burst/logo-burst-demo.tsx b/registry/logo-burst/logo-burst-demo.tsx new file mode 100644 index 0000000..1e4216e --- /dev/null +++ b/registry/logo-burst/logo-burst-demo.tsx @@ -0,0 +1,141 @@ +"use client" + +import { useMemo, useState } from "react" + +import { Button } from "@/components/ui/button" +import { + ComponentControls, + ControlColor, + ControlSlider, + ControlSwitch, +} from "@/components/component-controls" +import { ComponentPreview } from "@/components/component-preview" +import { usePreviewProps } from "@/hooks/use-preview-props" +import { + DEFAULT_COLOR, + DEFAULT_DURATION, + DEFAULT_PARTICLE_RATIO, + DEFAULT_RADIUS, + DEFAULT_TENTACLE_COUNT, + LogoBurst, +} from "@/registry/logo-burst/logo-burst" + +const STAGE = "#050506" + +export function LogoBurstDemo() { + const defaults = useMemo( + () => ({ + tentacleCount: DEFAULT_TENTACLE_COUNT, + color: DEFAULT_COLOR, + radius: DEFAULT_RADIUS, + duration: DEFAULT_DURATION, + particleRatio: DEFAULT_PARTICLE_RATIO, + replayOnClick: true, + }), + [] + ) + + const { props, updateProp, resetProps, hasChanges } = + usePreviewProps(defaults) + const [replayKey, setReplayKey] = useState(0) + + return ( + <> + +
+ +
+
+ + +
+ Burst + +
+ updateProp("color", v)} + /> + updateProp("tentacleCount", v)} + /> + updateProp("radius", v)} + /> + updateProp("duration", v)} + /> + updateProp("particleRatio", v)} + /> + updateProp("replayOnClick", v)} + /> +
+ + ) +} diff --git a/registry/logo-burst/logo-burst-vanilla.ts b/registry/logo-burst/logo-burst-vanilla.ts new file mode 100644 index 0000000..6fe6e06 --- /dev/null +++ b/registry/logo-burst/logo-burst-vanilla.ts @@ -0,0 +1,396 @@ +export type LogoBurstOptions = { + /** Hair-line count. Default `260`. */ + tentacleCount?: number + /** Filament + particle color. Default `#D6D2CA`. */ + color?: string + /** + * Hole for the centered mark, in CSS pixels. + * Lines start just under this disc so they tuck behind the logo. + * Default `80`. + */ + coreSize?: number + /** + * Outer reach as a fraction of the shorter canvas edge. + * Default `0.48`. + */ + radius?: number + /** Burst duration in seconds. Default `1.35`. */ + duration?: number + /** Deterministic tentacle layout. Default `23`. */ + seed?: number + /** Share of tentacles that grow a tip particle (0–1). Default `0.62`. */ + particleRatio?: number +} + +export type LogoBurstInstance = { + setOptions: (options: Partial) => void + replay: () => void + destroy: () => void +} + +export const DEFAULT_COLOR = "#D6D2CA" +export const DEFAULT_TENTACLE_COUNT = 260 +export const DEFAULT_CORE_SIZE = 80 +export const DEFAULT_RADIUS = 0.48 +export const DEFAULT_DURATION = 1.35 +export const DEFAULT_SEED = 23 +export const DEFAULT_PARTICLE_RATIO = 0.62 + +type Tentacle = { + angle: number + length: number + width: number + alpha: number + delay: number + grow: number + tip: boolean + tipSize: number + tipOvershoot: number + mids: Array<{ t: number; size: number; alpha: number }> +} + +function mulberry32(seed: number) { + let a = seed >>> 0 + return () => { + a |= 0 + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function tentacleCountOf(value?: number) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return DEFAULT_TENTACLE_COUNT + } + return Math.round(clamp(value, 24, 900)) +} + +function radiusOf(value?: number) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return DEFAULT_RADIUS + } + return clamp(value, 0.12, 0.95) +} + +function durationOf(value?: number) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return DEFAULT_DURATION + } + return clamp(value, 0.2, 6) +} + +function coreSizeOf(value?: number) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return DEFAULT_CORE_SIZE + } + return clamp(value, 8, 480) +} + +function seedOf(value?: number) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return DEFAULT_SEED + } + return Math.floor(value) +} + +function particleRatioOf(value?: number) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return DEFAULT_PARTICLE_RATIO + } + return clamp(value, 0, 1) +} + +function colorOf(value?: string) { + return value && value.trim() ? value.trim() : DEFAULT_COLOR +} + +function parseRgb(color: string): [number, number, number] { + const raw = color.trim() + if (raw.startsWith("#")) { + const hex = raw.slice(1) + const full = + hex.length === 3 + ? hex + .split("") + .map((c) => c + c) + .join("") + : hex.padEnd(6, "0").slice(0, 6) + const n = Number.parseInt(full, 16) + if (Number.isNaN(n)) return [214, 210, 202] + return [(n >> 16) & 255, (n >> 8) & 255, n & 255] + } + const rgb = raw.match(/rgba?\(\s*([.\d]+)\s*,\s*([.\d]+)\s*,\s*([.\d]+)/i) + if (rgb) { + return [ + Number.parseFloat(rgb[1]!), + Number.parseFloat(rgb[2]!), + Number.parseFloat(rgb[3]!), + ] + } + return [214, 210, 202] +} + +function rgba(rgb: [number, number, number], alpha: number) { + return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(alpha, 0, 1)})` +} + +function easeOutQuart(t: number) { + const p = 1 - clamp(t, 0, 1) + return 1 - p * p * p * p +} + +function createTentacles( + count: number, + seed: number, + particleRatio: number +): Tentacle[] { + const rand = mulberry32(seed) + const tentacles: Tentacle[] = [] + + for (let i = 0; i < count; i++) { + const base = (i / count) * Math.PI * 2 + const angle = base + (rand() - 0.5) * (Math.PI * 2 * 0.018) + const roll = rand() + // Power curve: more mid-length rays, a few long outliers, some stubs. + const length = + roll < 0.12 + ? 0.22 + rand() * 0.18 + : roll < 0.78 + ? 0.48 + rand() * 0.32 + : 0.78 + rand() * 0.22 + const tip = rand() < particleRatio + const midCount = rand() < 0.22 ? 2 : rand() < 0.48 ? 1 : 0 + const mids: Tentacle["mids"] = [] + for (let m = 0; m < midCount; m++) { + mids.push({ + t: 0.28 + rand() * 0.52, + size: 0.55 + rand() * 0.7, + alpha: 0.28 + rand() * 0.38, + }) + } + + tentacles.push({ + angle, + length: clamp(length, 0.16, 1), + width: 0.35 + rand() * 0.85, + alpha: 0.22 + rand() * 0.55, + delay: rand() * 0.42 + (i / count) * 0.08, + grow: 0.55 + rand() * 0.7, + tip, + tipSize: 0.85 + rand() * 1.35, + tipOvershoot: tip && rand() < 0.35 ? 0.018 + rand() * 0.05 : 0, + mids, + }) + } + + return tentacles +} + +/** + * Hair-line tentacle burst — filaments explode from a centered mark, then + * settle into a still starfield with tip particles. Transparent canvas; + * put it on a dark surface. + */ +export function createLogoBurst( + canvas: HTMLCanvasElement, + initial: LogoBurstOptions = {} +): LogoBurstInstance | null { + let options: Required = { + tentacleCount: tentacleCountOf(initial.tentacleCount), + color: colorOf(initial.color), + coreSize: coreSizeOf(initial.coreSize), + radius: radiusOf(initial.radius), + duration: durationOf(initial.duration), + seed: seedOf(initial.seed), + particleRatio: particleRatioOf(initial.particleRatio), + } + + const ctx = canvas.getContext("2d", { alpha: true }) + if (!ctx) return null + + let tentacles = createTentacles( + options.tentacleCount, + options.seed, + options.particleRatio + ) + const size = { w: 0, h: 0 } + let reduce = false + let raf = 0 + let running = true + let startedAt = performance.now() + let settled = false + + const resize = () => { + const parent = canvas.parentElement + if (!parent) return + const dpr = Math.min(window.devicePixelRatio || 1, 2) + const w = parent.clientWidth + const h = parent.clientHeight + size.w = w + size.h = h + canvas.width = Math.floor(w * dpr) + canvas.height = Math.floor(h * dpr) + canvas.style.width = `${w}px` + canvas.style.height = `${h}px` + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + } + + resize() + const ro = new ResizeObserver(resize) + if (canvas.parentElement) ro.observe(canvas.parentElement) + + const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") + const onReduce = () => { + reduce = mqReduce.matches + if (reduce) settled = true + } + onReduce() + mqReduce.addEventListener("change", onReduce) + + const progressOf = (now: number, tentacle: Tentacle) => { + if (reduce || settled) return 1 + const elapsed = (now - startedAt) / 1000 + const span = options.duration * tentacle.grow + const t = (elapsed - tentacle.delay * options.duration) / span + return easeOutQuart(t) + } + + const paint = (now: number) => { + if (!running) return + + const { w, h } = size + if (w === 0 || h === 0) { + raf = requestAnimationFrame(paint) + return + } + + const cx = w / 2 + const cy = h / 2 + const maxR = Math.min(w, h) * options.radius + const inner = Math.min(maxR * 0.92, options.coreSize * 0.42) + const rgb = parseRgb(options.color) + const twinkle = reduce ? 0 : Math.sin(now * 0.0016) * 0.08 + + ctx.clearRect(0, 0, w, h) + ctx.lineCap = "round" + + let allDone = true + + for (let i = 0; i < tentacles.length; i++) { + const tentacle = tentacles[i]! + const p = progressOf(now, tentacle) + if (p < 1) allDone = false + + const reach = inner + (maxR - inner) * tentacle.length * p + if (reach <= inner + 0.4) continue + + const cos = Math.cos(tentacle.angle) + const sin = Math.sin(tentacle.angle) + const x0 = cx + cos * inner + const y0 = cy + sin * inner + const x1 = cx + cos * reach + const y1 = cy + sin * reach + + ctx.beginPath() + ctx.moveTo(x0, y0) + ctx.lineTo(x1, y1) + ctx.strokeStyle = rgba(rgb, tentacle.alpha * (0.55 + p * 0.45)) + ctx.lineWidth = tentacle.width + ctx.stroke() + + for (const mid of tentacle.mids) { + if (p < mid.t) continue + const mr = inner + (maxR - inner) * tentacle.length * mid.t + ctx.beginPath() + ctx.arc(cx + cos * mr, cy + sin * mr, mid.size, 0, Math.PI * 2) + ctx.fillStyle = rgba(rgb, mid.alpha + twinkle * 0.4) + ctx.fill() + } + + if (tentacle.tip && p > 0.92) { + const tipR = + inner + (maxR - inner) * tentacle.length * (1 + tentacle.tipOvershoot) + const appear = clamp((p - 0.92) / 0.08, 0, 1) + ctx.beginPath() + ctx.arc( + cx + cos * tipR, + cy + sin * tipR, + tentacle.tipSize, + 0, + Math.PI * 2 + ) + ctx.fillStyle = rgba(rgb, (0.72 + twinkle) * appear) + ctx.fill() + } + } + + if (!reduce && allDone) settled = true + + if (running && (!settled || !reduce)) { + raf = requestAnimationFrame(paint) + } + } + + raf = requestAnimationFrame(paint) + + const replay = () => { + if (reduce) { + settled = true + raf = requestAnimationFrame(paint) + return + } + settled = false + startedAt = performance.now() + cancelAnimationFrame(raf) + raf = requestAnimationFrame(paint) + } + + return { + setOptions(next) { + const prevCount = options.tentacleCount + const prevSeed = options.seed + const prevRatio = options.particleRatio + options = { + tentacleCount: tentacleCountOf( + next.tentacleCount ?? options.tentacleCount + ), + color: colorOf(next.color ?? options.color), + coreSize: coreSizeOf(next.coreSize ?? options.coreSize), + radius: radiusOf(next.radius ?? options.radius), + duration: durationOf(next.duration ?? options.duration), + seed: seedOf(next.seed ?? options.seed), + particleRatio: particleRatioOf( + next.particleRatio ?? options.particleRatio + ), + } + const rebuilt = + options.tentacleCount !== prevCount || + options.seed !== prevSeed || + options.particleRatio !== prevRatio + if (rebuilt) { + tentacles = createTentacles( + options.tentacleCount, + options.seed, + options.particleRatio + ) + replay() + } else if (settled) { + cancelAnimationFrame(raf) + raf = requestAnimationFrame(paint) + } + }, + replay, + destroy() { + running = false + cancelAnimationFrame(raf) + ro.disconnect() + mqReduce.removeEventListener("change", onReduce) + }, + } +} diff --git a/registry/logo-burst/logo-burst.svelte b/registry/logo-burst/logo-burst.svelte new file mode 100644 index 0000000..41bf8c1 --- /dev/null +++ b/registry/logo-burst/logo-burst.svelte @@ -0,0 +1,165 @@ + + + + +{#snippet mark()} + {#if children} + {@render children()} + {:else} + + + + {/if} +{/snippet} + +
+ +
+ {#if replayOnClick} + + {:else} +
+ {@render mark()} +
+ {/if} +
+
diff --git a/registry/logo-burst/logo-burst.tsx b/registry/logo-burst/logo-burst.tsx new file mode 100644 index 0000000..3937e84 --- /dev/null +++ b/registry/logo-burst/logo-burst.tsx @@ -0,0 +1,186 @@ +"use client" + +import { useEffect, useRef, type ReactNode } from "react" + +import { cn } from "@/lib/utils" + +import { + createLogoBurst, + DEFAULT_COLOR, + DEFAULT_CORE_SIZE, + DEFAULT_DURATION, + DEFAULT_PARTICLE_RATIO, + DEFAULT_RADIUS, + DEFAULT_SEED, + DEFAULT_TENTACLE_COUNT, + type LogoBurstInstance, + type LogoBurstOptions, +} from "./logo-burst-vanilla" + +export { + DEFAULT_COLOR, + DEFAULT_CORE_SIZE, + DEFAULT_DURATION, + DEFAULT_PARTICLE_RATIO, + DEFAULT_RADIUS, + DEFAULT_SEED, + DEFAULT_TENTACLE_COUNT, +} from "./logo-burst-vanilla" +export type { LogoBurstInstance, LogoBurstOptions } from "./logo-burst-vanilla" + +export type LogoBurstProps = LogoBurstOptions & { + className?: string + /** Centered mark. Defaults to a rounded chevron plate. */ + children?: ReactNode + /** + * Increment to replay the explosion. Mount already plays once. + */ + replayKey?: number + /** Click / Enter on the mark replays the burst. Default `true`. */ + replayOnClick?: boolean + /** Accessible name. Default `"Logo burst"`. */ + label?: string +} + +/** Default centered plate — swap via `children`. */ +export function LogoBurstMark({ className }: { className?: string }) { + return ( + + + + + + ) +} + +/** + * Hair-line tentacles explode from a centered mark, then settle with tip + * particles. Transparent canvas — sit it on a dark surface. + */ +export function LogoBurst({ + className, + children, + tentacleCount = DEFAULT_TENTACLE_COUNT, + color = DEFAULT_COLOR, + coreSize, + radius = DEFAULT_RADIUS, + duration = DEFAULT_DURATION, + seed = DEFAULT_SEED, + particleRatio = DEFAULT_PARTICLE_RATIO, + replayKey = 0, + replayOnClick = true, + label = "Logo burst", +}: LogoBurstProps) { + const canvasRef = useRef(null) + const coreRef = useRef(null) + const instanceRef = useRef(null) + const replayKeyRef = useRef(replayKey) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + instanceRef.current = createLogoBurst(canvas, { + tentacleCount, + color, + coreSize: coreSize ?? DEFAULT_CORE_SIZE, + radius, + duration, + seed, + particleRatio, + }) + return () => { + instanceRef.current?.destroy() + instanceRef.current = null + } + // Engine reads live options via setOptions; mount once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + instanceRef.current?.setOptions({ + tentacleCount, + color, + coreSize, + radius, + duration, + seed, + particleRatio, + }) + }, [tentacleCount, color, coreSize, radius, duration, seed, particleRatio]) + + useEffect(() => { + const core = coreRef.current + if (!core || coreSize !== undefined) return + const sync = () => { + const box = core.getBoundingClientRect() + const measured = Math.max(box.width, box.height) + if (measured > 0) { + instanceRef.current?.setOptions({ coreSize: measured }) + } + } + sync() + const ro = new ResizeObserver(sync) + ro.observe(core) + return () => ro.disconnect() + }, [coreSize, children]) + + useEffect(() => { + if (replayKeyRef.current === replayKey) return + replayKeyRef.current = replayKey + instanceRef.current?.replay() + }, [replayKey]) + + function replay() { + instanceRef.current?.replay() + } + + const mark = children ?? + + return ( +
+ +
+ {replayOnClick ? ( + + ) : ( +
{ + coreRef.current = node + }} + className="pointer-events-auto relative" + > + {mark} +
+ )} +
+
+ ) +} diff --git a/registry/logo-burst/registry.json b/registry/logo-burst/registry.json new file mode 100644 index 0000000..d25d7e5 --- /dev/null +++ b/registry/logo-burst/registry.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "items": [ + { + "name": "logo-burst", + "type": "registry:ui", + "title": "Logo Burst", + "description": "Hair-line tentacles explode from a centered mark, then settle with tip particles — drop any logo in the middle.", + "files": [ + { + "path": "logo-burst.tsx", + "type": "registry:ui", + "target": "components/ui/logo-burst.tsx" + } + ] + }, + { + "name": "logo-burst-svelte", + "type": "registry:ui", + "title": "Logo Burst", + "description": "Hair-line tentacles explode from a centered mark, then settle with tip particles — drop any logo in the middle.", + "files": [ + { + "path": "logo-burst.svelte", + "type": "registry:file", + "target": "src/lib/components/ui/logo-burst.svelte" + } + ] + } + ] +} From a61e9ca12d31c9e295bc86fe2956356c6c43fec7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 12:02:38 +0000 Subject: [PATCH 2/4] feat: breathe idle motion and light/dark for Logo Burst After the explosion settles, filaments keep a slow inhale. Theme auto follows html.dark so ink/bone filaments and the plate work on both bg-background surfaces. Co-authored-by: Jay Sharma --- README.md | 2 +- content/docs/components/logo-burst.mdx | 50 ++++--- content/docs/index.mdx | 2 +- public/r/logo-burst-svelte.json | 4 +- public/r/logo-burst.json | 4 +- public/r/registry.json | 4 +- registry/logo-burst/logo-burst-demo.tsx | 52 +++++-- registry/logo-burst/logo-burst-vanilla.ts | 170 +++++++++++++++++----- registry/logo-burst/logo-burst.svelte | 13 +- registry/logo-burst/logo-burst.tsx | 41 ++++-- registry/logo-burst/registry.json | 4 +- 11 files changed, 250 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index 7cfd0a8..9992a0c 100644 --- a/README.md +++ b/README.md @@ -77,7 +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 a centered mark, then settle with tip particles | +| [Logo Burst](https://23rd.dev/docs/components/logo-burst) | `@23rd/logo-burst` | Hair-line tentacles explode from a mark, 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 index 0a9520a..b89e8e3 100644 --- a/content/docs/components/logo-burst.mdx +++ b/content/docs/components/logo-burst.mdx @@ -1,11 +1,11 @@ --- title: Logo Burst -description: Hair-line tentacles explode from a centered mark, then settle with tip particles — drop any logo in the middle. +description: Hair-line tentacles explode from a centered mark, then keep a slow inhale — drop any logo in the middle. 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, and the mark stays put. Click the plate — or bump `replayKey` — to explode again. Sit it on a dark surface. +A brand-hero burst. Thin filaments detonate out of the center, tip particles lock in, then the field breathes. Click the plate — or bump `replayKey` — to explode again. Default `theme="auto"` follows the site. Press `d` to toggle. @@ -19,7 +19,7 @@ Or install straight from the public GitHub source registry: ## Usage -Give the parent a size and a dark background. The canvas is transparent. Pass children to swap the default chevron plate. +Give the parent a size. The canvas is transparent over `bg-background`. Pass children to swap the default chevron plate. @@ -30,7 +30,7 @@ import { LogoBurst } from "@/components/ui/logo-burst" export function HeroMark() { return ( -
+
@@ -45,7 +45,7 @@ export function HeroMark() { import LogoBurst from "$lib/components/ui/logo-burst.svelte" -
+
@@ -70,7 +70,7 @@ export function Replayable() { const [key, setKey] = useState(0) return ( -
+
@@ -98,22 +98,26 @@ Replay -`prefers-reduced-motion: reduce` draws the settled field and skips the explosion. +`prefers-reduced-motion: reduce` draws the settled field and skips the explosion and the inhale. ## Props -| Prop | Type | Default | -| --------------- | ----------------------- | ---------------------------------------- | -| `children` | `ReactNode` / `Snippet` | chevron plate | -| `tentacleCount` | `number` | `260` | -| `color` | `string` | `#D6D2CA` | -| `coreSize` | `number` | measured from the mark | -| `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 | +| Prop | Type | Default | +| --------------- | ----------------------------- | ---------------------------------------- | +| `children` | `ReactNode` / `Snippet` | chevron plate | +| `tentacleCount` | `number` | `260` | +| `color` | `string` | theme ink / bone | +| `theme` | `"light" \| "dark" \| "auto"` | `"auto"` | +| `breathe` | `boolean` | `true` | +| `coreSize` | `number` | measured from the mark | +| `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/index.mdx b/content/docs/index.mdx index a5c3714..67e2c2f 100644 --- a/content/docs/index.mdx +++ b/content/docs/index.mdx @@ -21,6 +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 a mark +- [Logo Burst](/docs/components/logo-burst) — hair-line tentacles explode from a mark, 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/logo-burst-svelte.json b/public/r/logo-burst-svelte.json index 99fff43..6c42c7a 100644 --- a/public/r/logo-burst-svelte.json +++ b/public/r/logo-burst-svelte.json @@ -2,11 +2,11 @@ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "logo-burst-svelte", "title": "Logo Burst", - "description": "Hair-line tentacles explode from a centered mark, then settle with tip particles — drop any logo in the middle.", + "description": "Hair-line tentacles explode from a centered mark, then keep a slow inhale — drop any logo in the middle. Follows light and dark.", "files": [ { "path": "registry/logo-burst/logo-burst.svelte", - "content": "\n\n\n\n{#snippet mark()}\n {#if children}\n {@render children()}\n {:else}\n \n \n \n \n \n {/if}\n{/snippet}\n\n\n \n \n {#if replayOnClick}\n \n {@render mark()}\n \n {:else}\n
\n {@render mark()}\n
\n {/if}\n
\n
\n", + "content": "\n\n\n\n{#snippet mark()}\n {#if children}\n {@render children()}\n {:else}\n \n \n \n \n \n {/if}\n{/snippet}\n\n\n \n \n {#if replayOnClick}\n \n {@render mark()}\n \n {:else}\n
\n {@render mark()}\n
\n {/if}\n
\n
\n", "type": "registry:file", "target": "src/lib/components/ui/logo-burst.svelte" } diff --git a/public/r/logo-burst.json b/public/r/logo-burst.json index eb80592..b56c3b6 100644 --- a/public/r/logo-burst.json +++ b/public/r/logo-burst.json @@ -2,11 +2,11 @@ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "logo-burst", "title": "Logo Burst", - "description": "Hair-line tentacles explode from a centered mark, then settle with tip particles — drop any logo in the middle.", + "description": "Hair-line tentacles explode from a centered mark, then keep a slow inhale — drop any logo in the middle. Follows light and dark.", "files": [ { "path": "registry/logo-burst/logo-burst.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, type ReactNode } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type LogoBurstOptions = {\n /** Hair-line count. Default `260`. */\n tentacleCount?: number\n /** Filament + particle color. Default `#D6D2CA`. */\n color?: string\n /**\n * Hole for the centered mark, in CSS pixels.\n * Lines start just under this disc so they tuck behind the logo.\n * Default `80`.\n */\n coreSize?: number\n /**\n * Outer reach as a fraction of the shorter canvas edge.\n * Default `0.48`.\n */\n radius?: number\n /** Burst duration in seconds. Default `1.35`. */\n duration?: number\n /** Deterministic tentacle layout. Default `23`. */\n seed?: number\n /** Share of tentacles that grow a tip particle (0–1). Default `0.62`. */\n particleRatio?: number\n}\n\nexport type LogoBurstInstance = {\n setOptions: (options: Partial) => void\n replay: () => void\n destroy: () => void\n}\n\nexport const DEFAULT_COLOR = \"#D6D2CA\"\nexport const DEFAULT_TENTACLE_COUNT = 260\nexport const DEFAULT_CORE_SIZE = 80\nexport const DEFAULT_RADIUS = 0.48\nexport const DEFAULT_DURATION = 1.35\nexport const DEFAULT_SEED = 23\nexport const DEFAULT_PARTICLE_RATIO = 0.62\n\ntype Tentacle = {\n angle: number\n length: number\n width: number\n alpha: number\n delay: number\n grow: number\n tip: boolean\n tipSize: number\n tipOvershoot: number\n mids: Array<{ t: number; size: number; alpha: number }>\n}\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction tentacleCountOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_TENTACLE_COUNT\n }\n return Math.round(clamp(value, 24, 900))\n}\n\nfunction radiusOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_RADIUS\n }\n return clamp(value, 0.12, 0.95)\n}\n\nfunction durationOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_DURATION\n }\n return clamp(value, 0.2, 6)\n}\n\nfunction coreSizeOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_CORE_SIZE\n }\n return clamp(value, 8, 480)\n}\n\nfunction seedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_SEED\n }\n return Math.floor(value)\n}\n\nfunction particleRatioOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_PARTICLE_RATIO\n }\n return clamp(value, 0, 1)\n}\n\nfunction colorOf(value?: string) {\n return value && value.trim() ? value.trim() : DEFAULT_COLOR\n}\n\nfunction parseRgb(color: string): [number, number, number] {\n const raw = color.trim()\n if (raw.startsWith(\"#\")) {\n const hex = raw.slice(1)\n const full =\n hex.length === 3\n ? hex\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : hex.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return [214, 210, 202]\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255]\n }\n const rgb = raw.match(/rgba?\\(\\s*([.\\d]+)\\s*,\\s*([.\\d]+)\\s*,\\s*([.\\d]+)/i)\n if (rgb) {\n return [\n Number.parseFloat(rgb[1]!),\n Number.parseFloat(rgb[2]!),\n Number.parseFloat(rgb[3]!),\n ]\n }\n return [214, 210, 202]\n}\n\nfunction rgba(rgb: [number, number, number], alpha: number) {\n return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(alpha, 0, 1)})`\n}\n\nfunction easeOutQuart(t: number) {\n const p = 1 - clamp(t, 0, 1)\n return 1 - p * p * p * p\n}\n\nfunction createTentacles(\n count: number,\n seed: number,\n particleRatio: number\n): Tentacle[] {\n const rand = mulberry32(seed)\n const tentacles: Tentacle[] = []\n\n for (let i = 0; i < count; i++) {\n const base = (i / count) * Math.PI * 2\n const angle = base + (rand() - 0.5) * (Math.PI * 2 * 0.018)\n const roll = rand()\n // Power curve: more mid-length rays, a few long outliers, some stubs.\n const length =\n roll < 0.12\n ? 0.22 + rand() * 0.18\n : roll < 0.78\n ? 0.48 + rand() * 0.32\n : 0.78 + rand() * 0.22\n const tip = rand() < particleRatio\n const midCount = rand() < 0.22 ? 2 : rand() < 0.48 ? 1 : 0\n const mids: Tentacle[\"mids\"] = []\n for (let m = 0; m < midCount; m++) {\n mids.push({\n t: 0.28 + rand() * 0.52,\n size: 0.55 + rand() * 0.7,\n alpha: 0.28 + rand() * 0.38,\n })\n }\n\n tentacles.push({\n angle,\n length: clamp(length, 0.16, 1),\n width: 0.35 + rand() * 0.85,\n alpha: 0.22 + rand() * 0.55,\n delay: rand() * 0.42 + (i / count) * 0.08,\n grow: 0.55 + rand() * 0.7,\n tip,\n tipSize: 0.85 + rand() * 1.35,\n tipOvershoot: tip && rand() < 0.35 ? 0.018 + rand() * 0.05 : 0,\n mids,\n })\n }\n\n return tentacles\n}\n\n/**\n * Hair-line tentacle burst — filaments explode from a centered mark, then\n * settle into a still starfield with tip particles. Transparent canvas;\n * put it on a dark surface.\n */\nexport function createLogoBurst(\n canvas: HTMLCanvasElement,\n initial: LogoBurstOptions = {}\n): LogoBurstInstance | null {\n let options: Required = {\n tentacleCount: tentacleCountOf(initial.tentacleCount),\n color: colorOf(initial.color),\n coreSize: coreSizeOf(initial.coreSize),\n radius: radiusOf(initial.radius),\n duration: durationOf(initial.duration),\n seed: seedOf(initial.seed),\n particleRatio: particleRatioOf(initial.particleRatio),\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n let tentacles = createTentacles(\n options.tentacleCount,\n options.seed,\n options.particleRatio\n )\n const size = { w: 0, h: 0 }\n let reduce = false\n let raf = 0\n let running = true\n let startedAt = performance.now()\n let settled = false\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n if (reduce) settled = true\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const progressOf = (now: number, tentacle: Tentacle) => {\n if (reduce || settled) return 1\n const elapsed = (now - startedAt) / 1000\n const span = options.duration * tentacle.grow\n const t = (elapsed - tentacle.delay * options.duration) / span\n return easeOutQuart(t)\n }\n\n const paint = (now: number) => {\n if (!running) return\n\n const { w, h } = size\n if (w === 0 || h === 0) {\n raf = requestAnimationFrame(paint)\n return\n }\n\n const cx = w / 2\n const cy = h / 2\n const maxR = Math.min(w, h) * options.radius\n const inner = Math.min(maxR * 0.92, options.coreSize * 0.42)\n const rgb = parseRgb(options.color)\n const twinkle = reduce ? 0 : Math.sin(now * 0.0016) * 0.08\n\n ctx.clearRect(0, 0, w, h)\n ctx.lineCap = \"round\"\n\n let allDone = true\n\n for (let i = 0; i < tentacles.length; i++) {\n const tentacle = tentacles[i]!\n const p = progressOf(now, tentacle)\n if (p < 1) allDone = false\n\n const reach = inner + (maxR - inner) * tentacle.length * p\n if (reach <= inner + 0.4) continue\n\n const cos = Math.cos(tentacle.angle)\n const sin = Math.sin(tentacle.angle)\n const x0 = cx + cos * inner\n const y0 = cy + sin * inner\n const x1 = cx + cos * reach\n const y1 = cy + sin * reach\n\n ctx.beginPath()\n ctx.moveTo(x0, y0)\n ctx.lineTo(x1, y1)\n ctx.strokeStyle = rgba(rgb, tentacle.alpha * (0.55 + p * 0.45))\n ctx.lineWidth = tentacle.width\n ctx.stroke()\n\n for (const mid of tentacle.mids) {\n if (p < mid.t) continue\n const mr = inner + (maxR - inner) * tentacle.length * mid.t\n ctx.beginPath()\n ctx.arc(cx + cos * mr, cy + sin * mr, mid.size, 0, Math.PI * 2)\n ctx.fillStyle = rgba(rgb, mid.alpha + twinkle * 0.4)\n ctx.fill()\n }\n\n if (tentacle.tip && p > 0.92) {\n const tipR =\n inner + (maxR - inner) * tentacle.length * (1 + tentacle.tipOvershoot)\n const appear = clamp((p - 0.92) / 0.08, 0, 1)\n ctx.beginPath()\n ctx.arc(\n cx + cos * tipR,\n cy + sin * tipR,\n tentacle.tipSize,\n 0,\n Math.PI * 2\n )\n ctx.fillStyle = rgba(rgb, (0.72 + twinkle) * appear)\n ctx.fill()\n }\n }\n\n if (!reduce && allDone) settled = true\n\n if (running && (!settled || !reduce)) {\n raf = requestAnimationFrame(paint)\n }\n }\n\n raf = requestAnimationFrame(paint)\n\n const replay = () => {\n if (reduce) {\n settled = true\n raf = requestAnimationFrame(paint)\n return\n }\n settled = false\n startedAt = performance.now()\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n\n return {\n setOptions(next) {\n const prevCount = options.tentacleCount\n const prevSeed = options.seed\n const prevRatio = options.particleRatio\n options = {\n tentacleCount: tentacleCountOf(\n next.tentacleCount ?? options.tentacleCount\n ),\n color: colorOf(next.color ?? options.color),\n coreSize: coreSizeOf(next.coreSize ?? options.coreSize),\n radius: radiusOf(next.radius ?? options.radius),\n duration: durationOf(next.duration ?? options.duration),\n seed: seedOf(next.seed ?? options.seed),\n particleRatio: particleRatioOf(\n next.particleRatio ?? options.particleRatio\n ),\n }\n const rebuilt =\n options.tentacleCount !== prevCount ||\n options.seed !== prevSeed ||\n options.particleRatio !== prevRatio\n if (rebuilt) {\n tentacles = createTentacles(\n options.tentacleCount,\n options.seed,\n options.particleRatio\n )\n replay()\n } else if (settled) {\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n },\n replay,\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n },\n }\n}\n\nexport type LogoBurstProps = LogoBurstOptions & {\n className?: string\n /** Centered mark. Defaults to a rounded chevron plate. */\n children?: ReactNode\n /**\n * Increment to replay the explosion. Mount already plays once.\n */\n replayKey?: number\n /** Click / Enter on the mark replays the burst. Default `true`. */\n replayOnClick?: boolean\n /** Accessible name. Default `\"Logo burst\"`. */\n label?: string\n}\n\n/** Default centered plate — swap via `children`. */\nexport function LogoBurstMark({ className }: { className?: string }) {\n return (\n \n \n \n \n \n )\n}\n\n/**\n * Hair-line tentacles explode from a centered mark, then settle with tip\n * particles. Transparent canvas — sit it on a dark surface.\n */\nexport function LogoBurst({\n className,\n children,\n tentacleCount = DEFAULT_TENTACLE_COUNT,\n color = DEFAULT_COLOR,\n coreSize,\n radius = DEFAULT_RADIUS,\n duration = DEFAULT_DURATION,\n seed = DEFAULT_SEED,\n particleRatio = DEFAULT_PARTICLE_RATIO,\n replayKey = 0,\n replayOnClick = true,\n label = \"Logo burst\",\n}: LogoBurstProps) {\n const canvasRef = useRef(null)\n const coreRef = useRef(null)\n const instanceRef = useRef(null)\n const replayKeyRef = useRef(replayKey)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createLogoBurst(canvas, {\n tentacleCount,\n color,\n coreSize: coreSize ?? DEFAULT_CORE_SIZE,\n radius,\n duration,\n seed,\n particleRatio,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n tentacleCount,\n color,\n coreSize,\n radius,\n duration,\n seed,\n particleRatio,\n })\n }, [tentacleCount, color, coreSize, radius, duration, seed, particleRatio])\n\n useEffect(() => {\n const core = coreRef.current\n if (!core || coreSize !== undefined) return\n const sync = () => {\n const box = core.getBoundingClientRect()\n const measured = Math.max(box.width, box.height)\n if (measured > 0) {\n instanceRef.current?.setOptions({ coreSize: measured })\n }\n }\n sync()\n const ro = new ResizeObserver(sync)\n ro.observe(core)\n return () => ro.disconnect()\n }, [coreSize, children])\n\n useEffect(() => {\n if (replayKeyRef.current === replayKey) return\n replayKeyRef.current = replayKey\n instanceRef.current?.replay()\n }, [replayKey])\n\n function replay() {\n instanceRef.current?.replay()\n }\n\n const mark = children ?? \n\n return (\n \n \n
\n {replayOnClick ? (\n {\n coreRef.current = node\n }}\n type=\"button\"\n aria-label=\"Replay burst\"\n onClick={replay}\n className=\"pointer-events-auto relative cursor-pointer rounded-[22%] border-0 bg-transparent p-0 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none\"\n >\n {mark}\n \n ) : (\n {\n coreRef.current = node\n }}\n className=\"pointer-events-auto relative\"\n >\n {mark}\n
\n )}\n
\n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, type ReactNode } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type LogoBurstTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type LogoBurstOptions = {\n /** Hair-line count. Default `260`. */\n tentacleCount?: number\n /**\n * Filament + particle color. Omit to follow `theme`\n * (`LIGHT_COLOR` / `DARK_COLOR`).\n */\n color?: string\n /**\n * Hole for the centered mark, in CSS pixels.\n * Lines start just under this disc so they tuck behind the logo.\n * Default `80`.\n */\n coreSize?: number\n /**\n * Outer reach as a fraction of the shorter canvas edge.\n * Default `0.48`.\n */\n radius?: number\n /** Burst duration in seconds. Default `1.35`. */\n duration?: number\n /** Deterministic tentacle layout. Default `23`. */\n seed?: number\n /** Share of tentacles that grow a tip particle (0–1). Default `0.62`. */\n particleRatio?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class) so filaments swap with the site theme.\n */\n theme?: LogoBurstTheme\n /** Idle inhale after the burst. Default `true`. */\n breathe?: boolean\n /** Fires whenever resolved dark mode changes. */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type LogoBurstInstance = {\n setOptions: (options: Partial) => void\n replay: () => void\n destroy: () => void\n}\n\n/** Bone filament — reads on slate / black */\nexport const DARK_COLOR = \"#D6D2CA\"\n/** Ink filament — reads on paper / white */\nexport const LIGHT_COLOR = \"#3F3F46\"\n/** @deprecated Use `DARK_COLOR` or omit `color` and set `theme`. */\nexport const DEFAULT_COLOR = DARK_COLOR\nexport const DEFAULT_TENTACLE_COUNT = 260\nexport const DEFAULT_CORE_SIZE = 80\nexport const DEFAULT_RADIUS = 0.48\nexport const DEFAULT_DURATION = 1.35\nexport const DEFAULT_SEED = 23\nexport const DEFAULT_PARTICLE_RATIO = 0.62\n\ntype Tentacle = {\n angle: number\n length: number\n width: number\n alpha: number\n delay: number\n grow: number\n phase: number\n tip: boolean\n tipSize: number\n tipOvershoot: number\n mids: Array<{ t: number; size: number; alpha: number }>\n}\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction tentacleCountOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_TENTACLE_COUNT\n }\n return Math.round(clamp(value, 24, 900))\n}\n\nfunction radiusOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_RADIUS\n }\n return clamp(value, 0.12, 0.95)\n}\n\nfunction durationOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_DURATION\n }\n return clamp(value, 0.2, 6)\n}\n\nfunction coreSizeOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_CORE_SIZE\n }\n return clamp(value, 8, 480)\n}\n\nfunction seedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_SEED\n }\n return Math.floor(value)\n}\n\nfunction particleRatioOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_PARTICLE_RATIO\n }\n return clamp(value, 0, 1)\n}\n\nfunction themeOf(value?: LogoBurstTheme): LogoBurstTheme {\n return value === \"light\" || value === \"dark\" || value === \"auto\"\n ? value\n : \"auto\"\n}\n\nfunction optionalColor(value?: string) {\n const next = value?.trim()\n return next ? next : undefined\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: LogoBurstTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nexport function resolveColor(color: string | undefined, dark: boolean) {\n const custom = optionalColor(color)\n if (custom) return custom\n return dark ? DARK_COLOR : LIGHT_COLOR\n}\n\nfunction parseRgb(color: string): [number, number, number] {\n const raw = color.trim()\n if (raw.startsWith(\"#\")) {\n const hex = raw.slice(1)\n const full =\n hex.length === 3\n ? hex\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : hex.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return darkFallbackRgb(false)\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255]\n }\n const rgb = raw.match(/rgba?\\(\\s*([.\\d]+)\\s*,\\s*([.\\d]+)\\s*,\\s*([.\\d]+)/i)\n if (rgb) {\n return [\n Number.parseFloat(rgb[1]!),\n Number.parseFloat(rgb[2]!),\n Number.parseFloat(rgb[3]!),\n ]\n }\n return darkFallbackRgb(false)\n}\n\nfunction darkFallbackRgb(dark: boolean): [number, number, number] {\n return dark ? [214, 210, 202] : [63, 63, 70]\n}\n\nfunction rgba(rgb: [number, number, number], alpha: number) {\n return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(alpha, 0, 1)})`\n}\n\nfunction easeOutQuart(t: number) {\n const p = 1 - clamp(t, 0, 1)\n return 1 - p * p * p * p\n}\n\nfunction createTentacles(\n count: number,\n seed: number,\n particleRatio: number\n): Tentacle[] {\n const rand = mulberry32(seed)\n const tentacles: Tentacle[] = []\n\n for (let i = 0; i < count; i++) {\n const base = (i / count) * Math.PI * 2\n const angle = base + (rand() - 0.5) * (Math.PI * 2 * 0.018)\n const roll = rand()\n // Power curve: more mid-length rays, a few long outliers, some stubs.\n const length =\n roll < 0.12\n ? 0.22 + rand() * 0.18\n : roll < 0.78\n ? 0.48 + rand() * 0.32\n : 0.78 + rand() * 0.22\n const tip = rand() < particleRatio\n const midCount = rand() < 0.22 ? 2 : rand() < 0.48 ? 1 : 0\n const mids: Tentacle[\"mids\"] = []\n for (let m = 0; m < midCount; m++) {\n mids.push({\n t: 0.28 + rand() * 0.52,\n size: 0.55 + rand() * 0.7,\n alpha: 0.28 + rand() * 0.38,\n })\n }\n\n tentacles.push({\n angle,\n length: clamp(length, 0.16, 1),\n width: 0.35 + rand() * 0.85,\n alpha: 0.22 + rand() * 0.55,\n delay: rand() * 0.42 + (i / count) * 0.08,\n grow: 0.55 + rand() * 0.7,\n phase: rand() * Math.PI * 2,\n tip,\n tipSize: 0.85 + rand() * 1.35,\n tipOvershoot: tip && rand() < 0.35 ? 0.018 + rand() * 0.05 : 0,\n mids,\n })\n }\n\n return tentacles\n}\n\n/**\n * Hair-line tentacle burst — filaments explode from a centered mark, then\n * keep a slow inhale. Transparent canvas over `bg-background`.\n */\nexport function createLogoBurst(\n canvas: HTMLCanvasElement,\n initial: LogoBurstOptions = {}\n): LogoBurstInstance | null {\n let options: LogoBurstOptions = {\n tentacleCount: tentacleCountOf(initial.tentacleCount),\n color: optionalColor(initial.color),\n coreSize: coreSizeOf(initial.coreSize),\n radius: radiusOf(initial.radius),\n duration: durationOf(initial.duration),\n seed: seedOf(initial.seed),\n particleRatio: particleRatioOf(initial.particleRatio),\n theme: themeOf(initial.theme),\n breathe: initial.breathe !== false,\n onThemeChange: initial.onThemeChange,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n let tentacles = createTentacles(\n tentacleCountOf(options.tentacleCount),\n seedOf(options.seed),\n particleRatioOf(options.particleRatio)\n )\n const size = { w: 0, h: 0 }\n let reduce = false\n let raf = 0\n let running = true\n let startedAt = performance.now()\n let settled = false\n let dark = resolveDark(themeOf(options.theme))\n options.onThemeChange?.(dark)\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n if (reduce) settled = true\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(themeOf(options.theme))\n if (next === dark) return\n dark = next\n options.onThemeChange?.(dark)\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const progressOf = (now: number, tentacle: Tentacle) => {\n if (reduce || settled) return 1\n const elapsed = (now - startedAt) / 1000\n const span = durationOf(options.duration) * tentacle.grow\n const t = (elapsed - tentacle.delay * durationOf(options.duration)) / span\n return easeOutQuart(t)\n }\n\n const paint = (now: number) => {\n if (!running) return\n\n const { w, h } = size\n if (w === 0 || h === 0) {\n raf = requestAnimationFrame(paint)\n return\n }\n\n const cx = w / 2\n const cy = h / 2\n const maxR = Math.min(w, h) * radiusOf(options.radius)\n const inner = Math.min(maxR * 0.92, coreSizeOf(options.coreSize) * 0.42)\n const rgb = parseRgb(resolveColor(options.color, dark))\n const seconds = now / 1000\n const breathing = settled && !reduce && options.breathe !== false\n const field = breathing ? Math.sin(seconds * 0.92) * 0.015 : 0\n\n ctx.clearRect(0, 0, w, h)\n ctx.lineCap = \"round\"\n\n let allDone = true\n\n for (let i = 0; i < tentacles.length; i++) {\n const tentacle = tentacles[i]!\n const p = progressOf(now, tentacle)\n if (p < 1) allDone = false\n\n const line = breathing\n ? Math.sin(seconds * 1.28 + tentacle.phase) * 0.018\n : 0\n const scale = 1 + field + line\n const reach = inner + (maxR - inner) * tentacle.length * p * scale\n if (reach <= inner + 0.4) continue\n\n const fade = breathing\n ? 0.9 + 0.1 * Math.sin(seconds * 0.92 + tentacle.phase * 0.35)\n : 1\n const cos = Math.cos(tentacle.angle)\n const sin = Math.sin(tentacle.angle)\n const x0 = cx + cos * inner\n const y0 = cy + sin * inner\n const x1 = cx + cos * reach\n const y1 = cy + sin * reach\n\n ctx.beginPath()\n ctx.moveTo(x0, y0)\n ctx.lineTo(x1, y1)\n ctx.strokeStyle = rgba(rgb, tentacle.alpha * (0.55 + p * 0.45) * fade)\n ctx.lineWidth = tentacle.width\n ctx.stroke()\n\n for (const mid of tentacle.mids) {\n if (p < mid.t) continue\n const mr = inner + (maxR - inner) * tentacle.length * mid.t * scale\n ctx.beginPath()\n ctx.arc(cx + cos * mr, cy + sin * mr, mid.size, 0, Math.PI * 2)\n ctx.fillStyle = rgba(rgb, mid.alpha * fade)\n ctx.fill()\n }\n\n if (tentacle.tip && p > 0.92) {\n const tipR =\n inner +\n (maxR - inner) * tentacle.length * (1 + tentacle.tipOvershoot) * scale\n const appear = clamp((p - 0.92) / 0.08, 0, 1)\n const tipPulse = breathing\n ? 1 + 0.1 * Math.sin(seconds * 1.28 + tentacle.phase)\n : 1\n ctx.beginPath()\n ctx.arc(\n cx + cos * tipR,\n cy + sin * tipR,\n tentacle.tipSize * tipPulse,\n 0,\n Math.PI * 2\n )\n ctx.fillStyle = rgba(rgb, 0.78 * appear * fade)\n ctx.fill()\n }\n }\n\n if (!reduce && allDone) settled = true\n\n if (running && !reduce) {\n raf = requestAnimationFrame(paint)\n }\n }\n\n raf = requestAnimationFrame(paint)\n\n const replay = () => {\n if (reduce) {\n settled = true\n raf = requestAnimationFrame(paint)\n return\n }\n settled = false\n startedAt = performance.now()\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n\n return {\n setOptions(next) {\n const prevCount = tentacleCountOf(options.tentacleCount)\n const prevSeed = seedOf(options.seed)\n const prevRatio = particleRatioOf(options.particleRatio)\n options = {\n ...options,\n ...next,\n tentacleCount: tentacleCountOf(\n next.tentacleCount ?? options.tentacleCount\n ),\n color: \"color\" in next ? optionalColor(next.color) : options.color,\n coreSize: coreSizeOf(next.coreSize ?? options.coreSize),\n radius: radiusOf(next.radius ?? options.radius),\n duration: durationOf(next.duration ?? options.duration),\n seed: seedOf(next.seed ?? options.seed),\n particleRatio: particleRatioOf(\n next.particleRatio ?? options.particleRatio\n ),\n theme: themeOf(next.theme ?? options.theme),\n breathe: next.breathe ?? options.breathe,\n }\n syncTheme()\n const rebuilt =\n tentacleCountOf(options.tentacleCount) !== prevCount ||\n seedOf(options.seed) !== prevSeed ||\n particleRatioOf(options.particleRatio) !== prevRatio\n if (rebuilt) {\n tentacles = createTentacles(\n tentacleCountOf(options.tentacleCount),\n seedOf(options.seed),\n particleRatioOf(options.particleRatio)\n )\n replay()\n } else if (reduce) {\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n },\n replay,\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n },\n }\n}\n\nexport type LogoBurstProps = Omit & {\n className?: string\n /** Centered mark. Defaults to a rounded chevron plate. */\n children?: ReactNode\n /**\n * Increment to replay the explosion. Mount already plays once.\n */\n replayKey?: number\n /** Click / Enter on the mark replays the burst. Default `true`. */\n replayOnClick?: boolean\n /** Accessible name. Default `\"Logo burst\"`. */\n label?: string\n}\n\n/** Default centered plate — swap via `children`. Follows the theme. */\nexport function LogoBurstMark({ className }: { className?: string }) {\n return (\n \n \n \n \n \n )\n}\n\n/**\n * Hair-line tentacles explode from a centered mark, then keep a slow inhale.\n * Transparent canvas over `bg-background`. Theme-aware light / dark.\n */\nexport function LogoBurst({\n className,\n children,\n tentacleCount = DEFAULT_TENTACLE_COUNT,\n color,\n coreSize,\n radius = DEFAULT_RADIUS,\n duration = DEFAULT_DURATION,\n seed = DEFAULT_SEED,\n particleRatio = DEFAULT_PARTICLE_RATIO,\n theme = \"auto\",\n breathe = true,\n replayKey = 0,\n replayOnClick = true,\n label = \"Logo burst\",\n}: LogoBurstProps) {\n const canvasRef = useRef(null)\n const coreRef = useRef(null)\n const instanceRef = useRef(null)\n const replayKeyRef = useRef(replayKey)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createLogoBurst(canvas, {\n tentacleCount,\n color,\n coreSize: coreSize ?? DEFAULT_CORE_SIZE,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n tentacleCount,\n color,\n coreSize,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n })\n }, [\n tentacleCount,\n color,\n coreSize,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n ])\n\n useEffect(() => {\n const core = coreRef.current\n if (!core || coreSize !== undefined) return\n const sync = () => {\n const box = core.getBoundingClientRect()\n const measured = Math.max(box.width, box.height)\n if (measured > 0) {\n instanceRef.current?.setOptions({ coreSize: measured })\n }\n }\n sync()\n const ro = new ResizeObserver(sync)\n ro.observe(core)\n return () => ro.disconnect()\n }, [coreSize, children])\n\n useEffect(() => {\n if (replayKeyRef.current === replayKey) return\n replayKeyRef.current = replayKey\n instanceRef.current?.replay()\n }, [replayKey])\n\n function replay() {\n instanceRef.current?.replay()\n }\n\n const mark = children ?? \n\n return (\n \n \n
\n {replayOnClick ? (\n {\n coreRef.current = node\n }}\n type=\"button\"\n aria-label=\"Replay burst\"\n onClick={replay}\n className=\"pointer-events-auto relative cursor-pointer rounded-[22%] border-0 bg-transparent p-0 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none\"\n >\n {mark}\n \n ) : (\n {\n coreRef.current = node\n }}\n className=\"pointer-events-auto relative\"\n >\n {mark}\n
\n )}\n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/logo-burst.tsx" } diff --git a/public/r/registry.json b/public/r/registry.json index 956bbf6..003d03e 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -140,7 +140,7 @@ { "name": "logo-burst", "title": "Logo Burst", - "description": "Hair-line tentacles explode from a centered mark, then settle with tip particles — drop any logo in the middle.", + "description": "Hair-line tentacles explode from a centered mark, then keep a slow inhale — drop any logo in the middle. Follows light and dark.", "files": [ { "path": "registry/logo-burst/logo-burst.tsx", @@ -153,7 +153,7 @@ { "name": "logo-burst-svelte", "title": "Logo Burst", - "description": "Hair-line tentacles explode from a centered mark, then settle with tip particles — drop any logo in the middle.", + "description": "Hair-line tentacles explode from a centered mark, then keep a slow inhale — drop any logo in the middle. Follows light and dark.", "files": [ { "path": "registry/logo-burst/logo-burst.svelte", diff --git a/registry/logo-burst/logo-burst-demo.tsx b/registry/logo-burst/logo-burst-demo.tsx index 1e4216e..e6395d0 100644 --- a/registry/logo-burst/logo-burst-demo.tsx +++ b/registry/logo-burst/logo-burst-demo.tsx @@ -1,6 +1,6 @@ "use client" -import { useMemo, useState } from "react" +import { useLayoutEffect, useMemo, useState } from "react" import { Button } from "@/components/ui/button" import { @@ -10,34 +10,56 @@ import { ControlSwitch, } from "@/components/component-controls" import { ComponentPreview } from "@/components/component-preview" +import { useHydratedTheme } from "@/hooks/use-hydrated-theme" import { usePreviewProps } from "@/hooks/use-preview-props" import { - DEFAULT_COLOR, + DARK_COLOR, DEFAULT_DURATION, DEFAULT_PARTICLE_RATIO, DEFAULT_RADIUS, DEFAULT_TENTACLE_COUNT, + LIGHT_COLOR, LogoBurst, } from "@/registry/logo-burst/logo-burst" -const STAGE = "#050506" +function norm(hex: string) { + return hex.trim().toUpperCase() +} + +function isStockColor(color: string) { + return norm(color) === norm(LIGHT_COLOR) || norm(color) === norm(DARK_COLOR) +} export function LogoBurstDemo() { + const theme = useHydratedTheme() + const stock = theme === "dark" ? DARK_COLOR : LIGHT_COLOR + const defaults = useMemo( () => ({ tentacleCount: DEFAULT_TENTACLE_COUNT, - color: DEFAULT_COLOR, + color: stock, radius: DEFAULT_RADIUS, duration: DEFAULT_DURATION, particleRatio: DEFAULT_PARTICLE_RATIO, replayOnClick: true, + breathe: true, }), - [] + [stock] ) - const { props, updateProp, resetProps, hasChanges } = + const { props, updateProp, resetProps, hasChanges, setProps } = usePreviewProps(defaults) + + useLayoutEffect(() => { + setProps((prev) => { + if (!isStockColor(prev.color)) return prev + if (norm(prev.color) === norm(stock)) return prev + return { ...prev, color: stock } + }) + }, [setProps, stock]) + const [replayKey, setReplayKey] = useState(0) + const useAutoColor = isStockColor(props.color) return ( <> @@ -45,18 +67,17 @@ export function LogoBurstDemo() { title="Logo Burst" stageClassName="min-h-0 overflow-hidden p-0" > -
+
@@ -70,7 +91,7 @@ export function LogoBurstDemo() { props.tentacleCount === DEFAULT_TENTACLE_COUNT ? undefined : props.tentacleCount, - color: props.color === DEFAULT_COLOR ? undefined : props.color, + color: useAutoColor ? undefined : props.color, radius: props.radius === DEFAULT_RADIUS ? undefined : props.radius, duration: props.duration === DEFAULT_DURATION ? undefined : props.duration, @@ -79,6 +100,7 @@ export function LogoBurstDemo() { ? undefined : props.particleRatio, replayOnClick: props.replayOnClick ? undefined : false, + breathe: props.breathe ? undefined : false, }} >
@@ -129,6 +151,12 @@ export function LogoBurstDemo() { step={0.05} onChange={(v) => updateProp("particleRatio", v)} /> + updateProp("breathe", v)} + /> void } export type LogoBurstInstance = { @@ -28,7 +42,12 @@ export type LogoBurstInstance = { destroy: () => void } -export const DEFAULT_COLOR = "#D6D2CA" +/** Bone filament — reads on slate / black */ +export const DARK_COLOR = "#D6D2CA" +/** Ink filament — reads on paper / white */ +export const LIGHT_COLOR = "#3F3F46" +/** @deprecated Use `DARK_COLOR` or omit `color` and set `theme`. */ +export const DEFAULT_COLOR = DARK_COLOR export const DEFAULT_TENTACLE_COUNT = 260 export const DEFAULT_CORE_SIZE = 80 export const DEFAULT_RADIUS = 0.48 @@ -43,6 +62,7 @@ type Tentacle = { alpha: number delay: number grow: number + phase: number tip: boolean tipSize: number tipOvershoot: number @@ -106,8 +126,39 @@ function particleRatioOf(value?: number) { return clamp(value, 0, 1) } -function colorOf(value?: string) { - return value && value.trim() ? value.trim() : DEFAULT_COLOR +function themeOf(value?: LogoBurstTheme): LogoBurstTheme { + return value === "light" || value === "dark" || value === "auto" + ? value + : "auto" +} + +function optionalColor(value?: string) { + const next = value?.trim() + return next ? next : undefined +} + +/** Resolves shadcn / next-themes dark mode (`attribute="class"` → `html.dark`). */ +export function isDarkTheme(): boolean { + if (typeof document === "undefined") return false + const root = document.documentElement + if (root.classList.contains("dark")) return true + if (root.classList.contains("light")) return false + const dataTheme = root.getAttribute("data-theme") + if (dataTheme === "dark") return true + if (dataTheme === "light") return false + return window.matchMedia("(prefers-color-scheme: dark)").matches +} + +export function resolveDark(theme: LogoBurstTheme): boolean { + if (theme === "dark") return true + if (theme === "light") return false + return isDarkTheme() +} + +export function resolveColor(color: string | undefined, dark: boolean) { + const custom = optionalColor(color) + if (custom) return custom + return dark ? DARK_COLOR : LIGHT_COLOR } function parseRgb(color: string): [number, number, number] { @@ -122,7 +173,7 @@ function parseRgb(color: string): [number, number, number] { .join("") : hex.padEnd(6, "0").slice(0, 6) const n = Number.parseInt(full, 16) - if (Number.isNaN(n)) return [214, 210, 202] + if (Number.isNaN(n)) return darkFallbackRgb(false) return [(n >> 16) & 255, (n >> 8) & 255, n & 255] } const rgb = raw.match(/rgba?\(\s*([.\d]+)\s*,\s*([.\d]+)\s*,\s*([.\d]+)/i) @@ -133,7 +184,11 @@ function parseRgb(color: string): [number, number, number] { Number.parseFloat(rgb[3]!), ] } - return [214, 210, 202] + return darkFallbackRgb(false) +} + +function darkFallbackRgb(dark: boolean): [number, number, number] { + return dark ? [214, 210, 202] : [63, 63, 70] } function rgba(rgb: [number, number, number], alpha: number) { @@ -182,6 +237,7 @@ function createTentacles( alpha: 0.22 + rand() * 0.55, delay: rand() * 0.42 + (i / count) * 0.08, grow: 0.55 + rand() * 0.7, + phase: rand() * Math.PI * 2, tip, tipSize: 0.85 + rand() * 1.35, tipOvershoot: tip && rand() < 0.35 ? 0.018 + rand() * 0.05 : 0, @@ -194,30 +250,32 @@ function createTentacles( /** * Hair-line tentacle burst — filaments explode from a centered mark, then - * settle into a still starfield with tip particles. Transparent canvas; - * put it on a dark surface. + * keep a slow inhale. Transparent canvas over `bg-background`. */ export function createLogoBurst( canvas: HTMLCanvasElement, initial: LogoBurstOptions = {} ): LogoBurstInstance | null { - let options: Required = { + let options: LogoBurstOptions = { tentacleCount: tentacleCountOf(initial.tentacleCount), - color: colorOf(initial.color), + color: optionalColor(initial.color), coreSize: coreSizeOf(initial.coreSize), radius: radiusOf(initial.radius), duration: durationOf(initial.duration), seed: seedOf(initial.seed), particleRatio: particleRatioOf(initial.particleRatio), + theme: themeOf(initial.theme), + breathe: initial.breathe !== false, + onThemeChange: initial.onThemeChange, } const ctx = canvas.getContext("2d", { alpha: true }) if (!ctx) return null let tentacles = createTentacles( - options.tentacleCount, - options.seed, - options.particleRatio + tentacleCountOf(options.tentacleCount), + seedOf(options.seed), + particleRatioOf(options.particleRatio) ) const size = { w: 0, h: 0 } let reduce = false @@ -225,6 +283,8 @@ export function createLogoBurst( let running = true let startedAt = performance.now() let settled = false + let dark = resolveDark(themeOf(options.theme)) + options.onThemeChange?.(dark) const resize = () => { const parent = canvas.parentElement @@ -253,11 +313,25 @@ export function createLogoBurst( onReduce() mqReduce.addEventListener("change", onReduce) + const syncTheme = () => { + const next = resolveDark(themeOf(options.theme)) + if (next === dark) return + dark = next + options.onThemeChange?.(dark) + } + const mo = new MutationObserver(syncTheme) + mo.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "data-theme", "style"], + }) + const mqDark = window.matchMedia("(prefers-color-scheme: dark)") + mqDark.addEventListener("change", syncTheme) + const progressOf = (now: number, tentacle: Tentacle) => { if (reduce || settled) return 1 const elapsed = (now - startedAt) / 1000 - const span = options.duration * tentacle.grow - const t = (elapsed - tentacle.delay * options.duration) / span + const span = durationOf(options.duration) * tentacle.grow + const t = (elapsed - tentacle.delay * durationOf(options.duration)) / span return easeOutQuart(t) } @@ -272,10 +346,12 @@ export function createLogoBurst( const cx = w / 2 const cy = h / 2 - const maxR = Math.min(w, h) * options.radius - const inner = Math.min(maxR * 0.92, options.coreSize * 0.42) - const rgb = parseRgb(options.color) - const twinkle = reduce ? 0 : Math.sin(now * 0.0016) * 0.08 + const maxR = Math.min(w, h) * radiusOf(options.radius) + const inner = Math.min(maxR * 0.92, coreSizeOf(options.coreSize) * 0.42) + const rgb = parseRgb(resolveColor(options.color, dark)) + const seconds = now / 1000 + const breathing = settled && !reduce && options.breathe !== false + const field = breathing ? Math.sin(seconds * 0.92) * 0.015 : 0 ctx.clearRect(0, 0, w, h) ctx.lineCap = "round" @@ -287,9 +363,16 @@ export function createLogoBurst( const p = progressOf(now, tentacle) if (p < 1) allDone = false - const reach = inner + (maxR - inner) * tentacle.length * p + const line = breathing + ? Math.sin(seconds * 1.28 + tentacle.phase) * 0.018 + : 0 + const scale = 1 + field + line + const reach = inner + (maxR - inner) * tentacle.length * p * scale if (reach <= inner + 0.4) continue + const fade = breathing + ? 0.9 + 0.1 * Math.sin(seconds * 0.92 + tentacle.phase * 0.35) + : 1 const cos = Math.cos(tentacle.angle) const sin = Math.sin(tentacle.angle) const x0 = cx + cos * inner @@ -300,39 +383,43 @@ export function createLogoBurst( ctx.beginPath() ctx.moveTo(x0, y0) ctx.lineTo(x1, y1) - ctx.strokeStyle = rgba(rgb, tentacle.alpha * (0.55 + p * 0.45)) + ctx.strokeStyle = rgba(rgb, tentacle.alpha * (0.55 + p * 0.45) * fade) ctx.lineWidth = tentacle.width ctx.stroke() for (const mid of tentacle.mids) { if (p < mid.t) continue - const mr = inner + (maxR - inner) * tentacle.length * mid.t + const mr = inner + (maxR - inner) * tentacle.length * mid.t * scale ctx.beginPath() ctx.arc(cx + cos * mr, cy + sin * mr, mid.size, 0, Math.PI * 2) - ctx.fillStyle = rgba(rgb, mid.alpha + twinkle * 0.4) + ctx.fillStyle = rgba(rgb, mid.alpha * fade) ctx.fill() } if (tentacle.tip && p > 0.92) { const tipR = - inner + (maxR - inner) * tentacle.length * (1 + tentacle.tipOvershoot) + inner + + (maxR - inner) * tentacle.length * (1 + tentacle.tipOvershoot) * scale const appear = clamp((p - 0.92) / 0.08, 0, 1) + const tipPulse = breathing + ? 1 + 0.1 * Math.sin(seconds * 1.28 + tentacle.phase) + : 1 ctx.beginPath() ctx.arc( cx + cos * tipR, cy + sin * tipR, - tentacle.tipSize, + tentacle.tipSize * tipPulse, 0, Math.PI * 2 ) - ctx.fillStyle = rgba(rgb, (0.72 + twinkle) * appear) + ctx.fillStyle = rgba(rgb, 0.78 * appear * fade) ctx.fill() } } if (!reduce && allDone) settled = true - if (running && (!settled || !reduce)) { + if (running && !reduce) { raf = requestAnimationFrame(paint) } } @@ -353,14 +440,16 @@ export function createLogoBurst( return { setOptions(next) { - const prevCount = options.tentacleCount - const prevSeed = options.seed - const prevRatio = options.particleRatio + const prevCount = tentacleCountOf(options.tentacleCount) + const prevSeed = seedOf(options.seed) + const prevRatio = particleRatioOf(options.particleRatio) options = { + ...options, + ...next, tentacleCount: tentacleCountOf( next.tentacleCount ?? options.tentacleCount ), - color: colorOf(next.color ?? options.color), + color: "color" in next ? optionalColor(next.color) : options.color, coreSize: coreSizeOf(next.coreSize ?? options.coreSize), radius: radiusOf(next.radius ?? options.radius), duration: durationOf(next.duration ?? options.duration), @@ -368,19 +457,22 @@ export function createLogoBurst( particleRatio: particleRatioOf( next.particleRatio ?? options.particleRatio ), + theme: themeOf(next.theme ?? options.theme), + breathe: next.breathe ?? options.breathe, } + syncTheme() const rebuilt = - options.tentacleCount !== prevCount || - options.seed !== prevSeed || - options.particleRatio !== prevRatio + tentacleCountOf(options.tentacleCount) !== prevCount || + seedOf(options.seed) !== prevSeed || + particleRatioOf(options.particleRatio) !== prevRatio if (rebuilt) { tentacles = createTentacles( - options.tentacleCount, - options.seed, - options.particleRatio + tentacleCountOf(options.tentacleCount), + seedOf(options.seed), + particleRatioOf(options.particleRatio) ) replay() - } else if (settled) { + } else if (reduce) { cancelAnimationFrame(raf) raf = requestAnimationFrame(paint) } @@ -390,7 +482,9 @@ export function createLogoBurst( running = false cancelAnimationFrame(raf) ro.disconnect() + mo.disconnect() mqReduce.removeEventListener("change", onReduce) + mqDark.removeEventListener("change", syncTheme) }, } } diff --git a/registry/logo-burst/logo-burst.svelte b/registry/logo-burst/logo-burst.svelte index 41bf8c1..b482226 100644 --- a/registry/logo-burst/logo-burst.svelte +++ b/registry/logo-burst/logo-burst.svelte @@ -5,7 +5,6 @@ import { onMount, type Snippet } from "svelte" import { createLogoBurst, - DEFAULT_COLOR, DEFAULT_CORE_SIZE, DEFAULT_DURATION, DEFAULT_PARTICLE_RATIO, @@ -20,7 +19,7 @@ return parts.filter(Boolean).join(" ") } - interface Props extends LogoBurstOptions { + interface Props extends Omit { class?: string /** Centered mark. Defaults to a rounded chevron plate. */ children?: Snippet @@ -38,12 +37,14 @@ class: className = "", children, tentacleCount = DEFAULT_TENTACLE_COUNT, - color = DEFAULT_COLOR, + color, coreSize, radius = DEFAULT_RADIUS, duration = DEFAULT_DURATION, seed = DEFAULT_SEED, particleRatio = DEFAULT_PARTICLE_RATIO, + theme = "auto", + breathe = true, replayKey = 0, replayOnClick = true, label = "Logo burst", @@ -64,6 +65,8 @@ duration, seed, particleRatio, + theme, + breathe, }) return () => { instance?.destroy() @@ -80,6 +83,8 @@ duration, seed, particleRatio, + theme, + breathe, }) }) @@ -120,7 +125,7 @@ {:else} @@ -63,19 +70,21 @@ export function LogoBurstMark({ className }: { className?: string }) { } /** - * Hair-line tentacles explode from a centered mark, then settle with tip - * particles. Transparent canvas — sit it on a dark surface. + * Hair-line tentacles explode from a centered mark, then keep a slow inhale. + * Transparent canvas over `bg-background`. Theme-aware light / dark. */ export function LogoBurst({ className, children, tentacleCount = DEFAULT_TENTACLE_COUNT, - color = DEFAULT_COLOR, + color, coreSize, radius = DEFAULT_RADIUS, duration = DEFAULT_DURATION, seed = DEFAULT_SEED, particleRatio = DEFAULT_PARTICLE_RATIO, + theme = "auto", + breathe = true, replayKey = 0, replayOnClick = true, label = "Logo burst", @@ -96,6 +105,8 @@ export function LogoBurst({ duration, seed, particleRatio, + theme, + breathe, }) return () => { instanceRef.current?.destroy() @@ -114,8 +125,20 @@ export function LogoBurst({ duration, seed, particleRatio, + theme, + breathe, }) - }, [tentacleCount, color, coreSize, radius, duration, seed, particleRatio]) + }, [ + tentacleCount, + color, + coreSize, + radius, + duration, + seed, + particleRatio, + theme, + breathe, + ]) useEffect(() => { const core = coreRef.current diff --git a/registry/logo-burst/registry.json b/registry/logo-burst/registry.json index d25d7e5..0d70643 100644 --- a/registry/logo-burst/registry.json +++ b/registry/logo-burst/registry.json @@ -5,7 +5,7 @@ "name": "logo-burst", "type": "registry:ui", "title": "Logo Burst", - "description": "Hair-line tentacles explode from a centered mark, then settle with tip particles — drop any logo in the middle.", + "description": "Hair-line tentacles explode from a centered mark, then keep a slow inhale — drop any logo in the middle. Follows light and dark.", "files": [ { "path": "logo-burst.tsx", @@ -18,7 +18,7 @@ "name": "logo-burst-svelte", "type": "registry:ui", "title": "Logo Burst", - "description": "Hair-line tentacles explode from a centered mark, then settle with tip particles — drop any logo in the middle.", + "description": "Hair-line tentacles explode from a centered mark, then keep a slow inhale — drop any logo in the middle. Follows light and dark.", "files": [ { "path": "logo-burst.svelte", From 0f1bdc695c79a92b5f3f27ac7ce167c6c75f70e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 12:12:34 +0000 Subject: [PATCH 3/4] fix: drop the default center mark from Logo Burst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The burst is the hero — no chevron plate unless you pass children. Core hole tightens to the origin so filaments meet in the middle. Co-authored-by: Jay Sharma --- README.md | 2 +- content/docs/components/logo-burst.mdx | 69 ++++++++++++++--------- content/docs/index.mdx | 2 +- public/r/logo-burst-svelte.json | 4 +- public/r/logo-burst.json | 4 +- public/r/registry.json | 4 +- registry/logo-burst/logo-burst-demo.tsx | 2 +- registry/logo-burst/logo-burst-vanilla.ts | 9 ++- registry/logo-burst/logo-burst.svelte | 58 +++++++------------ registry/logo-burst/logo-burst.tsx | 43 ++++++-------- registry/logo-burst/registry.json | 4 +- 11 files changed, 93 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index 9992a0c..a94910b 100644 --- a/README.md +++ b/README.md @@ -77,7 +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 a mark, then breathe — light and dark | +| [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 index b89e8e3..4da1f52 100644 --- a/content/docs/components/logo-burst.mdx +++ b/content/docs/components/logo-burst.mdx @@ -1,11 +1,11 @@ --- title: Logo Burst -description: Hair-line tentacles explode from a centered mark, then keep a slow inhale — drop any logo in the middle. Follows light and dark. +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 plate — or bump `replayKey` — to explode again. Default `theme="auto"` follows the site. Press `d` to toggle. +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. @@ -19,7 +19,7 @@ Or install straight from the public GitHub source registry: ## Usage -Give the parent a size. The canvas is transparent over `bg-background`. Pass children to swap the default chevron plate. +Give the parent a size. The canvas is transparent over `bg-background`. @@ -28,12 +28,10 @@ Give the parent a size. The canvas is transparent over `bg-background`. Pass chi import { LogoBurst } from "@/components/ui/logo-burst" -export function HeroMark() { +export function HeroBurst() { return (
- - - +
) } @@ -46,15 +44,30 @@ export function HeroMark() {
- - - +
```
-Omit children to keep the built-in plate. The hole under the mark tracks the core size — pass `coreSize` only when you want to lock it. +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`. @@ -102,22 +115,22 @@ Replay ## Props -| Prop | Type | Default | -| --------------- | ----------------------------- | ---------------------------------------- | -| `children` | `ReactNode` / `Snippet` | chevron plate | -| `tentacleCount` | `number` | `260` | -| `color` | `string` | theme ink / bone | -| `theme` | `"light" \| "dark" \| "auto"` | `"auto"` | -| `breathe` | `boolean` | `true` | -| `coreSize` | `number` | measured from the mark | -| `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 | +| 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` | `10` — inner hole; 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/index.mdx b/content/docs/index.mdx index 67e2c2f..a697362 100644 --- a/content/docs/index.mdx +++ b/content/docs/index.mdx @@ -21,6 +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 a mark, then breathe +- [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/logo-burst-svelte.json b/public/r/logo-burst-svelte.json index 6c42c7a..369bbff 100644 --- a/public/r/logo-burst-svelte.json +++ b/public/r/logo-burst-svelte.json @@ -2,11 +2,11 @@ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "logo-burst-svelte", "title": "Logo Burst", - "description": "Hair-line tentacles explode from a centered mark, then keep a slow inhale — drop any logo in the middle. Follows light and dark.", + "description": "Hair-line tentacles explode from the center, then keep a slow inhale. Follows light and dark.", "files": [ { "path": "registry/logo-burst/logo-burst.svelte", - "content": "\n\n\n\n{#snippet mark()}\n {#if children}\n {@render children()}\n {:else}\n \n \n \n \n
\n {/if}\n{/snippet}\n\n\n \n \n {#if replayOnClick}\n \n {@render mark()}\n \n {:else}\n
\n {@render mark()}\n
\n {/if}\n
\n
\n", + "content": "\n\n\n\n\n
\n {#if children}\n \n
\n {@render children()}\n
\n \n {/if}\n {#if replayOnClick}\n \n {/if}\n\n", "type": "registry:file", "target": "src/lib/components/ui/logo-burst.svelte" } diff --git a/public/r/logo-burst.json b/public/r/logo-burst.json index b56c3b6..06c898e 100644 --- a/public/r/logo-burst.json +++ b/public/r/logo-burst.json @@ -2,11 +2,11 @@ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "logo-burst", "title": "Logo Burst", - "description": "Hair-line tentacles explode from a centered mark, then keep a slow inhale — drop any logo in the middle. Follows light and dark.", + "description": "Hair-line tentacles explode from the center, then keep a slow inhale. Follows light and dark.", "files": [ { "path": "registry/logo-burst/logo-burst.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, type ReactNode } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type LogoBurstTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type LogoBurstOptions = {\n /** Hair-line count. Default `260`. */\n tentacleCount?: number\n /**\n * Filament + particle color. Omit to follow `theme`\n * (`LIGHT_COLOR` / `DARK_COLOR`).\n */\n color?: string\n /**\n * Hole for the centered mark, in CSS pixels.\n * Lines start just under this disc so they tuck behind the logo.\n * Default `80`.\n */\n coreSize?: number\n /**\n * Outer reach as a fraction of the shorter canvas edge.\n * Default `0.48`.\n */\n radius?: number\n /** Burst duration in seconds. Default `1.35`. */\n duration?: number\n /** Deterministic tentacle layout. Default `23`. */\n seed?: number\n /** Share of tentacles that grow a tip particle (0–1). Default `0.62`. */\n particleRatio?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class) so filaments swap with the site theme.\n */\n theme?: LogoBurstTheme\n /** Idle inhale after the burst. Default `true`. */\n breathe?: boolean\n /** Fires whenever resolved dark mode changes. */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type LogoBurstInstance = {\n setOptions: (options: Partial) => void\n replay: () => void\n destroy: () => void\n}\n\n/** Bone filament — reads on slate / black */\nexport const DARK_COLOR = \"#D6D2CA\"\n/** Ink filament — reads on paper / white */\nexport const LIGHT_COLOR = \"#3F3F46\"\n/** @deprecated Use `DARK_COLOR` or omit `color` and set `theme`. */\nexport const DEFAULT_COLOR = DARK_COLOR\nexport const DEFAULT_TENTACLE_COUNT = 260\nexport const DEFAULT_CORE_SIZE = 80\nexport const DEFAULT_RADIUS = 0.48\nexport const DEFAULT_DURATION = 1.35\nexport const DEFAULT_SEED = 23\nexport const DEFAULT_PARTICLE_RATIO = 0.62\n\ntype Tentacle = {\n angle: number\n length: number\n width: number\n alpha: number\n delay: number\n grow: number\n phase: number\n tip: boolean\n tipSize: number\n tipOvershoot: number\n mids: Array<{ t: number; size: number; alpha: number }>\n}\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction tentacleCountOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_TENTACLE_COUNT\n }\n return Math.round(clamp(value, 24, 900))\n}\n\nfunction radiusOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_RADIUS\n }\n return clamp(value, 0.12, 0.95)\n}\n\nfunction durationOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_DURATION\n }\n return clamp(value, 0.2, 6)\n}\n\nfunction coreSizeOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_CORE_SIZE\n }\n return clamp(value, 8, 480)\n}\n\nfunction seedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_SEED\n }\n return Math.floor(value)\n}\n\nfunction particleRatioOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_PARTICLE_RATIO\n }\n return clamp(value, 0, 1)\n}\n\nfunction themeOf(value?: LogoBurstTheme): LogoBurstTheme {\n return value === \"light\" || value === \"dark\" || value === \"auto\"\n ? value\n : \"auto\"\n}\n\nfunction optionalColor(value?: string) {\n const next = value?.trim()\n return next ? next : undefined\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: LogoBurstTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nexport function resolveColor(color: string | undefined, dark: boolean) {\n const custom = optionalColor(color)\n if (custom) return custom\n return dark ? DARK_COLOR : LIGHT_COLOR\n}\n\nfunction parseRgb(color: string): [number, number, number] {\n const raw = color.trim()\n if (raw.startsWith(\"#\")) {\n const hex = raw.slice(1)\n const full =\n hex.length === 3\n ? hex\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : hex.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return darkFallbackRgb(false)\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255]\n }\n const rgb = raw.match(/rgba?\\(\\s*([.\\d]+)\\s*,\\s*([.\\d]+)\\s*,\\s*([.\\d]+)/i)\n if (rgb) {\n return [\n Number.parseFloat(rgb[1]!),\n Number.parseFloat(rgb[2]!),\n Number.parseFloat(rgb[3]!),\n ]\n }\n return darkFallbackRgb(false)\n}\n\nfunction darkFallbackRgb(dark: boolean): [number, number, number] {\n return dark ? [214, 210, 202] : [63, 63, 70]\n}\n\nfunction rgba(rgb: [number, number, number], alpha: number) {\n return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(alpha, 0, 1)})`\n}\n\nfunction easeOutQuart(t: number) {\n const p = 1 - clamp(t, 0, 1)\n return 1 - p * p * p * p\n}\n\nfunction createTentacles(\n count: number,\n seed: number,\n particleRatio: number\n): Tentacle[] {\n const rand = mulberry32(seed)\n const tentacles: Tentacle[] = []\n\n for (let i = 0; i < count; i++) {\n const base = (i / count) * Math.PI * 2\n const angle = base + (rand() - 0.5) * (Math.PI * 2 * 0.018)\n const roll = rand()\n // Power curve: more mid-length rays, a few long outliers, some stubs.\n const length =\n roll < 0.12\n ? 0.22 + rand() * 0.18\n : roll < 0.78\n ? 0.48 + rand() * 0.32\n : 0.78 + rand() * 0.22\n const tip = rand() < particleRatio\n const midCount = rand() < 0.22 ? 2 : rand() < 0.48 ? 1 : 0\n const mids: Tentacle[\"mids\"] = []\n for (let m = 0; m < midCount; m++) {\n mids.push({\n t: 0.28 + rand() * 0.52,\n size: 0.55 + rand() * 0.7,\n alpha: 0.28 + rand() * 0.38,\n })\n }\n\n tentacles.push({\n angle,\n length: clamp(length, 0.16, 1),\n width: 0.35 + rand() * 0.85,\n alpha: 0.22 + rand() * 0.55,\n delay: rand() * 0.42 + (i / count) * 0.08,\n grow: 0.55 + rand() * 0.7,\n phase: rand() * Math.PI * 2,\n tip,\n tipSize: 0.85 + rand() * 1.35,\n tipOvershoot: tip && rand() < 0.35 ? 0.018 + rand() * 0.05 : 0,\n mids,\n })\n }\n\n return tentacles\n}\n\n/**\n * Hair-line tentacle burst — filaments explode from a centered mark, then\n * keep a slow inhale. Transparent canvas over `bg-background`.\n */\nexport function createLogoBurst(\n canvas: HTMLCanvasElement,\n initial: LogoBurstOptions = {}\n): LogoBurstInstance | null {\n let options: LogoBurstOptions = {\n tentacleCount: tentacleCountOf(initial.tentacleCount),\n color: optionalColor(initial.color),\n coreSize: coreSizeOf(initial.coreSize),\n radius: radiusOf(initial.radius),\n duration: durationOf(initial.duration),\n seed: seedOf(initial.seed),\n particleRatio: particleRatioOf(initial.particleRatio),\n theme: themeOf(initial.theme),\n breathe: initial.breathe !== false,\n onThemeChange: initial.onThemeChange,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n let tentacles = createTentacles(\n tentacleCountOf(options.tentacleCount),\n seedOf(options.seed),\n particleRatioOf(options.particleRatio)\n )\n const size = { w: 0, h: 0 }\n let reduce = false\n let raf = 0\n let running = true\n let startedAt = performance.now()\n let settled = false\n let dark = resolveDark(themeOf(options.theme))\n options.onThemeChange?.(dark)\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n if (reduce) settled = true\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(themeOf(options.theme))\n if (next === dark) return\n dark = next\n options.onThemeChange?.(dark)\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const progressOf = (now: number, tentacle: Tentacle) => {\n if (reduce || settled) return 1\n const elapsed = (now - startedAt) / 1000\n const span = durationOf(options.duration) * tentacle.grow\n const t = (elapsed - tentacle.delay * durationOf(options.duration)) / span\n return easeOutQuart(t)\n }\n\n const paint = (now: number) => {\n if (!running) return\n\n const { w, h } = size\n if (w === 0 || h === 0) {\n raf = requestAnimationFrame(paint)\n return\n }\n\n const cx = w / 2\n const cy = h / 2\n const maxR = Math.min(w, h) * radiusOf(options.radius)\n const inner = Math.min(maxR * 0.92, coreSizeOf(options.coreSize) * 0.42)\n const rgb = parseRgb(resolveColor(options.color, dark))\n const seconds = now / 1000\n const breathing = settled && !reduce && options.breathe !== false\n const field = breathing ? Math.sin(seconds * 0.92) * 0.015 : 0\n\n ctx.clearRect(0, 0, w, h)\n ctx.lineCap = \"round\"\n\n let allDone = true\n\n for (let i = 0; i < tentacles.length; i++) {\n const tentacle = tentacles[i]!\n const p = progressOf(now, tentacle)\n if (p < 1) allDone = false\n\n const line = breathing\n ? Math.sin(seconds * 1.28 + tentacle.phase) * 0.018\n : 0\n const scale = 1 + field + line\n const reach = inner + (maxR - inner) * tentacle.length * p * scale\n if (reach <= inner + 0.4) continue\n\n const fade = breathing\n ? 0.9 + 0.1 * Math.sin(seconds * 0.92 + tentacle.phase * 0.35)\n : 1\n const cos = Math.cos(tentacle.angle)\n const sin = Math.sin(tentacle.angle)\n const x0 = cx + cos * inner\n const y0 = cy + sin * inner\n const x1 = cx + cos * reach\n const y1 = cy + sin * reach\n\n ctx.beginPath()\n ctx.moveTo(x0, y0)\n ctx.lineTo(x1, y1)\n ctx.strokeStyle = rgba(rgb, tentacle.alpha * (0.55 + p * 0.45) * fade)\n ctx.lineWidth = tentacle.width\n ctx.stroke()\n\n for (const mid of tentacle.mids) {\n if (p < mid.t) continue\n const mr = inner + (maxR - inner) * tentacle.length * mid.t * scale\n ctx.beginPath()\n ctx.arc(cx + cos * mr, cy + sin * mr, mid.size, 0, Math.PI * 2)\n ctx.fillStyle = rgba(rgb, mid.alpha * fade)\n ctx.fill()\n }\n\n if (tentacle.tip && p > 0.92) {\n const tipR =\n inner +\n (maxR - inner) * tentacle.length * (1 + tentacle.tipOvershoot) * scale\n const appear = clamp((p - 0.92) / 0.08, 0, 1)\n const tipPulse = breathing\n ? 1 + 0.1 * Math.sin(seconds * 1.28 + tentacle.phase)\n : 1\n ctx.beginPath()\n ctx.arc(\n cx + cos * tipR,\n cy + sin * tipR,\n tentacle.tipSize * tipPulse,\n 0,\n Math.PI * 2\n )\n ctx.fillStyle = rgba(rgb, 0.78 * appear * fade)\n ctx.fill()\n }\n }\n\n if (!reduce && allDone) settled = true\n\n if (running && !reduce) {\n raf = requestAnimationFrame(paint)\n }\n }\n\n raf = requestAnimationFrame(paint)\n\n const replay = () => {\n if (reduce) {\n settled = true\n raf = requestAnimationFrame(paint)\n return\n }\n settled = false\n startedAt = performance.now()\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n\n return {\n setOptions(next) {\n const prevCount = tentacleCountOf(options.tentacleCount)\n const prevSeed = seedOf(options.seed)\n const prevRatio = particleRatioOf(options.particleRatio)\n options = {\n ...options,\n ...next,\n tentacleCount: tentacleCountOf(\n next.tentacleCount ?? options.tentacleCount\n ),\n color: \"color\" in next ? optionalColor(next.color) : options.color,\n coreSize: coreSizeOf(next.coreSize ?? options.coreSize),\n radius: radiusOf(next.radius ?? options.radius),\n duration: durationOf(next.duration ?? options.duration),\n seed: seedOf(next.seed ?? options.seed),\n particleRatio: particleRatioOf(\n next.particleRatio ?? options.particleRatio\n ),\n theme: themeOf(next.theme ?? options.theme),\n breathe: next.breathe ?? options.breathe,\n }\n syncTheme()\n const rebuilt =\n tentacleCountOf(options.tentacleCount) !== prevCount ||\n seedOf(options.seed) !== prevSeed ||\n particleRatioOf(options.particleRatio) !== prevRatio\n if (rebuilt) {\n tentacles = createTentacles(\n tentacleCountOf(options.tentacleCount),\n seedOf(options.seed),\n particleRatioOf(options.particleRatio)\n )\n replay()\n } else if (reduce) {\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n },\n replay,\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n },\n }\n}\n\nexport type LogoBurstProps = Omit & {\n className?: string\n /** Centered mark. Defaults to a rounded chevron plate. */\n children?: ReactNode\n /**\n * Increment to replay the explosion. Mount already plays once.\n */\n replayKey?: number\n /** Click / Enter on the mark replays the burst. Default `true`. */\n replayOnClick?: boolean\n /** Accessible name. Default `\"Logo burst\"`. */\n label?: string\n}\n\n/** Default centered plate — swap via `children`. Follows the theme. */\nexport function LogoBurstMark({ className }: { className?: string }) {\n return (\n \n \n \n \n \n )\n}\n\n/**\n * Hair-line tentacles explode from a centered mark, then keep a slow inhale.\n * Transparent canvas over `bg-background`. Theme-aware light / dark.\n */\nexport function LogoBurst({\n className,\n children,\n tentacleCount = DEFAULT_TENTACLE_COUNT,\n color,\n coreSize,\n radius = DEFAULT_RADIUS,\n duration = DEFAULT_DURATION,\n seed = DEFAULT_SEED,\n particleRatio = DEFAULT_PARTICLE_RATIO,\n theme = \"auto\",\n breathe = true,\n replayKey = 0,\n replayOnClick = true,\n label = \"Logo burst\",\n}: LogoBurstProps) {\n const canvasRef = useRef(null)\n const coreRef = useRef(null)\n const instanceRef = useRef(null)\n const replayKeyRef = useRef(replayKey)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createLogoBurst(canvas, {\n tentacleCount,\n color,\n coreSize: coreSize ?? DEFAULT_CORE_SIZE,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n tentacleCount,\n color,\n coreSize,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n })\n }, [\n tentacleCount,\n color,\n coreSize,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n ])\n\n useEffect(() => {\n const core = coreRef.current\n if (!core || coreSize !== undefined) return\n const sync = () => {\n const box = core.getBoundingClientRect()\n const measured = Math.max(box.width, box.height)\n if (measured > 0) {\n instanceRef.current?.setOptions({ coreSize: measured })\n }\n }\n sync()\n const ro = new ResizeObserver(sync)\n ro.observe(core)\n return () => ro.disconnect()\n }, [coreSize, children])\n\n useEffect(() => {\n if (replayKeyRef.current === replayKey) return\n replayKeyRef.current = replayKey\n instanceRef.current?.replay()\n }, [replayKey])\n\n function replay() {\n instanceRef.current?.replay()\n }\n\n const mark = children ?? \n\n return (\n \n \n
\n {replayOnClick ? (\n {\n coreRef.current = node\n }}\n type=\"button\"\n aria-label=\"Replay burst\"\n onClick={replay}\n className=\"pointer-events-auto relative cursor-pointer rounded-[22%] border-0 bg-transparent p-0 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none\"\n >\n {mark}\n \n ) : (\n {\n coreRef.current = node\n }}\n className=\"pointer-events-auto relative\"\n >\n {mark}\n
\n )}\n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, type ReactNode } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type LogoBurstTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type LogoBurstOptions = {\n /** Hair-line count. Default `260`. */\n tentacleCount?: number\n /**\n * Filament + particle color. Omit to follow `theme`\n * (`LIGHT_COLOR` / `DARK_COLOR`).\n */\n color?: string\n /**\n * Inner hole in CSS pixels. Lines start just outside this disc.\n * Default `10` (tight origin). Raise it when you overlay a mark.\n */\n coreSize?: number\n /**\n * Outer reach as a fraction of the shorter canvas edge.\n * Default `0.48`.\n */\n radius?: number\n /** Burst duration in seconds. Default `1.35`. */\n duration?: number\n /** Deterministic tentacle layout. Default `23`. */\n seed?: number\n /** Share of tentacles that grow a tip particle (0–1). Default `0.62`. */\n particleRatio?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class) so filaments swap with the site theme.\n */\n theme?: LogoBurstTheme\n /** Idle inhale after the burst. Default `true`. */\n breathe?: boolean\n /** Fires whenever resolved dark mode changes. */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type LogoBurstInstance = {\n setOptions: (options: Partial) => void\n replay: () => void\n destroy: () => void\n}\n\n/** Bone filament — reads on slate / black */\nexport const DARK_COLOR = \"#D6D2CA\"\n/** Ink filament — reads on paper / white */\nexport const LIGHT_COLOR = \"#3F3F46\"\n/** @deprecated Use `DARK_COLOR` or omit `color` and set `theme`. */\nexport const DEFAULT_COLOR = DARK_COLOR\nexport const DEFAULT_TENTACLE_COUNT = 260\nexport const DEFAULT_CORE_SIZE = 10\nexport const DEFAULT_RADIUS = 0.48\nexport const DEFAULT_DURATION = 1.35\nexport const DEFAULT_SEED = 23\nexport const DEFAULT_PARTICLE_RATIO = 0.62\n\ntype Tentacle = {\n angle: number\n length: number\n width: number\n alpha: number\n delay: number\n grow: number\n phase: number\n tip: boolean\n tipSize: number\n tipOvershoot: number\n mids: Array<{ t: number; size: number; alpha: number }>\n}\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction tentacleCountOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_TENTACLE_COUNT\n }\n return Math.round(clamp(value, 24, 900))\n}\n\nfunction radiusOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_RADIUS\n }\n return clamp(value, 0.12, 0.95)\n}\n\nfunction durationOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_DURATION\n }\n return clamp(value, 0.2, 6)\n}\n\nfunction coreSizeOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_CORE_SIZE\n }\n return clamp(value, 8, 480)\n}\n\nfunction seedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_SEED\n }\n return Math.floor(value)\n}\n\nfunction particleRatioOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_PARTICLE_RATIO\n }\n return clamp(value, 0, 1)\n}\n\nfunction themeOf(value?: LogoBurstTheme): LogoBurstTheme {\n return value === \"light\" || value === \"dark\" || value === \"auto\"\n ? value\n : \"auto\"\n}\n\nfunction optionalColor(value?: string) {\n const next = value?.trim()\n return next ? next : undefined\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: LogoBurstTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nexport function resolveColor(color: string | undefined, dark: boolean) {\n const custom = optionalColor(color)\n if (custom) return custom\n return dark ? DARK_COLOR : LIGHT_COLOR\n}\n\nfunction parseRgb(color: string): [number, number, number] {\n const raw = color.trim()\n if (raw.startsWith(\"#\")) {\n const hex = raw.slice(1)\n const full =\n hex.length === 3\n ? hex\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : hex.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return darkFallbackRgb(false)\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255]\n }\n const rgb = raw.match(/rgba?\\(\\s*([.\\d]+)\\s*,\\s*([.\\d]+)\\s*,\\s*([.\\d]+)/i)\n if (rgb) {\n return [\n Number.parseFloat(rgb[1]!),\n Number.parseFloat(rgb[2]!),\n Number.parseFloat(rgb[3]!),\n ]\n }\n return darkFallbackRgb(false)\n}\n\nfunction darkFallbackRgb(dark: boolean): [number, number, number] {\n return dark ? [214, 210, 202] : [63, 63, 70]\n}\n\nfunction rgba(rgb: [number, number, number], alpha: number) {\n return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(alpha, 0, 1)})`\n}\n\nfunction easeOutQuart(t: number) {\n const p = 1 - clamp(t, 0, 1)\n return 1 - p * p * p * p\n}\n\nfunction createTentacles(\n count: number,\n seed: number,\n particleRatio: number\n): Tentacle[] {\n const rand = mulberry32(seed)\n const tentacles: Tentacle[] = []\n\n for (let i = 0; i < count; i++) {\n const base = (i / count) * Math.PI * 2\n const angle = base + (rand() - 0.5) * (Math.PI * 2 * 0.018)\n const roll = rand()\n // Power curve: more mid-length rays, a few long outliers, some stubs.\n const length =\n roll < 0.12\n ? 0.22 + rand() * 0.18\n : roll < 0.78\n ? 0.48 + rand() * 0.32\n : 0.78 + rand() * 0.22\n const tip = rand() < particleRatio\n const midCount = rand() < 0.22 ? 2 : rand() < 0.48 ? 1 : 0\n const mids: Tentacle[\"mids\"] = []\n for (let m = 0; m < midCount; m++) {\n mids.push({\n t: 0.28 + rand() * 0.52,\n size: 0.55 + rand() * 0.7,\n alpha: 0.28 + rand() * 0.38,\n })\n }\n\n tentacles.push({\n angle,\n length: clamp(length, 0.16, 1),\n width: 0.35 + rand() * 0.85,\n alpha: 0.22 + rand() * 0.55,\n delay: rand() * 0.42 + (i / count) * 0.08,\n grow: 0.55 + rand() * 0.7,\n phase: rand() * Math.PI * 2,\n tip,\n tipSize: 0.85 + rand() * 1.35,\n tipOvershoot: tip && rand() < 0.35 ? 0.018 + rand() * 0.05 : 0,\n mids,\n })\n }\n\n return tentacles\n}\n\n/**\n * Hair-line tentacle burst — filaments explode from the center, then\n * keep a slow inhale. Transparent canvas over `bg-background`.\n */\nexport function createLogoBurst(\n canvas: HTMLCanvasElement,\n initial: LogoBurstOptions = {}\n): LogoBurstInstance | null {\n let options: LogoBurstOptions = {\n tentacleCount: tentacleCountOf(initial.tentacleCount),\n color: optionalColor(initial.color),\n coreSize: coreSizeOf(initial.coreSize),\n radius: radiusOf(initial.radius),\n duration: durationOf(initial.duration),\n seed: seedOf(initial.seed),\n particleRatio: particleRatioOf(initial.particleRatio),\n theme: themeOf(initial.theme),\n breathe: initial.breathe !== false,\n onThemeChange: initial.onThemeChange,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n let tentacles = createTentacles(\n tentacleCountOf(options.tentacleCount),\n seedOf(options.seed),\n particleRatioOf(options.particleRatio)\n )\n const size = { w: 0, h: 0 }\n let reduce = false\n let raf = 0\n let running = true\n let startedAt = performance.now()\n let settled = false\n let dark = resolveDark(themeOf(options.theme))\n options.onThemeChange?.(dark)\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n if (reduce) settled = true\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(themeOf(options.theme))\n if (next === dark) return\n dark = next\n options.onThemeChange?.(dark)\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const progressOf = (now: number, tentacle: Tentacle) => {\n if (reduce || settled) return 1\n const elapsed = (now - startedAt) / 1000\n const span = durationOf(options.duration) * tentacle.grow\n const t = (elapsed - tentacle.delay * durationOf(options.duration)) / span\n return easeOutQuart(t)\n }\n\n const paint = (now: number) => {\n if (!running) return\n\n const { w, h } = size\n if (w === 0 || h === 0) {\n raf = requestAnimationFrame(paint)\n return\n }\n\n const cx = w / 2\n const cy = h / 2\n const maxR = Math.min(w, h) * radiusOf(options.radius)\n const inner = Math.min(maxR * 0.92, coreSizeOf(options.coreSize) * 0.42)\n const rgb = parseRgb(resolveColor(options.color, dark))\n const seconds = now / 1000\n const breathing = settled && !reduce && options.breathe !== false\n const field = breathing ? Math.sin(seconds * 0.92) * 0.015 : 0\n\n ctx.clearRect(0, 0, w, h)\n ctx.lineCap = \"round\"\n\n let allDone = true\n\n for (let i = 0; i < tentacles.length; i++) {\n const tentacle = tentacles[i]!\n const p = progressOf(now, tentacle)\n if (p < 1) allDone = false\n\n const line = breathing\n ? Math.sin(seconds * 1.28 + tentacle.phase) * 0.018\n : 0\n const scale = 1 + field + line\n const reach = inner + (maxR - inner) * tentacle.length * p * scale\n if (reach <= inner + 0.4) continue\n\n const fade = breathing\n ? 0.9 + 0.1 * Math.sin(seconds * 0.92 + tentacle.phase * 0.35)\n : 1\n const cos = Math.cos(tentacle.angle)\n const sin = Math.sin(tentacle.angle)\n const x0 = cx + cos * inner\n const y0 = cy + sin * inner\n const x1 = cx + cos * reach\n const y1 = cy + sin * reach\n\n ctx.beginPath()\n ctx.moveTo(x0, y0)\n ctx.lineTo(x1, y1)\n ctx.strokeStyle = rgba(rgb, tentacle.alpha * (0.55 + p * 0.45) * fade)\n ctx.lineWidth = tentacle.width\n ctx.stroke()\n\n for (const mid of tentacle.mids) {\n if (p < mid.t) continue\n const mr = inner + (maxR - inner) * tentacle.length * mid.t * scale\n ctx.beginPath()\n ctx.arc(cx + cos * mr, cy + sin * mr, mid.size, 0, Math.PI * 2)\n ctx.fillStyle = rgba(rgb, mid.alpha * fade)\n ctx.fill()\n }\n\n if (tentacle.tip && p > 0.92) {\n const tipR =\n inner +\n (maxR - inner) * tentacle.length * (1 + tentacle.tipOvershoot) * scale\n const appear = clamp((p - 0.92) / 0.08, 0, 1)\n const tipPulse = breathing\n ? 1 + 0.1 * Math.sin(seconds * 1.28 + tentacle.phase)\n : 1\n ctx.beginPath()\n ctx.arc(\n cx + cos * tipR,\n cy + sin * tipR,\n tentacle.tipSize * tipPulse,\n 0,\n Math.PI * 2\n )\n ctx.fillStyle = rgba(rgb, 0.78 * appear * fade)\n ctx.fill()\n }\n }\n\n if (!reduce && allDone) settled = true\n\n if (running && !reduce) {\n raf = requestAnimationFrame(paint)\n }\n }\n\n raf = requestAnimationFrame(paint)\n\n const replay = () => {\n if (reduce) {\n settled = true\n raf = requestAnimationFrame(paint)\n return\n }\n settled = false\n startedAt = performance.now()\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n\n return {\n setOptions(next) {\n const prevCount = tentacleCountOf(options.tentacleCount)\n const prevSeed = seedOf(options.seed)\n const prevRatio = particleRatioOf(options.particleRatio)\n options = {\n ...options,\n ...next,\n tentacleCount: tentacleCountOf(\n next.tentacleCount ?? options.tentacleCount\n ),\n color: \"color\" in next ? optionalColor(next.color) : options.color,\n coreSize: coreSizeOf(next.coreSize ?? options.coreSize),\n radius: radiusOf(next.radius ?? options.radius),\n duration: durationOf(next.duration ?? options.duration),\n seed: seedOf(next.seed ?? options.seed),\n particleRatio: particleRatioOf(\n next.particleRatio ?? options.particleRatio\n ),\n theme: themeOf(next.theme ?? options.theme),\n breathe: next.breathe ?? options.breathe,\n }\n syncTheme()\n const rebuilt =\n tentacleCountOf(options.tentacleCount) !== prevCount ||\n seedOf(options.seed) !== prevSeed ||\n particleRatioOf(options.particleRatio) !== prevRatio\n if (rebuilt) {\n tentacles = createTentacles(\n tentacleCountOf(options.tentacleCount),\n seedOf(options.seed),\n particleRatioOf(options.particleRatio)\n )\n replay()\n } else if (reduce) {\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n },\n replay,\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n },\n }\n}\n\nexport type LogoBurstProps = Omit & {\n className?: string\n /** Optional centered mark. Omitted by default — the burst is the hero. */\n children?: ReactNode\n /**\n * Increment to replay the explosion. Mount already plays once.\n */\n replayKey?: number\n /** Click the field to replay the burst. Default `true`. */\n replayOnClick?: boolean\n /** Accessible name. Default `\"Logo burst\"`. */\n label?: string\n}\n\n/** Optional centered plate if you want a logo over the burst. */\nexport function LogoBurstMark({ className }: { className?: string }) {\n return (\n \n \n \n \n \n )\n}\n\n/**\n * Hair-line tentacles explode from the center, then keep a slow inhale.\n * Transparent canvas over `bg-background`. Theme-aware light / dark.\n */\nexport function LogoBurst({\n className,\n children,\n tentacleCount = DEFAULT_TENTACLE_COUNT,\n color,\n coreSize,\n radius = DEFAULT_RADIUS,\n duration = DEFAULT_DURATION,\n seed = DEFAULT_SEED,\n particleRatio = DEFAULT_PARTICLE_RATIO,\n theme = \"auto\",\n breathe = true,\n replayKey = 0,\n replayOnClick = true,\n label = \"Logo burst\",\n}: LogoBurstProps) {\n const canvasRef = useRef(null)\n const coreRef = useRef(null)\n const instanceRef = useRef(null)\n const replayKeyRef = useRef(replayKey)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createLogoBurst(canvas, {\n tentacleCount,\n color,\n coreSize: coreSize ?? DEFAULT_CORE_SIZE,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n tentacleCount,\n color,\n coreSize,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n })\n }, [\n tentacleCount,\n color,\n coreSize,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n ])\n\n useEffect(() => {\n const core = coreRef.current\n if (!core || !children || coreSize !== undefined) return\n const sync = () => {\n const box = core.getBoundingClientRect()\n const measured = Math.max(box.width, box.height)\n if (measured > 0) {\n instanceRef.current?.setOptions({ coreSize: measured })\n }\n }\n sync()\n const ro = new ResizeObserver(sync)\n ro.observe(core)\n return () => ro.disconnect()\n }, [coreSize, children])\n\n useEffect(() => {\n if (replayKeyRef.current === replayKey) return\n replayKeyRef.current = replayKey\n instanceRef.current?.replay()\n }, [replayKey])\n\n function replay() {\n instanceRef.current?.replay()\n }\n\n return (\n \n \n {children ? (\n
\n {\n coreRef.current = node\n }}\n >\n {children}\n
\n \n ) : null}\n {replayOnClick ? (\n \n ) : null}\n \n )\n}\n", "type": "registry:ui", "target": "components/ui/logo-burst.tsx" } diff --git a/public/r/registry.json b/public/r/registry.json index 003d03e..0a03c64 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -140,7 +140,7 @@ { "name": "logo-burst", "title": "Logo Burst", - "description": "Hair-line tentacles explode from a centered mark, then keep a slow inhale — drop any logo in the middle. Follows light and dark.", + "description": "Hair-line tentacles explode from the center, then keep a slow inhale. Follows light and dark.", "files": [ { "path": "registry/logo-burst/logo-burst.tsx", @@ -153,7 +153,7 @@ { "name": "logo-burst-svelte", "title": "Logo Burst", - "description": "Hair-line tentacles explode from a centered mark, then keep a slow inhale — drop any logo in the middle. Follows light and dark.", + "description": "Hair-line tentacles explode from the center, then keep a slow inhale. Follows light and dark.", "files": [ { "path": "registry/logo-burst/logo-burst.svelte", diff --git a/registry/logo-burst/logo-burst-demo.tsx b/registry/logo-burst/logo-burst-demo.tsx index e6395d0..b335c44 100644 --- a/registry/logo-burst/logo-burst-demo.tsx +++ b/registry/logo-burst/logo-burst-demo.tsx @@ -159,7 +159,7 @@ export function LogoBurstDemo() { /> updateProp("replayOnClick", v)} /> diff --git a/registry/logo-burst/logo-burst-vanilla.ts b/registry/logo-burst/logo-burst-vanilla.ts index 42b23a2..f4ddae8 100644 --- a/registry/logo-burst/logo-burst-vanilla.ts +++ b/registry/logo-burst/logo-burst-vanilla.ts @@ -9,9 +9,8 @@ export type LogoBurstOptions = { */ color?: string /** - * Hole for the centered mark, in CSS pixels. - * Lines start just under this disc so they tuck behind the logo. - * Default `80`. + * Inner hole in CSS pixels. Lines start just outside this disc. + * Default `10` (tight origin). Raise it when you overlay a mark. */ coreSize?: number /** @@ -49,7 +48,7 @@ export const LIGHT_COLOR = "#3F3F46" /** @deprecated Use `DARK_COLOR` or omit `color` and set `theme`. */ export const DEFAULT_COLOR = DARK_COLOR export const DEFAULT_TENTACLE_COUNT = 260 -export const DEFAULT_CORE_SIZE = 80 +export const DEFAULT_CORE_SIZE = 10 export const DEFAULT_RADIUS = 0.48 export const DEFAULT_DURATION = 1.35 export const DEFAULT_SEED = 23 @@ -249,7 +248,7 @@ function createTentacles( } /** - * Hair-line tentacle burst — filaments explode from a centered mark, then + * Hair-line tentacle burst — filaments explode from the center, then * keep a slow inhale. Transparent canvas over `bg-background`. */ export function createLogoBurst( diff --git a/registry/logo-burst/logo-burst.svelte b/registry/logo-burst/logo-burst.svelte index b482226..c95b7ae 100644 --- a/registry/logo-burst/logo-burst.svelte +++ b/registry/logo-burst/logo-burst.svelte @@ -21,13 +21,13 @@ interface Props extends Omit { class?: string - /** Centered mark. Defaults to a rounded chevron plate. */ + /** Optional centered mark. Omitted by default — the burst is the hero. */ children?: Snippet /** * Increment to replay the explosion. Mount already plays once. */ replayKey?: number - /** Click / Enter on the mark replays the burst. Default `true`. */ + /** Click the field to replay the burst. Default `true`. */ replayOnClick?: boolean /** Accessible name. Default `"Logo burst"`. */ label?: string @@ -91,7 +91,7 @@ $effect(() => { const el = core const override = coreSize - if (!el || override !== undefined) return + if (!el || !children || override !== undefined) return const sync = () => { const box = el.getBoundingClientRect() const measured = Math.max(box.width, box.height) @@ -119,24 +119,6 @@ } -{#snippet mark()} - {#if children} - {@render children()} - {:else} - - - - {/if} -{/snippet} - \n", + "content": "\n\n\n\n\n \n {#if children}\n \n
\n {@render children()}\n
\n \n {/if}\n {#if replayOnClick}\n \n {/if}\n\n", "type": "registry:file", "target": "src/lib/components/ui/logo-burst.svelte" } diff --git a/public/r/logo-burst.json b/public/r/logo-burst.json index 06c898e..2b34d9d 100644 --- a/public/r/logo-burst.json +++ b/public/r/logo-burst.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/logo-burst/logo-burst.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, type ReactNode } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type LogoBurstTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type LogoBurstOptions = {\n /** Hair-line count. Default `260`. */\n tentacleCount?: number\n /**\n * Filament + particle color. Omit to follow `theme`\n * (`LIGHT_COLOR` / `DARK_COLOR`).\n */\n color?: string\n /**\n * Inner hole in CSS pixels. Lines start just outside this disc.\n * Default `10` (tight origin). Raise it when you overlay a mark.\n */\n coreSize?: number\n /**\n * Outer reach as a fraction of the shorter canvas edge.\n * Default `0.48`.\n */\n radius?: number\n /** Burst duration in seconds. Default `1.35`. */\n duration?: number\n /** Deterministic tentacle layout. Default `23`. */\n seed?: number\n /** Share of tentacles that grow a tip particle (0–1). Default `0.62`. */\n particleRatio?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class) so filaments swap with the site theme.\n */\n theme?: LogoBurstTheme\n /** Idle inhale after the burst. Default `true`. */\n breathe?: boolean\n /** Fires whenever resolved dark mode changes. */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type LogoBurstInstance = {\n setOptions: (options: Partial) => void\n replay: () => void\n destroy: () => void\n}\n\n/** Bone filament — reads on slate / black */\nexport const DARK_COLOR = \"#D6D2CA\"\n/** Ink filament — reads on paper / white */\nexport const LIGHT_COLOR = \"#3F3F46\"\n/** @deprecated Use `DARK_COLOR` or omit `color` and set `theme`. */\nexport const DEFAULT_COLOR = DARK_COLOR\nexport const DEFAULT_TENTACLE_COUNT = 260\nexport const DEFAULT_CORE_SIZE = 10\nexport const DEFAULT_RADIUS = 0.48\nexport const DEFAULT_DURATION = 1.35\nexport const DEFAULT_SEED = 23\nexport const DEFAULT_PARTICLE_RATIO = 0.62\n\ntype Tentacle = {\n angle: number\n length: number\n width: number\n alpha: number\n delay: number\n grow: number\n phase: number\n tip: boolean\n tipSize: number\n tipOvershoot: number\n mids: Array<{ t: number; size: number; alpha: number }>\n}\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction tentacleCountOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_TENTACLE_COUNT\n }\n return Math.round(clamp(value, 24, 900))\n}\n\nfunction radiusOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_RADIUS\n }\n return clamp(value, 0.12, 0.95)\n}\n\nfunction durationOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_DURATION\n }\n return clamp(value, 0.2, 6)\n}\n\nfunction coreSizeOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_CORE_SIZE\n }\n return clamp(value, 8, 480)\n}\n\nfunction seedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_SEED\n }\n return Math.floor(value)\n}\n\nfunction particleRatioOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_PARTICLE_RATIO\n }\n return clamp(value, 0, 1)\n}\n\nfunction themeOf(value?: LogoBurstTheme): LogoBurstTheme {\n return value === \"light\" || value === \"dark\" || value === \"auto\"\n ? value\n : \"auto\"\n}\n\nfunction optionalColor(value?: string) {\n const next = value?.trim()\n return next ? next : undefined\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: LogoBurstTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nexport function resolveColor(color: string | undefined, dark: boolean) {\n const custom = optionalColor(color)\n if (custom) return custom\n return dark ? DARK_COLOR : LIGHT_COLOR\n}\n\nfunction parseRgb(color: string): [number, number, number] {\n const raw = color.trim()\n if (raw.startsWith(\"#\")) {\n const hex = raw.slice(1)\n const full =\n hex.length === 3\n ? hex\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : hex.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return darkFallbackRgb(false)\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255]\n }\n const rgb = raw.match(/rgba?\\(\\s*([.\\d]+)\\s*,\\s*([.\\d]+)\\s*,\\s*([.\\d]+)/i)\n if (rgb) {\n return [\n Number.parseFloat(rgb[1]!),\n Number.parseFloat(rgb[2]!),\n Number.parseFloat(rgb[3]!),\n ]\n }\n return darkFallbackRgb(false)\n}\n\nfunction darkFallbackRgb(dark: boolean): [number, number, number] {\n return dark ? [214, 210, 202] : [63, 63, 70]\n}\n\nfunction rgba(rgb: [number, number, number], alpha: number) {\n return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(alpha, 0, 1)})`\n}\n\nfunction easeOutQuart(t: number) {\n const p = 1 - clamp(t, 0, 1)\n return 1 - p * p * p * p\n}\n\nfunction createTentacles(\n count: number,\n seed: number,\n particleRatio: number\n): Tentacle[] {\n const rand = mulberry32(seed)\n const tentacles: Tentacle[] = []\n\n for (let i = 0; i < count; i++) {\n const base = (i / count) * Math.PI * 2\n const angle = base + (rand() - 0.5) * (Math.PI * 2 * 0.018)\n const roll = rand()\n // Power curve: more mid-length rays, a few long outliers, some stubs.\n const length =\n roll < 0.12\n ? 0.22 + rand() * 0.18\n : roll < 0.78\n ? 0.48 + rand() * 0.32\n : 0.78 + rand() * 0.22\n const tip = rand() < particleRatio\n const midCount = rand() < 0.22 ? 2 : rand() < 0.48 ? 1 : 0\n const mids: Tentacle[\"mids\"] = []\n for (let m = 0; m < midCount; m++) {\n mids.push({\n t: 0.28 + rand() * 0.52,\n size: 0.55 + rand() * 0.7,\n alpha: 0.28 + rand() * 0.38,\n })\n }\n\n tentacles.push({\n angle,\n length: clamp(length, 0.16, 1),\n width: 0.35 + rand() * 0.85,\n alpha: 0.22 + rand() * 0.55,\n delay: rand() * 0.42 + (i / count) * 0.08,\n grow: 0.55 + rand() * 0.7,\n phase: rand() * Math.PI * 2,\n tip,\n tipSize: 0.85 + rand() * 1.35,\n tipOvershoot: tip && rand() < 0.35 ? 0.018 + rand() * 0.05 : 0,\n mids,\n })\n }\n\n return tentacles\n}\n\n/**\n * Hair-line tentacle burst — filaments explode from the center, then\n * keep a slow inhale. Transparent canvas over `bg-background`.\n */\nexport function createLogoBurst(\n canvas: HTMLCanvasElement,\n initial: LogoBurstOptions = {}\n): LogoBurstInstance | null {\n let options: LogoBurstOptions = {\n tentacleCount: tentacleCountOf(initial.tentacleCount),\n color: optionalColor(initial.color),\n coreSize: coreSizeOf(initial.coreSize),\n radius: radiusOf(initial.radius),\n duration: durationOf(initial.duration),\n seed: seedOf(initial.seed),\n particleRatio: particleRatioOf(initial.particleRatio),\n theme: themeOf(initial.theme),\n breathe: initial.breathe !== false,\n onThemeChange: initial.onThemeChange,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n let tentacles = createTentacles(\n tentacleCountOf(options.tentacleCount),\n seedOf(options.seed),\n particleRatioOf(options.particleRatio)\n )\n const size = { w: 0, h: 0 }\n let reduce = false\n let raf = 0\n let running = true\n let startedAt = performance.now()\n let settled = false\n let dark = resolveDark(themeOf(options.theme))\n options.onThemeChange?.(dark)\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n if (reduce) settled = true\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(themeOf(options.theme))\n if (next === dark) return\n dark = next\n options.onThemeChange?.(dark)\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const progressOf = (now: number, tentacle: Tentacle) => {\n if (reduce || settled) return 1\n const elapsed = (now - startedAt) / 1000\n const span = durationOf(options.duration) * tentacle.grow\n const t = (elapsed - tentacle.delay * durationOf(options.duration)) / span\n return easeOutQuart(t)\n }\n\n const paint = (now: number) => {\n if (!running) return\n\n const { w, h } = size\n if (w === 0 || h === 0) {\n raf = requestAnimationFrame(paint)\n return\n }\n\n const cx = w / 2\n const cy = h / 2\n const maxR = Math.min(w, h) * radiusOf(options.radius)\n const inner = Math.min(maxR * 0.92, coreSizeOf(options.coreSize) * 0.42)\n const rgb = parseRgb(resolveColor(options.color, dark))\n const seconds = now / 1000\n const breathing = settled && !reduce && options.breathe !== false\n const field = breathing ? Math.sin(seconds * 0.92) * 0.015 : 0\n\n ctx.clearRect(0, 0, w, h)\n ctx.lineCap = \"round\"\n\n let allDone = true\n\n for (let i = 0; i < tentacles.length; i++) {\n const tentacle = tentacles[i]!\n const p = progressOf(now, tentacle)\n if (p < 1) allDone = false\n\n const line = breathing\n ? Math.sin(seconds * 1.28 + tentacle.phase) * 0.018\n : 0\n const scale = 1 + field + line\n const reach = inner + (maxR - inner) * tentacle.length * p * scale\n if (reach <= inner + 0.4) continue\n\n const fade = breathing\n ? 0.9 + 0.1 * Math.sin(seconds * 0.92 + tentacle.phase * 0.35)\n : 1\n const cos = Math.cos(tentacle.angle)\n const sin = Math.sin(tentacle.angle)\n const x0 = cx + cos * inner\n const y0 = cy + sin * inner\n const x1 = cx + cos * reach\n const y1 = cy + sin * reach\n\n ctx.beginPath()\n ctx.moveTo(x0, y0)\n ctx.lineTo(x1, y1)\n ctx.strokeStyle = rgba(rgb, tentacle.alpha * (0.55 + p * 0.45) * fade)\n ctx.lineWidth = tentacle.width\n ctx.stroke()\n\n for (const mid of tentacle.mids) {\n if (p < mid.t) continue\n const mr = inner + (maxR - inner) * tentacle.length * mid.t * scale\n ctx.beginPath()\n ctx.arc(cx + cos * mr, cy + sin * mr, mid.size, 0, Math.PI * 2)\n ctx.fillStyle = rgba(rgb, mid.alpha * fade)\n ctx.fill()\n }\n\n if (tentacle.tip && p > 0.92) {\n const tipR =\n inner +\n (maxR - inner) * tentacle.length * (1 + tentacle.tipOvershoot) * scale\n const appear = clamp((p - 0.92) / 0.08, 0, 1)\n const tipPulse = breathing\n ? 1 + 0.1 * Math.sin(seconds * 1.28 + tentacle.phase)\n : 1\n ctx.beginPath()\n ctx.arc(\n cx + cos * tipR,\n cy + sin * tipR,\n tentacle.tipSize * tipPulse,\n 0,\n Math.PI * 2\n )\n ctx.fillStyle = rgba(rgb, 0.78 * appear * fade)\n ctx.fill()\n }\n }\n\n if (!reduce && allDone) settled = true\n\n if (running && !reduce) {\n raf = requestAnimationFrame(paint)\n }\n }\n\n raf = requestAnimationFrame(paint)\n\n const replay = () => {\n if (reduce) {\n settled = true\n raf = requestAnimationFrame(paint)\n return\n }\n settled = false\n startedAt = performance.now()\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n\n return {\n setOptions(next) {\n const prevCount = tentacleCountOf(options.tentacleCount)\n const prevSeed = seedOf(options.seed)\n const prevRatio = particleRatioOf(options.particleRatio)\n options = {\n ...options,\n ...next,\n tentacleCount: tentacleCountOf(\n next.tentacleCount ?? options.tentacleCount\n ),\n color: \"color\" in next ? optionalColor(next.color) : options.color,\n coreSize: coreSizeOf(next.coreSize ?? options.coreSize),\n radius: radiusOf(next.radius ?? options.radius),\n duration: durationOf(next.duration ?? options.duration),\n seed: seedOf(next.seed ?? options.seed),\n particleRatio: particleRatioOf(\n next.particleRatio ?? options.particleRatio\n ),\n theme: themeOf(next.theme ?? options.theme),\n breathe: next.breathe ?? options.breathe,\n }\n syncTheme()\n const rebuilt =\n tentacleCountOf(options.tentacleCount) !== prevCount ||\n seedOf(options.seed) !== prevSeed ||\n particleRatioOf(options.particleRatio) !== prevRatio\n if (rebuilt) {\n tentacles = createTentacles(\n tentacleCountOf(options.tentacleCount),\n seedOf(options.seed),\n particleRatioOf(options.particleRatio)\n )\n replay()\n } else if (reduce) {\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n },\n replay,\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n },\n }\n}\n\nexport type LogoBurstProps = Omit & {\n className?: string\n /** Optional centered mark. Omitted by default — the burst is the hero. */\n children?: ReactNode\n /**\n * Increment to replay the explosion. Mount already plays once.\n */\n replayKey?: number\n /** Click the field to replay the burst. Default `true`. */\n replayOnClick?: boolean\n /** Accessible name. Default `\"Logo burst\"`. */\n label?: string\n}\n\n/** Optional centered plate if you want a logo over the burst. */\nexport function LogoBurstMark({ className }: { className?: string }) {\n return (\n \n \n \n \n \n )\n}\n\n/**\n * Hair-line tentacles explode from the center, then keep a slow inhale.\n * Transparent canvas over `bg-background`. Theme-aware light / dark.\n */\nexport function LogoBurst({\n className,\n children,\n tentacleCount = DEFAULT_TENTACLE_COUNT,\n color,\n coreSize,\n radius = DEFAULT_RADIUS,\n duration = DEFAULT_DURATION,\n seed = DEFAULT_SEED,\n particleRatio = DEFAULT_PARTICLE_RATIO,\n theme = \"auto\",\n breathe = true,\n replayKey = 0,\n replayOnClick = true,\n label = \"Logo burst\",\n}: LogoBurstProps) {\n const canvasRef = useRef(null)\n const coreRef = useRef(null)\n const instanceRef = useRef(null)\n const replayKeyRef = useRef(replayKey)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createLogoBurst(canvas, {\n tentacleCount,\n color,\n coreSize: coreSize ?? DEFAULT_CORE_SIZE,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n tentacleCount,\n color,\n coreSize,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n })\n }, [\n tentacleCount,\n color,\n coreSize,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n ])\n\n useEffect(() => {\n const core = coreRef.current\n if (!core || !children || coreSize !== undefined) return\n const sync = () => {\n const box = core.getBoundingClientRect()\n const measured = Math.max(box.width, box.height)\n if (measured > 0) {\n instanceRef.current?.setOptions({ coreSize: measured })\n }\n }\n sync()\n const ro = new ResizeObserver(sync)\n ro.observe(core)\n return () => ro.disconnect()\n }, [coreSize, children])\n\n useEffect(() => {\n if (replayKeyRef.current === replayKey) return\n replayKeyRef.current = replayKey\n instanceRef.current?.replay()\n }, [replayKey])\n\n function replay() {\n instanceRef.current?.replay()\n }\n\n return (\n \n \n {children ? (\n
\n {\n coreRef.current = node\n }}\n >\n {children}\n
\n \n ) : null}\n {replayOnClick ? (\n \n ) : null}\n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, type ReactNode } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type LogoBurstTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type LogoBurstOptions = {\n /** Hair-line count. Default `260`. */\n tentacleCount?: number\n /**\n * Filament + particle color. Omit to follow `theme`\n * (`LIGHT_COLOR` / `DARK_COLOR`).\n */\n color?: string\n /**\n * Inner hole in CSS pixels. Lines start just outside this disc.\n * Default `0` (lines meet at the origin). Raise it when you overlay a mark.\n */\n coreSize?: number\n /**\n * Outer reach as a fraction of the shorter canvas edge.\n * Default `0.48`.\n */\n radius?: number\n /** Burst duration in seconds. Default `1.35`. */\n duration?: number\n /** Deterministic tentacle layout. Default `23`. */\n seed?: number\n /** Share of tentacles that grow a tip particle (0–1). Default `0.62`. */\n particleRatio?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class) so filaments swap with the site theme.\n */\n theme?: LogoBurstTheme\n /** Idle inhale after the burst. Default `true`. */\n breathe?: boolean\n /** Fires whenever resolved dark mode changes. */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type LogoBurstInstance = {\n setOptions: (options: Partial) => void\n replay: () => void\n destroy: () => void\n}\n\n/** Bone filament — reads on slate / black */\nexport const DARK_COLOR = \"#D6D2CA\"\n/** Ink filament — reads on paper / white */\nexport const LIGHT_COLOR = \"#3F3F46\"\n/** @deprecated Use `DARK_COLOR` or omit `color` and set `theme`. */\nexport const DEFAULT_COLOR = DARK_COLOR\nexport const DEFAULT_TENTACLE_COUNT = 260\nexport const DEFAULT_CORE_SIZE = 0\nexport const DEFAULT_RADIUS = 0.48\nexport const DEFAULT_DURATION = 1.35\nexport const DEFAULT_SEED = 23\nexport const DEFAULT_PARTICLE_RATIO = 0.62\n\ntype Tentacle = {\n angle: number\n length: number\n width: number\n alpha: number\n delay: number\n grow: number\n phase: number\n tip: boolean\n tipSize: number\n tipOvershoot: number\n mids: Array<{ t: number; size: number; alpha: number }>\n}\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction tentacleCountOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_TENTACLE_COUNT\n }\n return Math.round(clamp(value, 24, 900))\n}\n\nfunction radiusOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_RADIUS\n }\n return clamp(value, 0.12, 0.95)\n}\n\nfunction durationOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_DURATION\n }\n return clamp(value, 0.2, 6)\n}\n\nfunction coreSizeOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_CORE_SIZE\n }\n return clamp(value, 0, 480)\n}\n\nfunction seedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_SEED\n }\n return Math.floor(value)\n}\n\nfunction particleRatioOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_PARTICLE_RATIO\n }\n return clamp(value, 0, 1)\n}\n\nfunction themeOf(value?: LogoBurstTheme): LogoBurstTheme {\n return value === \"light\" || value === \"dark\" || value === \"auto\"\n ? value\n : \"auto\"\n}\n\nfunction optionalColor(value?: string) {\n const next = value?.trim()\n return next ? next : undefined\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: LogoBurstTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nexport function resolveColor(color: string | undefined, dark: boolean) {\n const custom = optionalColor(color)\n if (custom) return custom\n return dark ? DARK_COLOR : LIGHT_COLOR\n}\n\nfunction parseRgb(color: string): [number, number, number] {\n const raw = color.trim()\n if (raw.startsWith(\"#\")) {\n const hex = raw.slice(1)\n const full =\n hex.length === 3\n ? hex\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : hex.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return darkFallbackRgb(false)\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255]\n }\n const rgb = raw.match(/rgba?\\(\\s*([.\\d]+)\\s*,\\s*([.\\d]+)\\s*,\\s*([.\\d]+)/i)\n if (rgb) {\n return [\n Number.parseFloat(rgb[1]!),\n Number.parseFloat(rgb[2]!),\n Number.parseFloat(rgb[3]!),\n ]\n }\n return darkFallbackRgb(false)\n}\n\nfunction darkFallbackRgb(dark: boolean): [number, number, number] {\n return dark ? [214, 210, 202] : [63, 63, 70]\n}\n\nfunction rgba(rgb: [number, number, number], alpha: number) {\n return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(alpha, 0, 1)})`\n}\n\nfunction easeOutQuart(t: number) {\n const p = 1 - clamp(t, 0, 1)\n return 1 - p * p * p * p\n}\n\nfunction createTentacles(\n count: number,\n seed: number,\n particleRatio: number\n): Tentacle[] {\n const rand = mulberry32(seed)\n const tentacles: Tentacle[] = []\n\n for (let i = 0; i < count; i++) {\n const base = (i / count) * Math.PI * 2\n const angle = base + (rand() - 0.5) * (Math.PI * 2 * 0.018)\n const roll = rand()\n // Power curve: more mid-length rays, a few long outliers, some stubs.\n const length =\n roll < 0.12\n ? 0.22 + rand() * 0.18\n : roll < 0.78\n ? 0.48 + rand() * 0.32\n : 0.78 + rand() * 0.22\n const tip = rand() < particleRatio\n const midCount = rand() < 0.22 ? 2 : rand() < 0.48 ? 1 : 0\n const mids: Tentacle[\"mids\"] = []\n for (let m = 0; m < midCount; m++) {\n mids.push({\n t: 0.28 + rand() * 0.52,\n size: 0.55 + rand() * 0.7,\n alpha: 0.28 + rand() * 0.38,\n })\n }\n\n tentacles.push({\n angle,\n length: clamp(length, 0.16, 1),\n width: 0.35 + rand() * 0.85,\n alpha: 0.22 + rand() * 0.55,\n delay: rand() * 0.42 + (i / count) * 0.08,\n grow: 0.55 + rand() * 0.7,\n phase: rand() * Math.PI * 2,\n tip,\n tipSize: 0.85 + rand() * 1.35,\n tipOvershoot: tip && rand() < 0.35 ? 0.018 + rand() * 0.05 : 0,\n mids,\n })\n }\n\n return tentacles\n}\n\n/**\n * Hair-line tentacle burst — filaments explode from the center, then\n * keep a slow inhale. Transparent canvas over `bg-background`.\n */\nexport function createLogoBurst(\n canvas: HTMLCanvasElement,\n initial: LogoBurstOptions = {}\n): LogoBurstInstance | null {\n let options: LogoBurstOptions = {\n tentacleCount: tentacleCountOf(initial.tentacleCount),\n color: optionalColor(initial.color),\n coreSize: coreSizeOf(initial.coreSize),\n radius: radiusOf(initial.radius),\n duration: durationOf(initial.duration),\n seed: seedOf(initial.seed),\n particleRatio: particleRatioOf(initial.particleRatio),\n theme: themeOf(initial.theme),\n breathe: initial.breathe !== false,\n onThemeChange: initial.onThemeChange,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n let tentacles = createTentacles(\n tentacleCountOf(options.tentacleCount),\n seedOf(options.seed),\n particleRatioOf(options.particleRatio)\n )\n const size = { w: 0, h: 0 }\n let reduce = false\n let raf = 0\n let running = true\n let startedAt = performance.now()\n let settled = false\n let dark = resolveDark(themeOf(options.theme))\n options.onThemeChange?.(dark)\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n if (reduce) settled = true\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(themeOf(options.theme))\n if (next === dark) return\n dark = next\n options.onThemeChange?.(dark)\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const progressOf = (now: number, tentacle: Tentacle) => {\n if (reduce || settled) return 1\n const elapsed = (now - startedAt) / 1000\n const span = durationOf(options.duration) * tentacle.grow\n const t = (elapsed - tentacle.delay * durationOf(options.duration)) / span\n return easeOutQuart(t)\n }\n\n const paint = (now: number) => {\n if (!running) return\n\n const { w, h } = size\n if (w === 0 || h === 0) {\n raf = requestAnimationFrame(paint)\n return\n }\n\n const cx = w / 2\n const cy = h / 2\n const maxR = Math.min(w, h) * radiusOf(options.radius)\n const inner = Math.min(maxR * 0.92, coreSizeOf(options.coreSize) * 0.42)\n const rgb = parseRgb(resolveColor(options.color, dark))\n const seconds = now / 1000\n const breathing = settled && !reduce && options.breathe !== false\n const field = breathing ? Math.sin(seconds * 0.92) * 0.015 : 0\n\n ctx.clearRect(0, 0, w, h)\n ctx.lineCap = \"round\"\n\n let allDone = true\n\n for (let i = 0; i < tentacles.length; i++) {\n const tentacle = tentacles[i]!\n const p = progressOf(now, tentacle)\n if (p < 1) allDone = false\n\n const line = breathing\n ? Math.sin(seconds * 1.28 + tentacle.phase) * 0.018\n : 0\n const scale = 1 + field + line\n const reach = inner + (maxR - inner) * tentacle.length * p * scale\n if (reach <= inner + 0.4) continue\n\n const fade = breathing\n ? 0.9 + 0.1 * Math.sin(seconds * 0.92 + tentacle.phase * 0.35)\n : 1\n const cos = Math.cos(tentacle.angle)\n const sin = Math.sin(tentacle.angle)\n const x0 = cx + cos * inner\n const y0 = cy + sin * inner\n const x1 = cx + cos * reach\n const y1 = cy + sin * reach\n\n ctx.beginPath()\n ctx.moveTo(x0, y0)\n ctx.lineTo(x1, y1)\n ctx.strokeStyle = rgba(rgb, tentacle.alpha * (0.55 + p * 0.45) * fade)\n ctx.lineWidth = tentacle.width\n ctx.stroke()\n\n for (const mid of tentacle.mids) {\n if (p < mid.t) continue\n const mr = inner + (maxR - inner) * tentacle.length * mid.t * scale\n ctx.beginPath()\n ctx.arc(cx + cos * mr, cy + sin * mr, mid.size, 0, Math.PI * 2)\n ctx.fillStyle = rgba(rgb, mid.alpha * fade)\n ctx.fill()\n }\n\n if (tentacle.tip && p > 0.92) {\n const tipR =\n inner +\n (maxR - inner) * tentacle.length * (1 + tentacle.tipOvershoot) * scale\n const appear = clamp((p - 0.92) / 0.08, 0, 1)\n const tipPulse = breathing\n ? 1 + 0.1 * Math.sin(seconds * 1.28 + tentacle.phase)\n : 1\n ctx.beginPath()\n ctx.arc(\n cx + cos * tipR,\n cy + sin * tipR,\n tentacle.tipSize * tipPulse,\n 0,\n Math.PI * 2\n )\n ctx.fillStyle = rgba(rgb, 0.78 * appear * fade)\n ctx.fill()\n }\n }\n\n if (!reduce && allDone) settled = true\n\n if (running && !reduce) {\n raf = requestAnimationFrame(paint)\n }\n }\n\n raf = requestAnimationFrame(paint)\n\n const replay = () => {\n if (reduce) {\n settled = true\n raf = requestAnimationFrame(paint)\n return\n }\n settled = false\n startedAt = performance.now()\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n\n return {\n setOptions(next) {\n const prevCount = tentacleCountOf(options.tentacleCount)\n const prevSeed = seedOf(options.seed)\n const prevRatio = particleRatioOf(options.particleRatio)\n options = {\n ...options,\n ...next,\n tentacleCount: tentacleCountOf(\n next.tentacleCount ?? options.tentacleCount\n ),\n color: \"color\" in next ? optionalColor(next.color) : options.color,\n coreSize: coreSizeOf(next.coreSize ?? options.coreSize),\n radius: radiusOf(next.radius ?? options.radius),\n duration: durationOf(next.duration ?? options.duration),\n seed: seedOf(next.seed ?? options.seed),\n particleRatio: particleRatioOf(\n next.particleRatio ?? options.particleRatio\n ),\n theme: themeOf(next.theme ?? options.theme),\n breathe: next.breathe ?? options.breathe,\n }\n syncTheme()\n const rebuilt =\n tentacleCountOf(options.tentacleCount) !== prevCount ||\n seedOf(options.seed) !== prevSeed ||\n particleRatioOf(options.particleRatio) !== prevRatio\n if (rebuilt) {\n tentacles = createTentacles(\n tentacleCountOf(options.tentacleCount),\n seedOf(options.seed),\n particleRatioOf(options.particleRatio)\n )\n replay()\n } else if (reduce) {\n cancelAnimationFrame(raf)\n raf = requestAnimationFrame(paint)\n }\n },\n replay,\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n },\n }\n}\n\nexport type LogoBurstProps = Omit & {\n className?: string\n /** Optional centered mark. Omitted by default — the burst is the hero. */\n children?: ReactNode\n /**\n * Increment to replay the explosion. Mount already plays once.\n */\n replayKey?: number\n /** Click the field to replay the burst. Default `true`. */\n replayOnClick?: boolean\n /** Accessible name. Default `\"Logo burst\"`. */\n label?: string\n}\n\n/** Optional centered plate if you want a logo over the burst. */\nexport function LogoBurstMark({ className }: { className?: string }) {\n return (\n \n \n \n \n \n )\n}\n\n/**\n * Hair-line tentacles explode from the center, then keep a slow inhale.\n * Transparent canvas over `bg-background`. Theme-aware light / dark.\n */\nexport function LogoBurst({\n className,\n children,\n tentacleCount = DEFAULT_TENTACLE_COUNT,\n color,\n coreSize,\n radius = DEFAULT_RADIUS,\n duration = DEFAULT_DURATION,\n seed = DEFAULT_SEED,\n particleRatio = DEFAULT_PARTICLE_RATIO,\n theme = \"auto\",\n breathe = true,\n replayKey = 0,\n replayOnClick = true,\n label = \"Logo burst\",\n}: LogoBurstProps) {\n const canvasRef = useRef(null)\n const coreRef = useRef(null)\n const instanceRef = useRef(null)\n const replayKeyRef = useRef(replayKey)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createLogoBurst(canvas, {\n tentacleCount,\n color,\n coreSize: coreSize ?? DEFAULT_CORE_SIZE,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n tentacleCount,\n color,\n coreSize,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n })\n }, [\n tentacleCount,\n color,\n coreSize,\n radius,\n duration,\n seed,\n particleRatio,\n theme,\n breathe,\n ])\n\n useEffect(() => {\n const core = coreRef.current\n if (!core || !children || coreSize !== undefined) return\n const sync = () => {\n const box = core.getBoundingClientRect()\n const measured = Math.max(box.width, box.height)\n if (measured > 0) {\n instanceRef.current?.setOptions({ coreSize: measured })\n }\n }\n sync()\n const ro = new ResizeObserver(sync)\n ro.observe(core)\n return () => ro.disconnect()\n }, [coreSize, children])\n\n useEffect(() => {\n if (replayKeyRef.current === replayKey) return\n replayKeyRef.current = replayKey\n instanceRef.current?.replay()\n }, [replayKey])\n\n function replay() {\n instanceRef.current?.replay()\n }\n\n return (\n \n \n {children ? (\n
\n {\n coreRef.current = node\n }}\n >\n {children}\n
\n \n ) : null}\n {replayOnClick ? (\n \n ) : null}\n \n )\n}\n", "type": "registry:ui", "target": "components/ui/logo-burst.tsx" } diff --git a/registry/logo-burst/logo-burst-vanilla.ts b/registry/logo-burst/logo-burst-vanilla.ts index f4ddae8..5e092be 100644 --- a/registry/logo-burst/logo-burst-vanilla.ts +++ b/registry/logo-burst/logo-burst-vanilla.ts @@ -10,7 +10,7 @@ export type LogoBurstOptions = { color?: string /** * Inner hole in CSS pixels. Lines start just outside this disc. - * Default `10` (tight origin). Raise it when you overlay a mark. + * Default `0` (lines meet at the origin). Raise it when you overlay a mark. */ coreSize?: number /** @@ -48,7 +48,7 @@ export const LIGHT_COLOR = "#3F3F46" /** @deprecated Use `DARK_COLOR` or omit `color` and set `theme`. */ export const DEFAULT_COLOR = DARK_COLOR export const DEFAULT_TENTACLE_COUNT = 260 -export const DEFAULT_CORE_SIZE = 10 +export const DEFAULT_CORE_SIZE = 0 export const DEFAULT_RADIUS = 0.48 export const DEFAULT_DURATION = 1.35 export const DEFAULT_SEED = 23 @@ -108,7 +108,7 @@ function coreSizeOf(value?: number) { if (typeof value !== "number" || !Number.isFinite(value)) { return DEFAULT_CORE_SIZE } - return clamp(value, 8, 480) + return clamp(value, 0, 480) } function seedOf(value?: number) {