From eaf912f4898b4906a7c428a9e9adad560e3fa3ee Mon Sep 17 00:00:00 2001 From: pras75299 Date: Tue, 2 Jun 2026 16:02:32 +0530 Subject: [PATCH 1/3] feat(registry): add hero-radial-burst block with time-of-day themes --- apps/www/components/ui/hero-radial-burst.tsx | 725 ++++++++++++++++++ apps/www/config/components.ts | 40 + apps/www/config/demos.tsx | 4 + apps/www/config/docs-scenarios.ts | 5 + apps/www/public/r/hero-radial-burst.json | 21 + apps/www/public/r/registry.json | 19 + apps/www/public/registry.json | 59 ++ apps/www/public/registry/changelogs.json | 9 + .../public/registry/hero-radial-burst.json | 59 ++ apps/www/public/registry/index.json | 3 +- registry.json | 59 ++ .../blocks/hero/radial-burst/component.tsx | 725 ++++++++++++++++++ registry/blocks/hero/radial-burst/demo.tsx | 9 + registry/components/hero-radial-burst.json | 91 +++ registry/demos/demo-key-order.json | 3 +- registry/demos/shared.tsx | 1 + registry/manifest.json | 6 +- 17 files changed, 1834 insertions(+), 4 deletions(-) create mode 100644 apps/www/components/ui/hero-radial-burst.tsx create mode 100644 apps/www/public/r/hero-radial-burst.json create mode 100644 apps/www/public/registry/hero-radial-burst.json create mode 100644 registry/blocks/hero/radial-burst/component.tsx create mode 100644 registry/blocks/hero/radial-burst/demo.tsx create mode 100644 registry/components/hero-radial-burst.json diff --git a/apps/www/components/ui/hero-radial-burst.tsx b/apps/www/components/ui/hero-radial-burst.tsx new file mode 100644 index 00000000..179656c8 --- /dev/null +++ b/apps/www/components/ui/hero-radial-burst.tsx @@ -0,0 +1,725 @@ +"use client"; + +import { + useEffect, + useRef, + useState, + type ComponentProps, + type ReactNode, +} from "react"; +import { + motion, + AnimatePresence, + animate, + useMotionValue, + useReducedMotion, + type Variants, +} from "motion/react"; +import { + CloudMoon, + Sunrise, + Sun, + SunDim, + Sunset, + Moon, + type LucideIcon, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +/* ------------------------------------------------------------------ * + * Themes — a day cycle. Each palette drives the background gradient, + * the canvas burst colors, and (via `mode`) the text/UI contrast. + * ------------------------------------------------------------------ */ + +export type RadialBurstThemeId = + | "pre-dawn" + | "sunrise" + | "daytime" + | "dusk" + | "sunset" + | "night"; + +type RGB = [number, number, number]; + +type Palette = { + mode: "light" | "dark"; + /** CSS background for the section (crossfaded on theme change). */ + bg: string; + /** Central bloom color. */ + core: RGB; + /** Streamline color near the origin (bright) … */ + rayBase: RGB; + /** … fading to this color at the tip. */ + rayTip: RGB; + /** Dot color near the origin … */ + dotBase: RGB; + /** … to this color at the tip. */ + dotTip: RGB; +}; + +type ThemeDef = { + id: RadialBurstThemeId; + label: string; + Icon: LucideIcon; + palette: Palette; +}; + +export const RADIAL_BURST_THEMES: ThemeDef[] = [ + { + id: "pre-dawn", + label: "Pre-dawn", + Icon: CloudMoon, + palette: { + mode: "dark", + bg: "radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)", + core: [165, 180, 252], + rayBase: [199, 210, 254], + rayTip: [99, 102, 241], + dotBase: [199, 210, 254], + dotTip: [129, 140, 248], + }, + }, + { + id: "sunrise", + label: "Sunrise", + Icon: Sunrise, + palette: { + mode: "light", + bg: "radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)", + core: [147, 197, 253], + rayBase: [37, 99, 235], + rayTip: [96, 165, 250], + dotBase: [29, 78, 216], + dotTip: [59, 130, 246], + }, + }, + { + id: "daytime", + label: "Daytime", + Icon: Sun, + palette: { + mode: "light", + bg: "radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)", + core: [216, 180, 254], + rayBase: [124, 58, 237], + rayTip: [219, 39, 119], + dotBase: [30, 64, 175], + dotTip: [219, 39, 119], + }, + }, + { + id: "dusk", + label: "Dusk", + Icon: SunDim, + palette: { + mode: "light", + bg: "radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)", + core: [167, 139, 250], + rayBase: [109, 40, 217], + rayTip: [236, 72, 153], + dotBase: [76, 29, 149], + dotTip: [219, 39, 119], + }, + }, + { + id: "sunset", + label: "Sunset", + Icon: Sunset, + palette: { + mode: "light", + bg: "radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)", + core: [253, 186, 116], + rayBase: [244, 63, 94], + rayTip: [251, 146, 60], + dotBase: [99, 102, 241], + dotTip: [236, 72, 153], + }, + }, + { + id: "night", + label: "Night", + Icon: Moon, + palette: { + mode: "dark", + bg: "radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)", + core: [224, 231, 255], + rayBase: [237, 233, 254], + rayTip: [129, 140, 248], + dotBase: [224, 231, 255], + dotTip: [165, 180, 252], + }, + }, +]; + +const THEME_BY_ID = Object.fromEntries( + RADIAL_BURST_THEMES.map((t) => [t.id, t]), +) as Record; + +/* ------------------------------------------------------------------ * + * Small helpers + * ------------------------------------------------------------------ */ + +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; +const lerpRGB = (a: RGB, b: RGB, t: number): RGB => [ + lerp(a[0], b[0], t), + lerp(a[1], b[1], t), + lerp(a[2], b[2], t), +]; +const rgba = (c: RGB, a: number) => + `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`; +const easeOut = (t: number) => 1 - Math.pow(1 - t, 3); + +type Ray = { + angle: number; + length: number; // fraction of maxRadius + reveal: number; // 0..1 stagger delay + phase: number; // twinkle offset + dots: { p: number; r: number; phase: number }[]; +}; + +/* ------------------------------------------------------------------ * + * RadialBurst — the canvas background (reusable on its own). + * ------------------------------------------------------------------ */ + +export type RadialBurstProps = { + className?: string; + /** Palette id. */ + theme?: RadialBurstThemeId; + /** Ray-count multiplier (0.4–2). */ + density?: number; +}; + +export function RadialBurst({ + className, + theme = "night", + density = 1, +}: RadialBurstProps) { + const canvasRef = useRef(null); + const reduced = useReducedMotion(); + + // Reveal progress driven by Motion — read inside the canvas rAF loop. + const progress = useMotionValue(reduced ? 1 : 0); + + // Target palette + a smoothed "displayed" palette so theme switches lerp. + const targetRef = useRef(THEME_BY_ID[theme].palette); + const dispRef = useRef<{ + core: RGB; + rayBase: RGB; + rayTip: RGB; + dotBase: RGB; + dotTip: RGB; + }>({ + core: [...THEME_BY_ID[theme].palette.core] as RGB, + rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB, + rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB, + dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB, + dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB, + }); + const renderRef = useRef<() => void>(() => {}); + + // Mount reveal animation (Motion). + useEffect(() => { + if (reduced) { + progress.set(1); + return; + } + const controls = animate(progress, 1, { + duration: 1.7, + ease: [0.22, 1, 0.36, 1], + }); + return () => controls.stop(); + }, [progress, reduced]); + + // Update the target palette when the theme changes; redraw if static. + useEffect(() => { + targetRef.current = THEME_BY_ID[theme].palette; + if (reduced) { + const d = dispRef.current; + const p = targetRef.current; + d.core = [...p.core] as RGB; + d.rayBase = [...p.rayBase] as RGB; + d.rayTip = [...p.rayTip] as RGB; + d.dotBase = [...p.dotBase] as RGB; + d.dotTip = [...p.dotTip] as RGB; + renderRef.current(); + } + }, [theme, reduced]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let raf = 0; + let rays: Ray[] = []; + let maxRadius = 0; + let originX = 0; + let originY = 0; + + const seed = () => { + const w = canvas.clientWidth; + const h = canvas.clientHeight; + originX = w / 2; + originY = h * 0.99; + maxRadius = h * 0.94; + const count = Math.round( + Math.min(340, Math.max(120, w / 4.8)) * + Math.min(2, Math.max(0.4, density)), + ); + const aMin = -0.06 * Math.PI; + const aMax = 1.06 * Math.PI; + rays = Array.from({ length: count }, (_, i) => { + const t = count > 1 ? i / (count - 1) : 0.5; + const angle = lerp(aMin, aMax, t) + (Math.random() - 0.5) * 0.012; + // Closeness to vertical → longer rays → a dome silhouette. + const vert = Math.sin(Math.min(Math.PI, Math.max(0, angle))); + const length = + (0.34 + 0.66 * vert) * (0.78 + Math.random() * 0.3) + + (Math.random() < 0.06 ? 0.12 : 0); + const dotCount = 1 + Math.floor(Math.random() * 4); + return { + angle, + length: Math.min(1.05, length), + reveal: (1 - vert) * 0.42 + Math.random() * 0.12, + phase: Math.random() * Math.PI * 2, + dots: Array.from({ length: dotCount }, () => ({ + p: 0.2 + Math.random() * 0.8, + r: 0.6 + Math.random() * 0.9, + phase: Math.random() * Math.PI * 2, + })), + }; + }); + }; + + const resize = () => { + const { clientWidth, clientHeight } = canvas; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.round(clientWidth * dpr); + canvas.height = Math.round(clientHeight * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + seed(); + if (reduced) renderRef.current(); + }; + + const render = () => { + const w = canvas.clientWidth; + const h = canvas.clientHeight; + const now = performance.now() / 1000; + const prog = progress.get(); + const disp = dispRef.current; + const target = targetRef.current; + + // Ease displayed colors toward the target palette (theme crossfade). + const k = reduced ? 1 : 0.08; + disp.core = lerpRGB(disp.core, target.core, k); + disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k); + disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k); + disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k); + disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k); + + ctx.clearRect(0, 0, w, h); + const dark = target.mode === "dark"; + // Dark themes glow additively; light themes paint normally. + ctx.globalCompositeOperation = dark ? "lighter" : "source-over"; + + // Central bloom. + const bloomR = Math.min(w * 0.22, maxRadius * 0.5) * (0.6 + 0.4 * prog); + const bloom = ctx.createRadialGradient( + originX, + originY, + 0, + originX, + originY, + bloomR, + ); + bloom.addColorStop(0, rgba(disp.core, dark ? 0.7 : 0.6)); + bloom.addColorStop(0.45, rgba(disp.core, dark ? 0.22 : 0.18)); + bloom.addColorStop(1, rgba(disp.core, 0)); + ctx.fillStyle = bloom; + ctx.beginPath(); + ctx.arc(originX, originY, bloomR, 0, Math.PI * 2); + ctx.fill(); + + ctx.lineWidth = 0.7; + ctx.lineCap = "round"; + + for (let i = 0; i < rays.length; i++) { + const ray = rays[i]; + const lp = easeOut( + Math.min(1, Math.max(0, (prog - ray.reveal) / (1 - 0.54))), + ); + if (lp <= 0) continue; + const twinkle = reduced ? 1 : 0.72 + 0.28 * Math.sin(now * 1.4 + ray.phase); + const dx = Math.cos(ray.angle); + const dy = -Math.sin(ray.angle); + const len = ray.length * maxRadius * lp; + const ex = originX + dx * len; + const ey = originY + dy * len; + + const grad = ctx.createLinearGradient(originX, originY, ex, ey); + grad.addColorStop(0, rgba(disp.rayBase, 0.0)); + grad.addColorStop(0.05, rgba(disp.rayBase, 0.98 * twinkle)); + grad.addColorStop(0.4, rgba(disp.rayBase, 0.55 * twinkle)); + grad.addColorStop(1, rgba(disp.rayTip, 0)); + ctx.strokeStyle = grad; + ctx.beginPath(); + ctx.moveTo(originX, originY); + ctx.lineTo(ex, ey); + ctx.stroke(); + + // Dots — drift slowly outward for a living "data" feel. + for (let d = 0; d < ray.dots.length; d++) { + const dot = ray.dots[d]; + const dp = reduced ? dot.p : (dot.p + now * 0.025) % 1; + if (dp > lp) continue; + const px = originX + dx * ray.length * maxRadius * dp; + const py = originY + dy * ray.length * maxRadius * dp; + const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + dot.phase); + ctx.fillStyle = rgba( + lerpRGB(disp.dotBase, disp.dotTip, dp), + (dark ? 0.85 : 0.9) * (0.4 + 0.6 * dtw) * (1 - dp * 0.35), + ); + ctx.beginPath(); + ctx.arc(px, py, dot.r, 0, Math.PI * 2); + ctx.fill(); + } + } + + ctx.globalCompositeOperation = "source-over"; + }; + renderRef.current = render; + + resize(); + const ro = new ResizeObserver(resize); + ro.observe(canvas); + + if (reduced) { + render(); + return () => ro.disconnect(); + } + + const loop = () => { + render(); + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + }; + }, [density, progress, reduced]); + + return ( + + ); +} + +/* ------------------------------------------------------------------ * + * ThemeSwitcher — the corner dropdown. + * ------------------------------------------------------------------ */ + +function ThemeSwitcher({ + theme, + onChange, + mode, +}: { + theme: RadialBurstThemeId; + onChange: (id: RadialBurstThemeId) => void; + mode: "light" | "dark"; +}) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const Current = THEME_BY_ID[theme].Icon; + const dark = mode === "dark"; + + useEffect(() => { + if (!open) return; + const onPointer = (e: PointerEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("pointerdown", onPointer); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("pointerdown", onPointer); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + return ( +
+ + + + {open && ( + + {RADIAL_BURST_THEMES.map((t) => { + const active = t.id === theme; + return ( +
  • + +
  • + ); + })} +
    + )} +
    +
    + ); +} + +/* ------------------------------------------------------------------ * + * Count-up stat (Motion-driven). + * ------------------------------------------------------------------ */ + +type Stat = { + prefix?: string; + value: number; + decimals?: number; + suffix?: string; + label: string; +}; + +const DEFAULT_STATS: Stat[] = [ + { value: 135, suffix: "+", label: "currencies and payment\nmethods supported" }, + { prefix: "US$", value: 1.9, decimals: 1, suffix: "tn", label: "in payments volume\nprocessed in 2025" }, + { value: 99.999, decimals: 3, suffix: "%", label: "historical uptime\nfor Stripe services" }, + { value: 200, suffix: "M+", label: "active subscriptions\nmanaged on Stripe Billing" }, +]; + +function CountUp({ + value, + decimals = 0, + prefix = "", + suffix = "", +}: Stat) { + const ref = useRef(null); + const reduced = useReducedMotion(); + const fmt = (v: number) => { + const fixed = v.toFixed(decimals); + const [int, dec] = fixed.split("."); + const withSep = int.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + return `${prefix}${dec !== undefined ? `${withSep}.${dec}` : withSep}${suffix}`; + }; + + useEffect(() => { + const node = ref.current; + if (!node) return; + if (reduced) { + node.textContent = fmt(value); + return; + } + const controls = animate(0, value, { + duration: 1.8, + ease: [0.22, 1, 0.36, 1], + onUpdate: (v) => { + node.textContent = fmt(v); + }, + }); + return () => controls.stop(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value, decimals, reduced]); + + return ( + + {fmt(value)} + + ); +} + +/* ------------------------------------------------------------------ * + * RadialBurstHero — full composition. + * ------------------------------------------------------------------ */ + +const containerVariants: Variants = { + hidden: {}, + visible: { transition: { staggerChildren: 0.08, delayChildren: 0.1 } }, +}; +const itemVariants: Variants = { + hidden: { opacity: 0, y: 16 }, + visible: { + opacity: 1, + y: 0, + transition: { type: "spring", stiffness: 140, damping: 20 }, + }, +}; + +export type RadialBurstHeroProps = Omit< + ComponentProps<"section">, + "children" | "title" +> & { + /** Initial theme; also synced if it changes (e.g. from a site toggle). */ + defaultTheme?: RadialBurstThemeId; + title?: ReactNode; + stats?: Stat[]; + burstProps?: Omit; +}; + +export function RadialBurstHero({ + className, + defaultTheme = "night", + title = ( + <> + The backbone +
    + of global commerce + + ), + stats = DEFAULT_STATS, + burstProps, + ...rest +}: RadialBurstHeroProps) { + const [theme, setTheme] = useState(defaultTheme); + const reduced = useReducedMotion(); + + // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme. + useEffect(() => { + setTheme(defaultTheme); + }, [defaultTheme]); + + const palette = THEME_BY_ID[theme].palette; + const dark = palette.mode === "dark"; + + return ( +
    + {/* Background gradient — crossfades between themes. */} + + + + + {/* Canvas burst. */} + + + {/* Theme switcher. */} +
    + +
    + + {/* Content. */} + + + {title} + + + + {stats.map((stat, i) => ( +
    +
    + +
    +

    + {stat.label} +

    +
    + ))} +
    +
    +
    + ); +} + +export default RadialBurstHero; diff --git a/apps/www/config/components.ts b/apps/www/config/components.ts index 28e12f71..652fb6d7 100644 --- a/apps/www/config/components.ts +++ b/apps/www/config/components.ts @@ -29,6 +29,7 @@ import { LucideScanLine, LucideScrollText, LucideShield, + LucideSparkle, LucideSparkles, LucideSquare, LucideStars, @@ -101,6 +102,7 @@ const iconMap = { LucideScanLine, LucideScrollText, LucideShield, + LucideSparkle, LucideSparkles, LucideSquare, LucideStars, @@ -3421,6 +3423,44 @@ const componentDefinitions = [ } ], "usageCode": "import { FlowFieldHero } from \"@/components/ui/hero-flow-field\";\n\nexport default function Hero() {\n return (\n \n \n \n Field · scroll to flow\n \n

    \n Momentum you can see.\n

    \n

    \n A canvas flow field of streamlines that bend and wave with the page\n scroll position. Scroll-driven, not cursor-driven — no external deps.\n

    \n
    \n \n Get started\n \n →\n \n \n \n View source\n \n
    \n
    \n );\n}" + }, + { + "slug": "hero-radial-burst", + "name": "Radial Burst Hero", + "description": "A Stripe-style hero block: a canvas radial burst of fine rays — each fading from a bright bottom-center core to faint tips, with dots drifting outward along them — beneath a headline and a count-up stat row. Ships with six time-of-day themes (Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night) and an in-block switcher; the background crossfades and the burst colors lerp between themes. devicePixelRatio-aware, keyboard-accessible switcher, honors `prefers-reduced-motion`, and uses `motion` for the reveal, theme blend, count-ups, and dropdown.", + "icon": "LucideSparkle", + "category": "Hero", + "kind": "block", + "addedAt": "2026-06-02", + "props": [ + { + "name": "defaultTheme", + "type": "\"pre-dawn\" | \"sunrise\" | \"daytime\" | \"dusk\" | \"sunset\" | \"night\"", + "default": "\"night\"", + "description": "Initial time-of-day theme. Re-synced if it changes (e.g. from a site theme toggle); the in-block switcher overrides until then." + }, + { + "name": "title", + "type": "ReactNode", + "description": "Headline content. Defaults to \"The backbone of global commerce\"." + }, + { + "name": "stats", + "type": "{ prefix?: string; value: number; decimals?: number; suffix?: string; label: string }[]", + "description": "Stat row. Each value counts up on mount; `label` supports newlines. Defaults to the four Stripe-style stats." + }, + { + "name": "burstProps", + "type": "{ className?: string; density?: number }", + "description": "Forwarded to the canvas burst layer (`RadialBurst`). `density` scales ray count (0.4–2)." + }, + { + "name": "className", + "type": "string", + "description": "Classes for the outer `
    `." + } + ], + "usageCode": "import { RadialBurstHero } from \"@/components/ui/hero-radial-burst\";\n\nexport default function Hero() {\n return ;\n}\n\n// Or drive the time-of-day theme + content yourself:\n// Built for\\n global scale}\n// stats={[\n// { value: 135, suffix: \"+\", label: \"currencies supported\" },\n// { prefix: \"US$\", value: 1.9, decimals: 1, suffix: \"tn\", label: \"processed in 2025\" },\n// { value: 99.999, decimals: 3, suffix: \"%\", label: \"historical uptime\" },\n// { value: 200, suffix: \"M+\", label: \"active subscriptions\" },\n// ]}\n// />" } ] satisfies ComponentDefinition[]; diff --git a/apps/www/config/demos.tsx b/apps/www/config/demos.tsx index ec7003b1..e153e7ad 100644 --- a/apps/www/config/demos.tsx +++ b/apps/www/config/demos.tsx @@ -79,6 +79,7 @@ import { LogoMarqueeHero } from "@/components/ui/hero-logo-marquee"; import { MagneticLettersHero } from "@/components/ui/hero-magnetic-letters"; import { TerminalHero } from "@/components/ui/hero-terminal"; import { FlowFieldHero } from "@/components/ui/hero-flow-field"; +import { RadialBurstHero } from "@/components/ui/hero-radial-burst"; import { motion } from "motion/react"; import { useRef, useState } from "react"; import { @@ -3834,4 +3835,7 @@ export const componentDemos: Record = { "hero-flow-field": ({ theme = "dark" }) => ( ), + "hero-radial-burst": ({ theme = "dark" }) => ( + + ), }; diff --git a/apps/www/config/docs-scenarios.ts b/apps/www/config/docs-scenarios.ts index d16bd6ed..542cb7db 100644 --- a/apps/www/config/docs-scenarios.ts +++ b/apps/www/config/docs-scenarios.ts @@ -1126,5 +1126,10 @@ export const docsScenarios: Record = { "slug": "hero-flow-field", "overview": "Scroll-driven canvas flow field. Particles flow along a summed-sine vector field and leave fading comet trails; the field's phase tracks `window.scrollY`, so the streamlines bend and wave as the page scrolls, with a slow idle drift keeping it alive at rest. A centered radial scrim lifts text contrast over the field, and the `theme` prop switches the whole palette (field, scrim, text, CTAs) between light and dark. `FlowFieldBackground` is a separate export and can be reused under any composition.", "scenarios": [] + }, + "hero-radial-burst": { + "slug": "hero-radial-burst", + "overview": "A canvas radial burst rises from a bright bottom-center core: ~200 fine rays fan across the upper semicircle (longest near vertical, forming a dome), each a base-bright→tip-faint gradient with dots drifting outward. Above it sit a headline and a count-up stat row. Six time-of-day themes — Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night — are selectable from an in-block dropdown; switching crossfades the background gradient and eases the burst colors between palettes. `RadialBurst` is exported separately for reuse under your own layout.", + "scenarios": [] } }; diff --git a/apps/www/public/r/hero-radial-burst.json b/apps/www/public/r/hero-radial-burst.json new file mode 100644 index 00000000..82f5fb33 --- /dev/null +++ b/apps/www/public/r/hero-radial-burst.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "hero-radial-burst", + "type": "registry:ui", + "title": "Radial Burst Hero", + "description": "A Stripe-style hero block: a canvas radial burst of fine rays — each fading from a bright bottom-center core to faint tips, with dots drifting outward along them — beneath a headline and a count-up stat row. Ships with six time-of-day themes (Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night) and an in-block switcher; the background crossfades and the burst colors lerp between themes. devicePixelRatio-aware, keyboard-accessible switcher, honors `prefers-reduced-motion`, and uses `motion` for the reveal, theme blend, count-ups, and dropdown.", + "dependencies": [ + "motion", + "lucide-react", + "clsx", + "tailwind-merge" + ], + "files": [ + { + "path": "components/ui/hero-radial-burst.tsx", + "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n type Variants,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n\ntype Ray = {\n angle: number;\n length: number; // fraction of maxRadius\n reveal: number; // 0..1 stagger delay\n phase: number; // twinkle offset\n dots: { p: number; r: number; phase: number }[];\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the canvas background (reusable on its own).\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n}: RadialBurstProps) {\n const canvasRef = useRef(null);\n const reduced = useReducedMotion();\n\n // Reveal progress driven by Motion — read inside the canvas rAF loop.\n const progress = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<() => void>(() => {});\n\n // Mount reveal animation (Motion).\n useEffect(() => {\n if (reduced) {\n progress.set(1);\n return;\n }\n const controls = animate(progress, 1, {\n duration: 1.7,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [progress, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current();\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n originY = h * 0.99;\n maxRadius = h * 0.94;\n const count = Math.round(\n Math.min(340, Math.max(120, w / 4.8)) *\n Math.min(2, Math.max(0.4, density)),\n );\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n rays = Array.from({ length: count }, (_, i) => {\n const t = count > 1 ? i / (count - 1) : 0.5;\n const angle = lerp(aMin, aMax, t) + (Math.random() - 0.5) * 0.012;\n // Closeness to vertical → longer rays → a dome silhouette.\n const vert = Math.sin(Math.min(Math.PI, Math.max(0, angle)));\n const length =\n (0.34 + 0.66 * vert) * (0.78 + Math.random() * 0.3) +\n (Math.random() < 0.06 ? 0.12 : 0);\n const dotCount = 1 + Math.floor(Math.random() * 4);\n return {\n angle,\n length: Math.min(1.05, length),\n reveal: (1 - vert) * 0.42 + Math.random() * 0.12,\n phase: Math.random() * Math.PI * 2,\n dots: Array.from({ length: dotCount }, () => ({\n p: 0.2 + Math.random() * 0.8,\n r: 0.6 + Math.random() * 0.9,\n phase: Math.random() * Math.PI * 2,\n })),\n };\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current();\n };\n\n const render = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const prog = progress.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n\n // Central bloom.\n const bloomR = Math.min(w * 0.22, maxRadius * 0.5) * (0.6 + 0.4 * prog);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, dark ? 0.7 : 0.6));\n bloom.addColorStop(0.45, rgba(disp.core, dark ? 0.22 : 0.18));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n ctx.lineWidth = 0.7;\n ctx.lineCap = \"round\";\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n const lp = easeOut(\n Math.min(1, Math.max(0, (prog - ray.reveal) / (1 - 0.54))),\n );\n if (lp <= 0) continue;\n const twinkle = reduced ? 1 : 0.72 + 0.28 * Math.sin(now * 1.4 + ray.phase);\n const dx = Math.cos(ray.angle);\n const dy = -Math.sin(ray.angle);\n const len = ray.length * maxRadius * lp;\n const ex = originX + dx * len;\n const ey = originY + dy * len;\n\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0.0));\n grad.addColorStop(0.05, rgba(disp.rayBase, 0.98 * twinkle));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.55 * twinkle));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Dots — drift slowly outward for a living \"data\" feel.\n for (let d = 0; d < ray.dots.length; d++) {\n const dot = ray.dots[d];\n const dp = reduced ? dot.p : (dot.p + now * 0.025) % 1;\n if (dp > lp) continue;\n const px = originX + dx * ray.length * maxRadius * dp;\n const py = originY + dy * ray.length * maxRadius * dp;\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + dot.phase);\n ctx.fillStyle = rgba(\n lerpRGB(disp.dotBase, disp.dotTip, dp),\n (dark ? 0.85 : 0.9) * (0.4 + 0.6 * dtw) * (1 - dp * 0.35),\n );\n ctx.beginPath();\n ctx.arc(px, py, dot.r, 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render();\n return () => ro.disconnect();\n }\n\n const loop = () => {\n render();\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n };\n }, [density, progress, reduced]);\n\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n
    \n setOpen((v) => !v)}\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n \n \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n
    \n );\n}\n\n/* ------------------------------------------------------------------ *\n * Count-up stat (Motion-driven).\n * ------------------------------------------------------------------ */\n\ntype Stat = {\n prefix?: string;\n value: number;\n decimals?: number;\n suffix?: string;\n label: string;\n};\n\nconst DEFAULT_STATS: Stat[] = [\n { value: 135, suffix: \"+\", label: \"currencies and payment\\nmethods supported\" },\n { prefix: \"US$\", value: 1.9, decimals: 1, suffix: \"tn\", label: \"in payments volume\\nprocessed in 2025\" },\n { value: 99.999, decimals: 3, suffix: \"%\", label: \"historical uptime\\nfor Stripe services\" },\n { value: 200, suffix: \"M+\", label: \"active subscriptions\\nmanaged on Stripe Billing\" },\n];\n\nfunction CountUp({\n value,\n decimals = 0,\n prefix = \"\",\n suffix = \"\",\n}: Stat) {\n const ref = useRef(null);\n const reduced = useReducedMotion();\n const fmt = (v: number) => {\n const fixed = v.toFixed(decimals);\n const [int, dec] = fixed.split(\".\");\n const withSep = int.replace(/\\B(?=(\\d{3})+(?!\\d))/g, \",\");\n return `${prefix}${dec !== undefined ? `${withSep}.${dec}` : withSep}${suffix}`;\n };\n\n useEffect(() => {\n const node = ref.current;\n if (!node) return;\n if (reduced) {\n node.textContent = fmt(value);\n return;\n }\n const controls = animate(0, value, {\n duration: 1.8,\n ease: [0.22, 1, 0.36, 1],\n onUpdate: (v) => {\n node.textContent = fmt(v);\n },\n });\n return () => controls.stop();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [value, decimals, reduced]);\n\n return (\n \n {fmt(value)}\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition.\n * ------------------------------------------------------------------ */\n\nconst containerVariants: Variants = {\n hidden: {},\n visible: { transition: { staggerChildren: 0.08, delayChildren: 0.1 } },\n};\nconst itemVariants: Variants = {\n hidden: { opacity: 0, y: 16 },\n visible: {\n opacity: 1,\n y: 0,\n transition: { type: \"spring\", stiffness: 140, damping: 20 },\n },\n};\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n stats?: Stat[];\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n stats = DEFAULT_STATS,\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Canvas burst. */}\n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Content. */}\n \n \n {title}\n \n\n \n {stats.map((stat, i) => (\n
    \n \n \n
    \n \n {stat.label}\n

    \n \n ))}\n \n \n
    \n );\n}\n\nexport default RadialBurstHero;\n", + "type": "registry:component", + "target": "components/ui/hero-radial-burst.tsx" + } + ] +} diff --git a/apps/www/public/r/registry.json b/apps/www/public/r/registry.json index ddf2ff48..db3e64b5 100644 --- a/apps/www/public/r/registry.json +++ b/apps/www/public/r/registry.json @@ -1197,6 +1197,25 @@ "target": "components/ui/magnetic-text.tsx" } ] + }, + { + "name": "hero-radial-burst", + "type": "registry:ui", + "title": "Radial Burst Hero", + "description": "A Stripe-style hero block: a canvas radial burst of fine rays — each fading from a bright bottom-center core to faint tips, with dots drifting outward along them — beneath a headline and a count-up stat row. Ships with six time-of-day themes (Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night) and an in-block switcher; the background crossfades and the burst colors lerp between themes. devicePixelRatio-aware, keyboard-accessible switcher, honors `prefers-reduced-motion`, and uses `motion` for the reveal, theme blend, count-ups, and dropdown.", + "dependencies": [ + "motion", + "lucide-react", + "clsx", + "tailwind-merge" + ], + "files": [ + { + "path": "components/ui/hero-radial-burst.tsx", + "type": "registry:component", + "target": "components/ui/hero-radial-burst.tsx" + } + ] } ] } diff --git a/apps/www/public/registry.json b/apps/www/public/registry.json index c3d3ede6..a8c21105 100644 --- a/apps/www/public/registry.json +++ b/apps/www/public/registry.json @@ -4101,5 +4101,64 @@ "motion": { "reducedMotion": "full" } + }, + { + "name": "hero-radial-burst", + "dependencies": [ + "motion", + "lucide-react", + "clsx", + "tailwind-merge" + ], + "files": [ + { + "path": "blocks/hero/radial-burst/component.tsx", + "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n type Variants,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n\ntype Ray = {\n angle: number;\n length: number; // fraction of maxRadius\n reveal: number; // 0..1 stagger delay\n phase: number; // twinkle offset\n dots: { p: number; r: number; phase: number }[];\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the canvas background (reusable on its own).\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n}: RadialBurstProps) {\n const canvasRef = useRef(null);\n const reduced = useReducedMotion();\n\n // Reveal progress driven by Motion — read inside the canvas rAF loop.\n const progress = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<() => void>(() => {});\n\n // Mount reveal animation (Motion).\n useEffect(() => {\n if (reduced) {\n progress.set(1);\n return;\n }\n const controls = animate(progress, 1, {\n duration: 1.7,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [progress, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current();\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n originY = h * 0.99;\n maxRadius = h * 0.94;\n const count = Math.round(\n Math.min(340, Math.max(120, w / 4.8)) *\n Math.min(2, Math.max(0.4, density)),\n );\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n rays = Array.from({ length: count }, (_, i) => {\n const t = count > 1 ? i / (count - 1) : 0.5;\n const angle = lerp(aMin, aMax, t) + (Math.random() - 0.5) * 0.012;\n // Closeness to vertical → longer rays → a dome silhouette.\n const vert = Math.sin(Math.min(Math.PI, Math.max(0, angle)));\n const length =\n (0.34 + 0.66 * vert) * (0.78 + Math.random() * 0.3) +\n (Math.random() < 0.06 ? 0.12 : 0);\n const dotCount = 1 + Math.floor(Math.random() * 4);\n return {\n angle,\n length: Math.min(1.05, length),\n reveal: (1 - vert) * 0.42 + Math.random() * 0.12,\n phase: Math.random() * Math.PI * 2,\n dots: Array.from({ length: dotCount }, () => ({\n p: 0.2 + Math.random() * 0.8,\n r: 0.6 + Math.random() * 0.9,\n phase: Math.random() * Math.PI * 2,\n })),\n };\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current();\n };\n\n const render = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const prog = progress.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n\n // Central bloom.\n const bloomR = Math.min(w * 0.22, maxRadius * 0.5) * (0.6 + 0.4 * prog);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, dark ? 0.7 : 0.6));\n bloom.addColorStop(0.45, rgba(disp.core, dark ? 0.22 : 0.18));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n ctx.lineWidth = 0.7;\n ctx.lineCap = \"round\";\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n const lp = easeOut(\n Math.min(1, Math.max(0, (prog - ray.reveal) / (1 - 0.54))),\n );\n if (lp <= 0) continue;\n const twinkle = reduced ? 1 : 0.72 + 0.28 * Math.sin(now * 1.4 + ray.phase);\n const dx = Math.cos(ray.angle);\n const dy = -Math.sin(ray.angle);\n const len = ray.length * maxRadius * lp;\n const ex = originX + dx * len;\n const ey = originY + dy * len;\n\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0.0));\n grad.addColorStop(0.05, rgba(disp.rayBase, 0.98 * twinkle));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.55 * twinkle));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Dots — drift slowly outward for a living \"data\" feel.\n for (let d = 0; d < ray.dots.length; d++) {\n const dot = ray.dots[d];\n const dp = reduced ? dot.p : (dot.p + now * 0.025) % 1;\n if (dp > lp) continue;\n const px = originX + dx * ray.length * maxRadius * dp;\n const py = originY + dy * ray.length * maxRadius * dp;\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + dot.phase);\n ctx.fillStyle = rgba(\n lerpRGB(disp.dotBase, disp.dotTip, dp),\n (dark ? 0.85 : 0.9) * (0.4 + 0.6 * dtw) * (1 - dp * 0.35),\n );\n ctx.beginPath();\n ctx.arc(px, py, dot.r, 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render();\n return () => ro.disconnect();\n }\n\n const loop = () => {\n render();\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n };\n }, [density, progress, reduced]);\n\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n
    \n setOpen((v) => !v)}\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n \n \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n
    \n );\n}\n\n/* ------------------------------------------------------------------ *\n * Count-up stat (Motion-driven).\n * ------------------------------------------------------------------ */\n\ntype Stat = {\n prefix?: string;\n value: number;\n decimals?: number;\n suffix?: string;\n label: string;\n};\n\nconst DEFAULT_STATS: Stat[] = [\n { value: 135, suffix: \"+\", label: \"currencies and payment\\nmethods supported\" },\n { prefix: \"US$\", value: 1.9, decimals: 1, suffix: \"tn\", label: \"in payments volume\\nprocessed in 2025\" },\n { value: 99.999, decimals: 3, suffix: \"%\", label: \"historical uptime\\nfor Stripe services\" },\n { value: 200, suffix: \"M+\", label: \"active subscriptions\\nmanaged on Stripe Billing\" },\n];\n\nfunction CountUp({\n value,\n decimals = 0,\n prefix = \"\",\n suffix = \"\",\n}: Stat) {\n const ref = useRef(null);\n const reduced = useReducedMotion();\n const fmt = (v: number) => {\n const fixed = v.toFixed(decimals);\n const [int, dec] = fixed.split(\".\");\n const withSep = int.replace(/\\B(?=(\\d{3})+(?!\\d))/g, \",\");\n return `${prefix}${dec !== undefined ? `${withSep}.${dec}` : withSep}${suffix}`;\n };\n\n useEffect(() => {\n const node = ref.current;\n if (!node) return;\n if (reduced) {\n node.textContent = fmt(value);\n return;\n }\n const controls = animate(0, value, {\n duration: 1.8,\n ease: [0.22, 1, 0.36, 1],\n onUpdate: (v) => {\n node.textContent = fmt(v);\n },\n });\n return () => controls.stop();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [value, decimals, reduced]);\n\n return (\n \n {fmt(value)}\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition.\n * ------------------------------------------------------------------ */\n\nconst containerVariants: Variants = {\n hidden: {},\n visible: { transition: { staggerChildren: 0.08, delayChildren: 0.1 } },\n};\nconst itemVariants: Variants = {\n hidden: { opacity: 0, y: 16 },\n visible: {\n opacity: 1,\n y: 0,\n transition: { type: \"spring\", stiffness: 140, damping: 20 },\n },\n};\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n stats?: Stat[];\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n stats = DEFAULT_STATS,\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Canvas burst. */}\n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Content. */}\n \n \n {title}\n \n\n \n {stats.map((stat, i) => (\n
    \n \n \n
    \n \n {stat.label}\n

    \n \n ))}\n \n \n \n );\n}\n\nexport default RadialBurstHero;\n", + "type": "registry:ui", + "integrity": "sha384-858wzmwi4WlpfzklkxQxjFDmEhxfmWG9OTc5CbN2ZvkxlulRZPmXVWJboYrK+/ux" + }, + { + "path": "utils/cn.ts", + "content": "import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n", + "type": "registry:util", + "integrity": "sha384-PFsmUDxsGyTpithwRHqXHK4J46ePXMjvbm5/78jCSJEH1Dsygh4anV8Y/45UrHos" + } + ], + "meta": { + "version": "1.0.0" + }, + "changelog": [ + { + "version": "1.0.0", + "date": "2026-06-02", + "changes": [ + "Initial release." + ] + } + ], + "peerDependencies": [ + "react", + "react-dom" + ], + "tags": [ + "hero", + "block", + "canvas", + "starburst", + "stats" + ], + "compatibility": { + "react": "18+", + "next": "14+", + "tailwind": "3+|4+", + "rsc": false, + "ssr": true + }, + "accessibility": { + "status": "unaudited" + }, + "motion": { + "reducedMotion": "full" + } } ] diff --git a/apps/www/public/registry/changelogs.json b/apps/www/public/registry/changelogs.json index 68aa64cf..326194ce 100644 --- a/apps/www/public/registry/changelogs.json +++ b/apps/www/public/registry/changelogs.json @@ -669,5 +669,14 @@ "Initial release: standalone per-letter cursor-tracking text component. Wraps any string with springs that lean toward the pointer; falloff is linear inside `radius`, zero outside; capped at `strength`. Optional `animateEntry` staggers letters in on mount. `as` prop lets it render as h1/h2/p/span. aria-label on the wrapper + aria-hidden on every glyph keep screen readers from spelling out the word." ] } + ], + "hero-radial-burst": [ + { + "version": "1.0.0", + "date": "2026-06-02", + "changes": [ + "Initial release." + ] + } ] } diff --git a/apps/www/public/registry/hero-radial-burst.json b/apps/www/public/registry/hero-radial-burst.json new file mode 100644 index 00000000..3c56d993 --- /dev/null +++ b/apps/www/public/registry/hero-radial-burst.json @@ -0,0 +1,59 @@ +{ + "name": "hero-radial-burst", + "dependencies": [ + "motion", + "lucide-react", + "clsx", + "tailwind-merge" + ], + "files": [ + { + "path": "blocks/hero/radial-burst/component.tsx", + "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n type Variants,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n\ntype Ray = {\n angle: number;\n length: number; // fraction of maxRadius\n reveal: number; // 0..1 stagger delay\n phase: number; // twinkle offset\n dots: { p: number; r: number; phase: number }[];\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the canvas background (reusable on its own).\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n}: RadialBurstProps) {\n const canvasRef = useRef(null);\n const reduced = useReducedMotion();\n\n // Reveal progress driven by Motion — read inside the canvas rAF loop.\n const progress = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<() => void>(() => {});\n\n // Mount reveal animation (Motion).\n useEffect(() => {\n if (reduced) {\n progress.set(1);\n return;\n }\n const controls = animate(progress, 1, {\n duration: 1.7,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [progress, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current();\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n originY = h * 0.99;\n maxRadius = h * 0.94;\n const count = Math.round(\n Math.min(340, Math.max(120, w / 4.8)) *\n Math.min(2, Math.max(0.4, density)),\n );\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n rays = Array.from({ length: count }, (_, i) => {\n const t = count > 1 ? i / (count - 1) : 0.5;\n const angle = lerp(aMin, aMax, t) + (Math.random() - 0.5) * 0.012;\n // Closeness to vertical → longer rays → a dome silhouette.\n const vert = Math.sin(Math.min(Math.PI, Math.max(0, angle)));\n const length =\n (0.34 + 0.66 * vert) * (0.78 + Math.random() * 0.3) +\n (Math.random() < 0.06 ? 0.12 : 0);\n const dotCount = 1 + Math.floor(Math.random() * 4);\n return {\n angle,\n length: Math.min(1.05, length),\n reveal: (1 - vert) * 0.42 + Math.random() * 0.12,\n phase: Math.random() * Math.PI * 2,\n dots: Array.from({ length: dotCount }, () => ({\n p: 0.2 + Math.random() * 0.8,\n r: 0.6 + Math.random() * 0.9,\n phase: Math.random() * Math.PI * 2,\n })),\n };\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current();\n };\n\n const render = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const prog = progress.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n\n // Central bloom.\n const bloomR = Math.min(w * 0.22, maxRadius * 0.5) * (0.6 + 0.4 * prog);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, dark ? 0.7 : 0.6));\n bloom.addColorStop(0.45, rgba(disp.core, dark ? 0.22 : 0.18));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n ctx.lineWidth = 0.7;\n ctx.lineCap = \"round\";\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n const lp = easeOut(\n Math.min(1, Math.max(0, (prog - ray.reveal) / (1 - 0.54))),\n );\n if (lp <= 0) continue;\n const twinkle = reduced ? 1 : 0.72 + 0.28 * Math.sin(now * 1.4 + ray.phase);\n const dx = Math.cos(ray.angle);\n const dy = -Math.sin(ray.angle);\n const len = ray.length * maxRadius * lp;\n const ex = originX + dx * len;\n const ey = originY + dy * len;\n\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0.0));\n grad.addColorStop(0.05, rgba(disp.rayBase, 0.98 * twinkle));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.55 * twinkle));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Dots — drift slowly outward for a living \"data\" feel.\n for (let d = 0; d < ray.dots.length; d++) {\n const dot = ray.dots[d];\n const dp = reduced ? dot.p : (dot.p + now * 0.025) % 1;\n if (dp > lp) continue;\n const px = originX + dx * ray.length * maxRadius * dp;\n const py = originY + dy * ray.length * maxRadius * dp;\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + dot.phase);\n ctx.fillStyle = rgba(\n lerpRGB(disp.dotBase, disp.dotTip, dp),\n (dark ? 0.85 : 0.9) * (0.4 + 0.6 * dtw) * (1 - dp * 0.35),\n );\n ctx.beginPath();\n ctx.arc(px, py, dot.r, 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render();\n return () => ro.disconnect();\n }\n\n const loop = () => {\n render();\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n };\n }, [density, progress, reduced]);\n\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n
    \n setOpen((v) => !v)}\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n \n \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n
    \n );\n}\n\n/* ------------------------------------------------------------------ *\n * Count-up stat (Motion-driven).\n * ------------------------------------------------------------------ */\n\ntype Stat = {\n prefix?: string;\n value: number;\n decimals?: number;\n suffix?: string;\n label: string;\n};\n\nconst DEFAULT_STATS: Stat[] = [\n { value: 135, suffix: \"+\", label: \"currencies and payment\\nmethods supported\" },\n { prefix: \"US$\", value: 1.9, decimals: 1, suffix: \"tn\", label: \"in payments volume\\nprocessed in 2025\" },\n { value: 99.999, decimals: 3, suffix: \"%\", label: \"historical uptime\\nfor Stripe services\" },\n { value: 200, suffix: \"M+\", label: \"active subscriptions\\nmanaged on Stripe Billing\" },\n];\n\nfunction CountUp({\n value,\n decimals = 0,\n prefix = \"\",\n suffix = \"\",\n}: Stat) {\n const ref = useRef(null);\n const reduced = useReducedMotion();\n const fmt = (v: number) => {\n const fixed = v.toFixed(decimals);\n const [int, dec] = fixed.split(\".\");\n const withSep = int.replace(/\\B(?=(\\d{3})+(?!\\d))/g, \",\");\n return `${prefix}${dec !== undefined ? `${withSep}.${dec}` : withSep}${suffix}`;\n };\n\n useEffect(() => {\n const node = ref.current;\n if (!node) return;\n if (reduced) {\n node.textContent = fmt(value);\n return;\n }\n const controls = animate(0, value, {\n duration: 1.8,\n ease: [0.22, 1, 0.36, 1],\n onUpdate: (v) => {\n node.textContent = fmt(v);\n },\n });\n return () => controls.stop();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [value, decimals, reduced]);\n\n return (\n \n {fmt(value)}\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition.\n * ------------------------------------------------------------------ */\n\nconst containerVariants: Variants = {\n hidden: {},\n visible: { transition: { staggerChildren: 0.08, delayChildren: 0.1 } },\n};\nconst itemVariants: Variants = {\n hidden: { opacity: 0, y: 16 },\n visible: {\n opacity: 1,\n y: 0,\n transition: { type: \"spring\", stiffness: 140, damping: 20 },\n },\n};\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n stats?: Stat[];\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n stats = DEFAULT_STATS,\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Canvas burst. */}\n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Content. */}\n \n \n {title}\n \n\n \n {stats.map((stat, i) => (\n
    \n \n \n
    \n \n {stat.label}\n

    \n \n ))}\n \n \n \n );\n}\n\nexport default RadialBurstHero;\n", + "type": "registry:ui", + "integrity": "sha384-858wzmwi4WlpfzklkxQxjFDmEhxfmWG9OTc5CbN2ZvkxlulRZPmXVWJboYrK+/ux" + }, + { + "path": "utils/cn.ts", + "content": "import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n", + "type": "registry:util", + "integrity": "sha384-PFsmUDxsGyTpithwRHqXHK4J46ePXMjvbm5/78jCSJEH1Dsygh4anV8Y/45UrHos" + } + ], + "meta": { + "version": "1.0.0" + }, + "changelog": [ + { + "version": "1.0.0", + "date": "2026-06-02", + "changes": [ + "Initial release." + ] + } + ], + "peerDependencies": [ + "react", + "react-dom" + ], + "tags": [ + "hero", + "block", + "canvas", + "starburst", + "stats" + ], + "compatibility": { + "react": "18+", + "next": "14+", + "tailwind": "3+|4+", + "rsc": false, + "ssr": true + }, + "accessibility": { + "status": "unaudited" + }, + "motion": { + "reducedMotion": "full" + } +} diff --git a/apps/www/public/registry/index.json b/apps/www/public/registry/index.json index 7faa92fb..1bda826f 100644 --- a/apps/www/public/registry/index.json +++ b/apps/www/public/registry/index.json @@ -63,6 +63,7 @@ "hero-magnetic-letters", "hero-terminal", "hero-flow-field", - "magnetic-text" + "magnetic-text", + "hero-radial-burst" ] } diff --git a/registry.json b/registry.json index c3d3ede6..a8c21105 100644 --- a/registry.json +++ b/registry.json @@ -4101,5 +4101,64 @@ "motion": { "reducedMotion": "full" } + }, + { + "name": "hero-radial-burst", + "dependencies": [ + "motion", + "lucide-react", + "clsx", + "tailwind-merge" + ], + "files": [ + { + "path": "blocks/hero/radial-burst/component.tsx", + "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n type Variants,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n\ntype Ray = {\n angle: number;\n length: number; // fraction of maxRadius\n reveal: number; // 0..1 stagger delay\n phase: number; // twinkle offset\n dots: { p: number; r: number; phase: number }[];\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the canvas background (reusable on its own).\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n}: RadialBurstProps) {\n const canvasRef = useRef(null);\n const reduced = useReducedMotion();\n\n // Reveal progress driven by Motion — read inside the canvas rAF loop.\n const progress = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<() => void>(() => {});\n\n // Mount reveal animation (Motion).\n useEffect(() => {\n if (reduced) {\n progress.set(1);\n return;\n }\n const controls = animate(progress, 1, {\n duration: 1.7,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [progress, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current();\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n originY = h * 0.99;\n maxRadius = h * 0.94;\n const count = Math.round(\n Math.min(340, Math.max(120, w / 4.8)) *\n Math.min(2, Math.max(0.4, density)),\n );\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n rays = Array.from({ length: count }, (_, i) => {\n const t = count > 1 ? i / (count - 1) : 0.5;\n const angle = lerp(aMin, aMax, t) + (Math.random() - 0.5) * 0.012;\n // Closeness to vertical → longer rays → a dome silhouette.\n const vert = Math.sin(Math.min(Math.PI, Math.max(0, angle)));\n const length =\n (0.34 + 0.66 * vert) * (0.78 + Math.random() * 0.3) +\n (Math.random() < 0.06 ? 0.12 : 0);\n const dotCount = 1 + Math.floor(Math.random() * 4);\n return {\n angle,\n length: Math.min(1.05, length),\n reveal: (1 - vert) * 0.42 + Math.random() * 0.12,\n phase: Math.random() * Math.PI * 2,\n dots: Array.from({ length: dotCount }, () => ({\n p: 0.2 + Math.random() * 0.8,\n r: 0.6 + Math.random() * 0.9,\n phase: Math.random() * Math.PI * 2,\n })),\n };\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current();\n };\n\n const render = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const prog = progress.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n\n // Central bloom.\n const bloomR = Math.min(w * 0.22, maxRadius * 0.5) * (0.6 + 0.4 * prog);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, dark ? 0.7 : 0.6));\n bloom.addColorStop(0.45, rgba(disp.core, dark ? 0.22 : 0.18));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n ctx.lineWidth = 0.7;\n ctx.lineCap = \"round\";\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n const lp = easeOut(\n Math.min(1, Math.max(0, (prog - ray.reveal) / (1 - 0.54))),\n );\n if (lp <= 0) continue;\n const twinkle = reduced ? 1 : 0.72 + 0.28 * Math.sin(now * 1.4 + ray.phase);\n const dx = Math.cos(ray.angle);\n const dy = -Math.sin(ray.angle);\n const len = ray.length * maxRadius * lp;\n const ex = originX + dx * len;\n const ey = originY + dy * len;\n\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0.0));\n grad.addColorStop(0.05, rgba(disp.rayBase, 0.98 * twinkle));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.55 * twinkle));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Dots — drift slowly outward for a living \"data\" feel.\n for (let d = 0; d < ray.dots.length; d++) {\n const dot = ray.dots[d];\n const dp = reduced ? dot.p : (dot.p + now * 0.025) % 1;\n if (dp > lp) continue;\n const px = originX + dx * ray.length * maxRadius * dp;\n const py = originY + dy * ray.length * maxRadius * dp;\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + dot.phase);\n ctx.fillStyle = rgba(\n lerpRGB(disp.dotBase, disp.dotTip, dp),\n (dark ? 0.85 : 0.9) * (0.4 + 0.6 * dtw) * (1 - dp * 0.35),\n );\n ctx.beginPath();\n ctx.arc(px, py, dot.r, 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render();\n return () => ro.disconnect();\n }\n\n const loop = () => {\n render();\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n };\n }, [density, progress, reduced]);\n\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n
    \n setOpen((v) => !v)}\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n \n \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n
    \n );\n}\n\n/* ------------------------------------------------------------------ *\n * Count-up stat (Motion-driven).\n * ------------------------------------------------------------------ */\n\ntype Stat = {\n prefix?: string;\n value: number;\n decimals?: number;\n suffix?: string;\n label: string;\n};\n\nconst DEFAULT_STATS: Stat[] = [\n { value: 135, suffix: \"+\", label: \"currencies and payment\\nmethods supported\" },\n { prefix: \"US$\", value: 1.9, decimals: 1, suffix: \"tn\", label: \"in payments volume\\nprocessed in 2025\" },\n { value: 99.999, decimals: 3, suffix: \"%\", label: \"historical uptime\\nfor Stripe services\" },\n { value: 200, suffix: \"M+\", label: \"active subscriptions\\nmanaged on Stripe Billing\" },\n];\n\nfunction CountUp({\n value,\n decimals = 0,\n prefix = \"\",\n suffix = \"\",\n}: Stat) {\n const ref = useRef(null);\n const reduced = useReducedMotion();\n const fmt = (v: number) => {\n const fixed = v.toFixed(decimals);\n const [int, dec] = fixed.split(\".\");\n const withSep = int.replace(/\\B(?=(\\d{3})+(?!\\d))/g, \",\");\n return `${prefix}${dec !== undefined ? `${withSep}.${dec}` : withSep}${suffix}`;\n };\n\n useEffect(() => {\n const node = ref.current;\n if (!node) return;\n if (reduced) {\n node.textContent = fmt(value);\n return;\n }\n const controls = animate(0, value, {\n duration: 1.8,\n ease: [0.22, 1, 0.36, 1],\n onUpdate: (v) => {\n node.textContent = fmt(v);\n },\n });\n return () => controls.stop();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [value, decimals, reduced]);\n\n return (\n \n {fmt(value)}\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition.\n * ------------------------------------------------------------------ */\n\nconst containerVariants: Variants = {\n hidden: {},\n visible: { transition: { staggerChildren: 0.08, delayChildren: 0.1 } },\n};\nconst itemVariants: Variants = {\n hidden: { opacity: 0, y: 16 },\n visible: {\n opacity: 1,\n y: 0,\n transition: { type: \"spring\", stiffness: 140, damping: 20 },\n },\n};\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n stats?: Stat[];\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n stats = DEFAULT_STATS,\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Canvas burst. */}\n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Content. */}\n \n \n {title}\n \n\n \n {stats.map((stat, i) => (\n
    \n \n \n
    \n \n {stat.label}\n

    \n \n ))}\n \n \n \n );\n}\n\nexport default RadialBurstHero;\n", + "type": "registry:ui", + "integrity": "sha384-858wzmwi4WlpfzklkxQxjFDmEhxfmWG9OTc5CbN2ZvkxlulRZPmXVWJboYrK+/ux" + }, + { + "path": "utils/cn.ts", + "content": "import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n", + "type": "registry:util", + "integrity": "sha384-PFsmUDxsGyTpithwRHqXHK4J46ePXMjvbm5/78jCSJEH1Dsygh4anV8Y/45UrHos" + } + ], + "meta": { + "version": "1.0.0" + }, + "changelog": [ + { + "version": "1.0.0", + "date": "2026-06-02", + "changes": [ + "Initial release." + ] + } + ], + "peerDependencies": [ + "react", + "react-dom" + ], + "tags": [ + "hero", + "block", + "canvas", + "starburst", + "stats" + ], + "compatibility": { + "react": "18+", + "next": "14+", + "tailwind": "3+|4+", + "rsc": false, + "ssr": true + }, + "accessibility": { + "status": "unaudited" + }, + "motion": { + "reducedMotion": "full" + } } ] diff --git a/registry/blocks/hero/radial-burst/component.tsx b/registry/blocks/hero/radial-burst/component.tsx new file mode 100644 index 00000000..179656c8 --- /dev/null +++ b/registry/blocks/hero/radial-burst/component.tsx @@ -0,0 +1,725 @@ +"use client"; + +import { + useEffect, + useRef, + useState, + type ComponentProps, + type ReactNode, +} from "react"; +import { + motion, + AnimatePresence, + animate, + useMotionValue, + useReducedMotion, + type Variants, +} from "motion/react"; +import { + CloudMoon, + Sunrise, + Sun, + SunDim, + Sunset, + Moon, + type LucideIcon, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +/* ------------------------------------------------------------------ * + * Themes — a day cycle. Each palette drives the background gradient, + * the canvas burst colors, and (via `mode`) the text/UI contrast. + * ------------------------------------------------------------------ */ + +export type RadialBurstThemeId = + | "pre-dawn" + | "sunrise" + | "daytime" + | "dusk" + | "sunset" + | "night"; + +type RGB = [number, number, number]; + +type Palette = { + mode: "light" | "dark"; + /** CSS background for the section (crossfaded on theme change). */ + bg: string; + /** Central bloom color. */ + core: RGB; + /** Streamline color near the origin (bright) … */ + rayBase: RGB; + /** … fading to this color at the tip. */ + rayTip: RGB; + /** Dot color near the origin … */ + dotBase: RGB; + /** … to this color at the tip. */ + dotTip: RGB; +}; + +type ThemeDef = { + id: RadialBurstThemeId; + label: string; + Icon: LucideIcon; + palette: Palette; +}; + +export const RADIAL_BURST_THEMES: ThemeDef[] = [ + { + id: "pre-dawn", + label: "Pre-dawn", + Icon: CloudMoon, + palette: { + mode: "dark", + bg: "radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)", + core: [165, 180, 252], + rayBase: [199, 210, 254], + rayTip: [99, 102, 241], + dotBase: [199, 210, 254], + dotTip: [129, 140, 248], + }, + }, + { + id: "sunrise", + label: "Sunrise", + Icon: Sunrise, + palette: { + mode: "light", + bg: "radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)", + core: [147, 197, 253], + rayBase: [37, 99, 235], + rayTip: [96, 165, 250], + dotBase: [29, 78, 216], + dotTip: [59, 130, 246], + }, + }, + { + id: "daytime", + label: "Daytime", + Icon: Sun, + palette: { + mode: "light", + bg: "radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)", + core: [216, 180, 254], + rayBase: [124, 58, 237], + rayTip: [219, 39, 119], + dotBase: [30, 64, 175], + dotTip: [219, 39, 119], + }, + }, + { + id: "dusk", + label: "Dusk", + Icon: SunDim, + palette: { + mode: "light", + bg: "radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)", + core: [167, 139, 250], + rayBase: [109, 40, 217], + rayTip: [236, 72, 153], + dotBase: [76, 29, 149], + dotTip: [219, 39, 119], + }, + }, + { + id: "sunset", + label: "Sunset", + Icon: Sunset, + palette: { + mode: "light", + bg: "radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)", + core: [253, 186, 116], + rayBase: [244, 63, 94], + rayTip: [251, 146, 60], + dotBase: [99, 102, 241], + dotTip: [236, 72, 153], + }, + }, + { + id: "night", + label: "Night", + Icon: Moon, + palette: { + mode: "dark", + bg: "radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)", + core: [224, 231, 255], + rayBase: [237, 233, 254], + rayTip: [129, 140, 248], + dotBase: [224, 231, 255], + dotTip: [165, 180, 252], + }, + }, +]; + +const THEME_BY_ID = Object.fromEntries( + RADIAL_BURST_THEMES.map((t) => [t.id, t]), +) as Record; + +/* ------------------------------------------------------------------ * + * Small helpers + * ------------------------------------------------------------------ */ + +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; +const lerpRGB = (a: RGB, b: RGB, t: number): RGB => [ + lerp(a[0], b[0], t), + lerp(a[1], b[1], t), + lerp(a[2], b[2], t), +]; +const rgba = (c: RGB, a: number) => + `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`; +const easeOut = (t: number) => 1 - Math.pow(1 - t, 3); + +type Ray = { + angle: number; + length: number; // fraction of maxRadius + reveal: number; // 0..1 stagger delay + phase: number; // twinkle offset + dots: { p: number; r: number; phase: number }[]; +}; + +/* ------------------------------------------------------------------ * + * RadialBurst — the canvas background (reusable on its own). + * ------------------------------------------------------------------ */ + +export type RadialBurstProps = { + className?: string; + /** Palette id. */ + theme?: RadialBurstThemeId; + /** Ray-count multiplier (0.4–2). */ + density?: number; +}; + +export function RadialBurst({ + className, + theme = "night", + density = 1, +}: RadialBurstProps) { + const canvasRef = useRef(null); + const reduced = useReducedMotion(); + + // Reveal progress driven by Motion — read inside the canvas rAF loop. + const progress = useMotionValue(reduced ? 1 : 0); + + // Target palette + a smoothed "displayed" palette so theme switches lerp. + const targetRef = useRef(THEME_BY_ID[theme].palette); + const dispRef = useRef<{ + core: RGB; + rayBase: RGB; + rayTip: RGB; + dotBase: RGB; + dotTip: RGB; + }>({ + core: [...THEME_BY_ID[theme].palette.core] as RGB, + rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB, + rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB, + dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB, + dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB, + }); + const renderRef = useRef<() => void>(() => {}); + + // Mount reveal animation (Motion). + useEffect(() => { + if (reduced) { + progress.set(1); + return; + } + const controls = animate(progress, 1, { + duration: 1.7, + ease: [0.22, 1, 0.36, 1], + }); + return () => controls.stop(); + }, [progress, reduced]); + + // Update the target palette when the theme changes; redraw if static. + useEffect(() => { + targetRef.current = THEME_BY_ID[theme].palette; + if (reduced) { + const d = dispRef.current; + const p = targetRef.current; + d.core = [...p.core] as RGB; + d.rayBase = [...p.rayBase] as RGB; + d.rayTip = [...p.rayTip] as RGB; + d.dotBase = [...p.dotBase] as RGB; + d.dotTip = [...p.dotTip] as RGB; + renderRef.current(); + } + }, [theme, reduced]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let raf = 0; + let rays: Ray[] = []; + let maxRadius = 0; + let originX = 0; + let originY = 0; + + const seed = () => { + const w = canvas.clientWidth; + const h = canvas.clientHeight; + originX = w / 2; + originY = h * 0.99; + maxRadius = h * 0.94; + const count = Math.round( + Math.min(340, Math.max(120, w / 4.8)) * + Math.min(2, Math.max(0.4, density)), + ); + const aMin = -0.06 * Math.PI; + const aMax = 1.06 * Math.PI; + rays = Array.from({ length: count }, (_, i) => { + const t = count > 1 ? i / (count - 1) : 0.5; + const angle = lerp(aMin, aMax, t) + (Math.random() - 0.5) * 0.012; + // Closeness to vertical → longer rays → a dome silhouette. + const vert = Math.sin(Math.min(Math.PI, Math.max(0, angle))); + const length = + (0.34 + 0.66 * vert) * (0.78 + Math.random() * 0.3) + + (Math.random() < 0.06 ? 0.12 : 0); + const dotCount = 1 + Math.floor(Math.random() * 4); + return { + angle, + length: Math.min(1.05, length), + reveal: (1 - vert) * 0.42 + Math.random() * 0.12, + phase: Math.random() * Math.PI * 2, + dots: Array.from({ length: dotCount }, () => ({ + p: 0.2 + Math.random() * 0.8, + r: 0.6 + Math.random() * 0.9, + phase: Math.random() * Math.PI * 2, + })), + }; + }); + }; + + const resize = () => { + const { clientWidth, clientHeight } = canvas; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.round(clientWidth * dpr); + canvas.height = Math.round(clientHeight * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + seed(); + if (reduced) renderRef.current(); + }; + + const render = () => { + const w = canvas.clientWidth; + const h = canvas.clientHeight; + const now = performance.now() / 1000; + const prog = progress.get(); + const disp = dispRef.current; + const target = targetRef.current; + + // Ease displayed colors toward the target palette (theme crossfade). + const k = reduced ? 1 : 0.08; + disp.core = lerpRGB(disp.core, target.core, k); + disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k); + disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k); + disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k); + disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k); + + ctx.clearRect(0, 0, w, h); + const dark = target.mode === "dark"; + // Dark themes glow additively; light themes paint normally. + ctx.globalCompositeOperation = dark ? "lighter" : "source-over"; + + // Central bloom. + const bloomR = Math.min(w * 0.22, maxRadius * 0.5) * (0.6 + 0.4 * prog); + const bloom = ctx.createRadialGradient( + originX, + originY, + 0, + originX, + originY, + bloomR, + ); + bloom.addColorStop(0, rgba(disp.core, dark ? 0.7 : 0.6)); + bloom.addColorStop(0.45, rgba(disp.core, dark ? 0.22 : 0.18)); + bloom.addColorStop(1, rgba(disp.core, 0)); + ctx.fillStyle = bloom; + ctx.beginPath(); + ctx.arc(originX, originY, bloomR, 0, Math.PI * 2); + ctx.fill(); + + ctx.lineWidth = 0.7; + ctx.lineCap = "round"; + + for (let i = 0; i < rays.length; i++) { + const ray = rays[i]; + const lp = easeOut( + Math.min(1, Math.max(0, (prog - ray.reveal) / (1 - 0.54))), + ); + if (lp <= 0) continue; + const twinkle = reduced ? 1 : 0.72 + 0.28 * Math.sin(now * 1.4 + ray.phase); + const dx = Math.cos(ray.angle); + const dy = -Math.sin(ray.angle); + const len = ray.length * maxRadius * lp; + const ex = originX + dx * len; + const ey = originY + dy * len; + + const grad = ctx.createLinearGradient(originX, originY, ex, ey); + grad.addColorStop(0, rgba(disp.rayBase, 0.0)); + grad.addColorStop(0.05, rgba(disp.rayBase, 0.98 * twinkle)); + grad.addColorStop(0.4, rgba(disp.rayBase, 0.55 * twinkle)); + grad.addColorStop(1, rgba(disp.rayTip, 0)); + ctx.strokeStyle = grad; + ctx.beginPath(); + ctx.moveTo(originX, originY); + ctx.lineTo(ex, ey); + ctx.stroke(); + + // Dots — drift slowly outward for a living "data" feel. + for (let d = 0; d < ray.dots.length; d++) { + const dot = ray.dots[d]; + const dp = reduced ? dot.p : (dot.p + now * 0.025) % 1; + if (dp > lp) continue; + const px = originX + dx * ray.length * maxRadius * dp; + const py = originY + dy * ray.length * maxRadius * dp; + const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + dot.phase); + ctx.fillStyle = rgba( + lerpRGB(disp.dotBase, disp.dotTip, dp), + (dark ? 0.85 : 0.9) * (0.4 + 0.6 * dtw) * (1 - dp * 0.35), + ); + ctx.beginPath(); + ctx.arc(px, py, dot.r, 0, Math.PI * 2); + ctx.fill(); + } + } + + ctx.globalCompositeOperation = "source-over"; + }; + renderRef.current = render; + + resize(); + const ro = new ResizeObserver(resize); + ro.observe(canvas); + + if (reduced) { + render(); + return () => ro.disconnect(); + } + + const loop = () => { + render(); + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + }; + }, [density, progress, reduced]); + + return ( + + ); +} + +/* ------------------------------------------------------------------ * + * ThemeSwitcher — the corner dropdown. + * ------------------------------------------------------------------ */ + +function ThemeSwitcher({ + theme, + onChange, + mode, +}: { + theme: RadialBurstThemeId; + onChange: (id: RadialBurstThemeId) => void; + mode: "light" | "dark"; +}) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const Current = THEME_BY_ID[theme].Icon; + const dark = mode === "dark"; + + useEffect(() => { + if (!open) return; + const onPointer = (e: PointerEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("pointerdown", onPointer); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("pointerdown", onPointer); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + return ( +
    + + + + {open && ( + + {RADIAL_BURST_THEMES.map((t) => { + const active = t.id === theme; + return ( +
  • + +
  • + ); + })} +
    + )} +
    +
    + ); +} + +/* ------------------------------------------------------------------ * + * Count-up stat (Motion-driven). + * ------------------------------------------------------------------ */ + +type Stat = { + prefix?: string; + value: number; + decimals?: number; + suffix?: string; + label: string; +}; + +const DEFAULT_STATS: Stat[] = [ + { value: 135, suffix: "+", label: "currencies and payment\nmethods supported" }, + { prefix: "US$", value: 1.9, decimals: 1, suffix: "tn", label: "in payments volume\nprocessed in 2025" }, + { value: 99.999, decimals: 3, suffix: "%", label: "historical uptime\nfor Stripe services" }, + { value: 200, suffix: "M+", label: "active subscriptions\nmanaged on Stripe Billing" }, +]; + +function CountUp({ + value, + decimals = 0, + prefix = "", + suffix = "", +}: Stat) { + const ref = useRef(null); + const reduced = useReducedMotion(); + const fmt = (v: number) => { + const fixed = v.toFixed(decimals); + const [int, dec] = fixed.split("."); + const withSep = int.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + return `${prefix}${dec !== undefined ? `${withSep}.${dec}` : withSep}${suffix}`; + }; + + useEffect(() => { + const node = ref.current; + if (!node) return; + if (reduced) { + node.textContent = fmt(value); + return; + } + const controls = animate(0, value, { + duration: 1.8, + ease: [0.22, 1, 0.36, 1], + onUpdate: (v) => { + node.textContent = fmt(v); + }, + }); + return () => controls.stop(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value, decimals, reduced]); + + return ( + + {fmt(value)} + + ); +} + +/* ------------------------------------------------------------------ * + * RadialBurstHero — full composition. + * ------------------------------------------------------------------ */ + +const containerVariants: Variants = { + hidden: {}, + visible: { transition: { staggerChildren: 0.08, delayChildren: 0.1 } }, +}; +const itemVariants: Variants = { + hidden: { opacity: 0, y: 16 }, + visible: { + opacity: 1, + y: 0, + transition: { type: "spring", stiffness: 140, damping: 20 }, + }, +}; + +export type RadialBurstHeroProps = Omit< + ComponentProps<"section">, + "children" | "title" +> & { + /** Initial theme; also synced if it changes (e.g. from a site toggle). */ + defaultTheme?: RadialBurstThemeId; + title?: ReactNode; + stats?: Stat[]; + burstProps?: Omit; +}; + +export function RadialBurstHero({ + className, + defaultTheme = "night", + title = ( + <> + The backbone +
    + of global commerce + + ), + stats = DEFAULT_STATS, + burstProps, + ...rest +}: RadialBurstHeroProps) { + const [theme, setTheme] = useState(defaultTheme); + const reduced = useReducedMotion(); + + // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme. + useEffect(() => { + setTheme(defaultTheme); + }, [defaultTheme]); + + const palette = THEME_BY_ID[theme].palette; + const dark = palette.mode === "dark"; + + return ( +
    + {/* Background gradient — crossfades between themes. */} + + + + + {/* Canvas burst. */} + + + {/* Theme switcher. */} +
    + +
    + + {/* Content. */} + + + {title} + + + + {stats.map((stat, i) => ( +
    +
    + +
    +

    + {stat.label} +

    +
    + ))} +
    +
    +
    + ); +} + +export default RadialBurstHero; diff --git a/registry/blocks/hero/radial-burst/demo.tsx b/registry/blocks/hero/radial-burst/demo.tsx new file mode 100644 index 00000000..dbcef9bc --- /dev/null +++ b/registry/blocks/hero/radial-burst/demo.tsx @@ -0,0 +1,9 @@ +// Demo entries for docs previews — merged by `pnpm build:registry`. +// Do not import this file directly from apps/www. +// `theme` is supplied by ComponentPreview and follows the site theme toggle; +// it seeds the initial time-of-day theme (the in-block switcher takes over). + +export const demoEntries = { + "hero-radial-burst": ({ theme = "dark" }) => ( + + )} as const; diff --git a/registry/components/hero-radial-burst.json b/registry/components/hero-radial-burst.json new file mode 100644 index 00000000..199a3748 --- /dev/null +++ b/registry/components/hero-radial-burst.json @@ -0,0 +1,91 @@ +{ + "slug": "hero-radial-burst", + "registry": { + "dependencies": [ + "motion", + "lucide-react", + "clsx", + "tailwind-merge" + ], + "files": [ + { + "path": "blocks/hero/radial-burst/component.tsx", + "type": "registry:ui" + } + ] + }, + "docs": { + "name": "Radial Burst Hero", + "description": "A Stripe-style hero block: a canvas radial burst of fine rays — each fading from a bright bottom-center core to faint tips, with dots drifting outward along them — beneath a headline and a count-up stat row. Ships with six time-of-day themes (Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night) and an in-block switcher; the background crossfades and the burst colors lerp between themes. devicePixelRatio-aware, keyboard-accessible switcher, honors `prefers-reduced-motion`, and uses `motion` for the reveal, theme blend, count-ups, and dropdown.", + "icon": "LucideSparkle", + "category": "Hero", + "kind": "block", + "addedAt": "2026-06-02", + "props": [ + { + "name": "defaultTheme", + "type": "\"pre-dawn\" | \"sunrise\" | \"daytime\" | \"dusk\" | \"sunset\" | \"night\"", + "default": "\"night\"", + "description": "Initial time-of-day theme. Re-synced if it changes (e.g. from a site theme toggle); the in-block switcher overrides until then." + }, + { + "name": "title", + "type": "ReactNode", + "description": "Headline content. Defaults to \"The backbone of global commerce\"." + }, + { + "name": "stats", + "type": "{ prefix?: string; value: number; decimals?: number; suffix?: string; label: string }[]", + "description": "Stat row. Each value counts up on mount; `label` supports newlines. Defaults to the four Stripe-style stats." + }, + { + "name": "burstProps", + "type": "{ className?: string; density?: number }", + "description": "Forwarded to the canvas burst layer (`RadialBurst`). `density` scales ray count (0.4–2)." + }, + { + "name": "className", + "type": "string", + "description": "Classes for the outer `
    `." + } + ], + "usageCode": "import { RadialBurstHero } from \"@/components/ui/hero-radial-burst\";\n\nexport default function Hero() {\n return ;\n}\n\n// Or drive the time-of-day theme + content yourself:\n// Built for\\n global scale}\n// stats={[\n// { value: 135, suffix: \"+\", label: \"currencies supported\" },\n// { prefix: \"US$\", value: 1.9, decimals: 1, suffix: \"tn\", label: \"processed in 2025\" },\n// { value: 99.999, decimals: 3, suffix: \"%\", label: \"historical uptime\" },\n// { value: 200, suffix: \"M+\", label: \"active subscriptions\" },\n// ]}\n// />", + "docs": { + "overview": "A canvas radial burst rises from a bright bottom-center core: ~200 fine rays fan across the upper semicircle (longest near vertical, forming a dome), each a base-bright→tip-faint gradient with dots drifting outward. Above it sit a headline and a count-up stat row. Six time-of-day themes — Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night — are selectable from an in-block dropdown; switching crossfades the background gradient and eases the burst colors between palettes. `RadialBurst` is exported separately for reuse under your own layout.", + "scenarios": [] + } + }, + "tags": [ + "hero", + "block", + "canvas", + "starburst", + "stats" + ], + "peerDependencies": [ + "react", + "react-dom" + ], + "compatibility": { + "react": "18+", + "next": "14+", + "tailwind": "3+|4+", + "rsc": false, + "ssr": true + }, + "accessibility": { + "status": "unaudited" + }, + "changelog": [ + { + "version": "1.0.0", + "date": "2026-06-02", + "changes": [ + "Initial release." + ] + } + ], + "motion": { + "reducedMotion": "full" + } +} diff --git a/registry/demos/demo-key-order.json b/registry/demos/demo-key-order.json index ebe7be1e..6a7548b7 100644 --- a/registry/demos/demo-key-order.json +++ b/registry/demos/demo-key-order.json @@ -81,5 +81,6 @@ "hero-logo-marquee-thumbnail", "hero-magnetic-letters", "hero-terminal", - "hero-flow-field" + "hero-flow-field", + "hero-radial-burst" ] diff --git a/registry/demos/shared.tsx b/registry/demos/shared.tsx index 8e85eebb..af73336a 100644 --- a/registry/demos/shared.tsx +++ b/registry/demos/shared.tsx @@ -75,6 +75,7 @@ import { LogoMarqueeHero } from "@/components/ui/hero-logo-marquee"; import { MagneticLettersHero } from "@/components/ui/hero-magnetic-letters"; import { TerminalHero } from "@/components/ui/hero-terminal"; import { FlowFieldHero } from "@/components/ui/hero-flow-field"; +import { RadialBurstHero } from "@/components/ui/hero-radial-burst"; import { motion } from "motion/react"; import { useRef, useState } from "react"; import { diff --git a/registry/manifest.json b/registry/manifest.json index ddde313d..8a563627 100644 --- a/registry/manifest.json +++ b/registry/manifest.json @@ -66,7 +66,8 @@ "hero-magnetic-letters", "hero-terminal", "hero-flow-field", - "magnetic-text" + "magnetic-text", + "hero-radial-burst" ], "docsOrder": [ "animated-glowing-text-outline", @@ -132,6 +133,7 @@ "hero-logo-marquee", "hero-magnetic-letters", "hero-terminal", - "hero-flow-field" + "hero-flow-field", + "hero-radial-burst" ] } From 48781de45bd92433fe2b90de33f8985d2d6585b1 Mon Sep 17 00:00:00 2001 From: pras75299 Date: Thu, 4 Jun 2026 16:54:00 +0530 Subject: [PATCH 2/3] feat(hero-radial-burst): rewrite burst as interactive fiber-optic engine --- apps/www/components/ui/hero-radial-burst.tsx | 468 ++++++++++-------- apps/www/config/components.ts | 13 +- apps/www/config/demos.tsx | 11 +- apps/www/config/docs-scenarios.ts | 2 +- apps/www/public/r/hero-radial-burst.json | 4 +- apps/www/public/r/registry.json | 2 +- apps/www/public/registry.json | 25 +- apps/www/public/registry/changelogs.json | 16 + .../public/registry/hero-radial-burst.json | 25 +- registry.json | 25 +- .../blocks/hero/radial-burst/component.tsx | 468 ++++++++++-------- registry/blocks/hero/radial-burst/demo.tsx | 13 +- registry/components/hero-radial-burst.json | 34 +- 13 files changed, 674 insertions(+), 432 deletions(-) diff --git a/apps/www/components/ui/hero-radial-burst.tsx b/apps/www/components/ui/hero-radial-burst.tsx index 179656c8..6b07441b 100644 --- a/apps/www/components/ui/hero-radial-burst.tsx +++ b/apps/www/components/ui/hero-radial-burst.tsx @@ -13,7 +13,6 @@ import { animate, useMotionValue, useReducedMotion, - type Variants, } from "motion/react"; import { CloudMoon, @@ -167,18 +166,61 @@ const lerpRGB = (a: RGB, b: RGB, t: number): RGB => [ ]; const rgba = (c: RGB, a: number) => `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`; +const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v)); const easeOut = (t: number) => 1 - Math.pow(1 - t, 3); +/** Wrap an angle into (-π, π]. */ +const wrapAngle = (a: number) => { + let x = a; + while (x > Math.PI) x -= 2 * Math.PI; + while (x < -Math.PI) x += 2 * Math.PI; + return x; +}; +/** Distance from point (px,py) to segment (ax,ay)-(bx,by). */ +const segDist = ( + px: number, + py: number, + ax: number, + ay: number, + bx: number, + by: number, +) => { + const dx = bx - ax; + const dy = by - ay; + const len2 = dx * dx + dy * dy || 1; + const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1); + const cx = ax + t * dx; + const cy = ay + t * dy; + return Math.hypot(px - cx, py - cy); +}; +/** Horizontal spread multiplier — widens the fan without changing its height. */ +const SPREAD_X = 1.3; + +/** One fiber: a streamline that grows, holds, extends, fades, then respawns. */ type Ray = { - angle: number; - length: number; // fraction of maxRadius - reveal: number; // 0..1 stagger delay + angle: number; // base emission angle (radians) + maxLen: number; // target length as a fraction of maxRadius + speed: number; // life units per second (→ lifetime ≈ 1/speed) + life: number; // < 0 staggered delay, 0..1 active + width: number; // core stroke width + bright: number; // base brightness 0..1 phase: number; // twinkle offset - dots: { p: number; r: number; phase: number }[]; + react: number; // smoothed pointer reaction 0..1 + bend: number; // smoothed angular bend toward the pointer + hasDot: boolean; // whether a glowing dot rides this fiber's tip + dotR: number; // tip-dot radius + dotPhase: number; // tip-dot twinkle offset }; /* ------------------------------------------------------------------ * - * RadialBurst — the canvas background (reusable on its own). + * RadialBurst — the interactive canvas background (reusable on its own). + * + * Fibers stream out of a bottom-center origin in a wide radial fan. Each + * one continuously grows, slightly over-extends, fades, and regenerates + * with fresh angle/length/speed/opacity, while glowing dots travel along + * it. Rays near the pointer brighten, stretch, and bend toward it, then + * ease back to their drift. The burst stays in the lower band so it never + * reaches the headline above. * ------------------------------------------------------------------ */ export type RadialBurstProps = { @@ -187,18 +229,21 @@ export type RadialBurstProps = { theme?: RadialBurstThemeId; /** Ray-count multiplier (0.4–2). */ density?: number; + /** Disable pointer reactivity. */ + interactive?: boolean; }; export function RadialBurst({ className, theme = "night", density = 1, + interactive = true, }: RadialBurstProps) { const canvasRef = useRef(null); const reduced = useReducedMotion(); - // Reveal progress driven by Motion — read inside the canvas rAF loop. - const progress = useMotionValue(reduced ? 1 : 0); + // Global intro fade, driven by Motion — read inside the canvas rAF loop. + const intro = useMotionValue(reduced ? 1 : 0); // Target palette + a smoothed "displayed" palette so theme switches lerp. const targetRef = useRef(THEME_BY_ID[theme].palette); @@ -215,20 +260,20 @@ export function RadialBurst({ dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB, dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB, }); - const renderRef = useRef<() => void>(() => {}); + const renderRef = useRef<(dt: number) => void>(() => {}); - // Mount reveal animation (Motion). + // Mount fade-in (Motion). useEffect(() => { if (reduced) { - progress.set(1); + intro.set(1); return; } - const controls = animate(progress, 1, { - duration: 1.7, + const controls = animate(intro, 1, { + duration: 1.6, ease: [0.22, 1, 0.36, 1], }); return () => controls.stop(); - }, [progress, reduced]); + }, [intro, reduced]); // Update the target palette when the theme changes; redraw if static. useEffect(() => { @@ -241,7 +286,7 @@ export function RadialBurst({ d.rayTip = [...p.rayTip] as RGB; d.dotBase = [...p.dotBase] as RGB; d.dotTip = [...p.dotTip] as RGB; - renderRef.current(); + renderRef.current(0); } }, [theme, reduced]); @@ -257,38 +302,61 @@ export function RadialBurst({ let originX = 0; let originY = 0; + const pointer = { x: 0, y: 0, active: false }; + + const respawn = (ray: Ray, initial: boolean) => { + const aMin = -0.06 * Math.PI; + const aMax = 1.06 * Math.PI; + const angle = lerp(aMin, aMax, Math.random()) + (Math.random() - 0.5) * 0.05; + // Mild bias toward vertical, but side rays stay long so the fan fills + // the full width along the bottom rather than tapering to a dome. + const vert = Math.sin(clamp(angle, 0, Math.PI)); + ray.angle = angle; + ray.maxLen = Math.min( + 1.05, + (0.8 + 0.2 * vert) * (0.6 + Math.random() * 0.45) + + (Math.random() < 0.06 ? 0.12 : 0), + ); + ray.speed = 0.085 + Math.random() * 0.13; // lifetime ≈ 4.5–11.8s + ray.life = initial ? Math.random() : -Math.random() * 0.6; + ray.width = 0.55 + Math.random() * 0.5; + ray.bright = 0.5 + Math.random() * 0.5; + ray.phase = Math.random() * Math.PI * 2; + // A single glowing dot sits at the tip — it rides the growing tip, it + // does not travel along the fiber. + ray.hasDot = Math.random() < 0.72; + ray.dotR = 0.8 + Math.random() * 1; + ray.dotPhase = Math.random() * Math.PI * 2; + }; + const seed = () => { const w = canvas.clientWidth; const h = canvas.clientHeight; originX = w / 2; - originY = h * 0.99; - maxRadius = h * 0.94; + // Origin sits on the bottom edge so the burst touches the bottom. + originY = h; + // Lower-band height — kept well below the headline, ~100px shorter. + maxRadius = Math.max(h * 0.4, h * 0.6 - 100); const count = Math.round( - Math.min(340, Math.max(120, w / 4.8)) * - Math.min(2, Math.max(0.4, density)), + Math.min(400, Math.max(220, w / 3.2)) * clamp(density, 0.4, 2), ); - const aMin = -0.06 * Math.PI; - const aMax = 1.06 * Math.PI; - rays = Array.from({ length: count }, (_, i) => { - const t = count > 1 ? i / (count - 1) : 0.5; - const angle = lerp(aMin, aMax, t) + (Math.random() - 0.5) * 0.012; - // Closeness to vertical → longer rays → a dome silhouette. - const vert = Math.sin(Math.min(Math.PI, Math.max(0, angle))); - const length = - (0.34 + 0.66 * vert) * (0.78 + Math.random() * 0.3) + - (Math.random() < 0.06 ? 0.12 : 0); - const dotCount = 1 + Math.floor(Math.random() * 4); - return { - angle, - length: Math.min(1.05, length), - reveal: (1 - vert) * 0.42 + Math.random() * 0.12, - phase: Math.random() * Math.PI * 2, - dots: Array.from({ length: dotCount }, () => ({ - p: 0.2 + Math.random() * 0.8, - r: 0.6 + Math.random() * 0.9, - phase: Math.random() * Math.PI * 2, - })), + rays = Array.from({ length: count }, () => { + const ray: Ray = { + angle: 0, + maxLen: 0, + speed: 0, + life: 0, + width: 1, + bright: 1, + phase: 0, + react: 0, + bend: 0, + hasDot: false, + dotR: 1, + dotPhase: 0, }; + respawn(ray, true); + return ray; }); }; @@ -299,14 +367,14 @@ export function RadialBurst({ canvas.height = Math.round(clientHeight * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); seed(); - if (reduced) renderRef.current(); + if (reduced) renderRef.current(0); }; - const render = () => { + const render = (dt: number) => { const w = canvas.clientWidth; const h = canvas.clientHeight; const now = performance.now() / 1000; - const prog = progress.get(); + const introA = intro.get(); const disp = dispRef.current; const target = targetRef.current; @@ -322,9 +390,11 @@ export function RadialBurst({ const dark = target.mode === "dark"; // Dark themes glow additively; light themes paint normally. ctx.globalCompositeOperation = dark ? "lighter" : "source-over"; + ctx.lineCap = "round"; - // Central bloom. - const bloomR = Math.min(w * 0.22, maxRadius * 0.5) * (0.6 + 0.4 * prog); + // Central bloom — a soft, diffuse glow rather than a hard bright disc. + const bloomR = + Math.min(w * 0.22, maxRadius * 0.6) * (0.7 + 0.3 * introA); const bloom = ctx.createRadialGradient( originX, originY, @@ -333,59 +403,137 @@ export function RadialBurst({ originY, bloomR, ); - bloom.addColorStop(0, rgba(disp.core, dark ? 0.7 : 0.6)); - bloom.addColorStop(0.45, rgba(disp.core, dark ? 0.22 : 0.18)); + bloom.addColorStop(0, rgba(disp.core, (dark ? 0.5 : 0.44) * introA)); + bloom.addColorStop(0.3, rgba(disp.core, (dark ? 0.18 : 0.15) * introA)); + bloom.addColorStop(0.65, rgba(disp.core, (dark ? 0.06 : 0.05) * introA)); bloom.addColorStop(1, rgba(disp.core, 0)); ctx.fillStyle = bloom; ctx.beginPath(); ctx.arc(originX, originY, bloomR, 0, Math.PI * 2); ctx.fill(); - ctx.lineWidth = 0.7; - ctx.lineCap = "round"; + const pointerOn = interactive && !reduced && pointer.active; + const reactR = 170; // px radius of pointer influence + // No reaction near the origin — only the middle/tip of fibers respond. + const originGuard = maxRadius * 0.22; + const pointerNearOrigin = + Math.hypot(pointer.x - originX, pointer.y - originY) < originGuard; for (let i = 0; i < rays.length; i++) { const ray = rays[i]; - const lp = easeOut( - Math.min(1, Math.max(0, (prog - ray.reveal) / (1 - 0.54))), + + if (!reduced) { + ray.life += ray.speed * dt; + if (ray.life >= 1) respawn(ray, false); + } + if (ray.life < 0) continue; + + const life = reduced ? 0.62 : ray.life; + const growT = clamp(life / 0.7, 0, 1); + const lenFrac = easeOut(growT); + const extend = life > 0.7 ? (life - 0.7) / 0.3 : 0; + const env = reduced + ? 1 + : Math.min(1, life / 0.12) * + (life > 0.8 ? clamp(1 - (life - 0.8) / 0.2, 0, 1) : 1); + if (env <= 0) continue; + + const baseLen = + ray.maxLen * maxRadius * lenFrac * (1 + 0.06 * extend); + + // Pointer reaction — engage quickly, return slowly. Hit-test only the + // outer 35%→tip span so the dense near-origin zone stays calm. + if (pointerOn && !pointerNearOrigin) { + const cx = Math.cos(ray.angle) * SPREAD_X; + const cy = -Math.sin(ray.angle); + const ix = originX + cx * baseLen * 0.35; + const iy = originY + cy * baseLen * 0.35; + const bx = originX + cx * baseLen; + const by = originY + cy * baseLen; + const d = segDist(pointer.x, pointer.y, ix, iy, bx, by); + let reactTarget = 0; + let bendTarget = 0; + if (d < reactR) { + reactTarget = 1 - d / reactR; + reactTarget *= reactTarget; + const pAng = Math.atan2( + -(pointer.y - originY), + (pointer.x - originX) / SPREAD_X, + ); + bendTarget = clamp(wrapAngle(pAng - ray.angle), -0.4, 0.4); + } + ray.react += + (reactTarget - ray.react) * (reactTarget > ray.react ? 0.14 : 0.06); + ray.bend += (bendTarget * ray.react - ray.bend) * 0.1; + } else if (ray.react !== 0 || ray.bend !== 0) { + ray.react += -ray.react * 0.06; + ray.bend += -ray.bend * 0.1; + } + + const react = ray.react; + const drawAngle = ray.angle + ray.bend; + const dirx = Math.cos(drawAngle) * SPREAD_X; + const diry = -Math.sin(drawAngle); + const effLen = baseLen * (1 + 0.12 * react); + const ex = originX + dirx * effLen; + const ey = originY + diry * effLen; + + const twinkle = reduced ? 1 : 0.82 + 0.18 * Math.sin(now * 1.3 + ray.phase); + const aBase = clamp( + env * introA * ray.bright * twinkle * (1 + 0.9 * react), + 0, + 1, ); - if (lp <= 0) continue; - const twinkle = reduced ? 1 : 0.72 + 0.28 * Math.sin(now * 1.4 + ray.phase); - const dx = Math.cos(ray.angle); - const dy = -Math.sin(ray.angle); - const len = ray.length * maxRadius * lp; - const ex = originX + dx * len; - const ey = originY + dy * len; + // Brightness builds up *along* the fiber: near-zero through the dense + // convergence zone at the origin (so overlapping starts don't blow out + // to white), peaking once the fibers have fanned apart, fading at the tip. const grad = ctx.createLinearGradient(originX, originY, ex, ey); - grad.addColorStop(0, rgba(disp.rayBase, 0.0)); - grad.addColorStop(0.05, rgba(disp.rayBase, 0.98 * twinkle)); - grad.addColorStop(0.4, rgba(disp.rayBase, 0.55 * twinkle)); + grad.addColorStop(0, rgba(disp.rayBase, 0)); + grad.addColorStop(0.16, rgba(disp.rayBase, 0.22 * aBase)); + grad.addColorStop(0.4, rgba(disp.rayBase, 0.85 * aBase)); + grad.addColorStop(0.75, rgba(disp.rayBase, 0.36 * aBase)); grad.addColorStop(1, rgba(disp.rayTip, 0)); ctx.strokeStyle = grad; + + // Soft wide pass → subtle blur/glow. + ctx.lineWidth = ray.width * (dark ? 3.2 : 2.6); + ctx.globalAlpha = dark ? 0.22 : 0.18; + ctx.beginPath(); + ctx.moveTo(originX, originY); + ctx.lineTo(ex, ey); + ctx.stroke(); + + // Crisp core pass. + ctx.lineWidth = ray.width; + ctx.globalAlpha = 1; ctx.beginPath(); ctx.moveTo(originX, originY); ctx.lineTo(ex, ey); ctx.stroke(); - // Dots — drift slowly outward for a living "data" feel. - for (let d = 0; d < ray.dots.length; d++) { - const dot = ray.dots[d]; - const dp = reduced ? dot.p : (dot.p + now * 0.025) % 1; - if (dp > lp) continue; - const px = originX + dx * ray.length * maxRadius * dp; - const py = originY + dy * ray.length * maxRadius * dp; - const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + dot.phase); + // A single glowing dot sits at the very tip — it rides the growing + // tip but never travels along the fiber. + if (ray.hasDot) { + const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + ray.dotPhase); + // Dim dots whose tip still sits inside the convergence zone. + const tipFade = clamp(effLen / (maxRadius * 0.28), 0, 1); ctx.fillStyle = rgba( - lerpRGB(disp.dotBase, disp.dotTip, dp), - (dark ? 0.85 : 0.9) * (0.4 + 0.6 * dtw) * (1 - dp * 0.35), + disp.dotTip, + clamp( + env * introA * tipFade * (dark ? 0.95 : 1) * (0.45 + 0.55 * dtw) * + (1 + 0.6 * react), + 0, + 1, + ), ); ctx.beginPath(); - ctx.arc(px, py, dot.r, 0, Math.PI * 2); + ctx.arc(ex, ey, ray.dotR * (1 + 0.4 * react), 0, Math.PI * 2); ctx.fill(); } } + ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over"; }; renderRef.current = render; @@ -395,12 +543,35 @@ export function RadialBurst({ ro.observe(canvas); if (reduced) { - render(); + render(0); return () => ro.disconnect(); } - const loop = () => { - render(); + const onMove = (e: PointerEvent) => { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + if (x < 0 || y < 0 || x > rect.width || y > rect.height) { + pointer.active = false; + return; + } + pointer.x = x; + pointer.y = y; + pointer.active = true; + }; + const onLeave = () => { + pointer.active = false; + }; + if (interactive) { + window.addEventListener("pointermove", onMove, { passive: true }); + window.addEventListener("blur", onLeave); + } + + let last = performance.now(); + const loop = (t: number) => { + const dt = Math.min(0.05, (t - last) / 1000); + last = t; + render(dt); raf = requestAnimationFrame(loop); }; raf = requestAnimationFrame(loop); @@ -408,8 +579,12 @@ export function RadialBurst({ return () => { cancelAnimationFrame(raf); ro.disconnect(); + if (interactive) { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("blur", onLeave); + } }; - }, [density, progress, reduced]); + }, [density, intro, reduced, interactive]); return ( (null); - const reduced = useReducedMotion(); - const fmt = (v: number) => { - const fixed = v.toFixed(decimals); - const [int, dec] = fixed.split("."); - const withSep = int.replace(/\B(?=(\d{3})+(?!\d))/g, ","); - return `${prefix}${dec !== undefined ? `${withSep}.${dec}` : withSep}${suffix}`; - }; - - useEffect(() => { - const node = ref.current; - if (!node) return; - if (reduced) { - node.textContent = fmt(value); - return; - } - const controls = animate(0, value, { - duration: 1.8, - ease: [0.22, 1, 0.36, 1], - onUpdate: (v) => { - node.textContent = fmt(v); - }, - }); - return () => controls.stop(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value, decimals, reduced]); - - return ( - - {fmt(value)} - - ); -} - -/* ------------------------------------------------------------------ * - * RadialBurstHero — full composition. + * RadialBurstHero — full composition: headline over the interactive burst. * ------------------------------------------------------------------ */ -const containerVariants: Variants = { - hidden: {}, - visible: { transition: { staggerChildren: 0.08, delayChildren: 0.1 } }, -}; -const itemVariants: Variants = { - hidden: { opacity: 0, y: 16 }, - visible: { - opacity: 1, - y: 0, - transition: { type: "spring", stiffness: 140, damping: 20 }, - }, -}; - export type RadialBurstHeroProps = Omit< ComponentProps<"section">, "children" | "title" @@ -608,7 +711,6 @@ export type RadialBurstHeroProps = Omit< /** Initial theme; also synced if it changes (e.g. from a site toggle). */ defaultTheme?: RadialBurstThemeId; title?: ReactNode; - stats?: Stat[]; burstProps?: Omit; }; @@ -622,7 +724,6 @@ export function RadialBurstHero({ of global commerce ), - stats = DEFAULT_STATS, burstProps, ...rest }: RadialBurstHeroProps) { @@ -661,63 +762,36 @@ export function RadialBurstHero({ /> - {/* Canvas burst. */} - + {/* Interactive burst — masked so it fades out below the headline. */} +
    + +
    {/* Theme switcher. */}
    - {/* Content. */} - + {/* Headline. */} +
    {title} - - - {stats.map((stat, i) => ( -
    -
    - -
    -

    - {stat.label} -

    -
    - ))} -
    - +
    ); } diff --git a/apps/www/config/components.ts b/apps/www/config/components.ts index 652fb6d7..da0a89cc 100644 --- a/apps/www/config/components.ts +++ b/apps/www/config/components.ts @@ -3427,7 +3427,7 @@ const componentDefinitions = [ { "slug": "hero-radial-burst", "name": "Radial Burst Hero", - "description": "A Stripe-style hero block: a canvas radial burst of fine rays — each fading from a bright bottom-center core to faint tips, with dots drifting outward along them — beneath a headline and a count-up stat row. Ships with six time-of-day themes (Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night) and an in-block switcher; the background crossfades and the burst colors lerp between themes. devicePixelRatio-aware, keyboard-accessible switcher, honors `prefers-reduced-motion`, and uses `motion` for the reveal, theme blend, count-ups, and dropdown.", + "description": "A Stripe-style hero block: an interactive fiber-optic radial burst on canvas. Fine glowing rays stream out of a bottom-center origin in a wide fan, each one continuously growing, over-extending, fading, and regenerating with fresh angle/length/speed/opacity, with a single glowing dot riding each fiber's tip. Hovering the middle or tip of a fiber makes it (and its neighbours) brighten, stretch, and bend toward the cursor, then ease back to their drift; the dense zone near the origin stays calm. The burst sits in a short lower band, masked so it fades out below the headline. Ships with six time-of-day themes (Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night) and an in-block switcher; the background crossfades and the burst colors lerp between themes. devicePixelRatio-aware, keyboard-accessible switcher, honors `prefers-reduced-motion` (renders a calm static frame), and uses `motion` for the intro, theme blend, and dropdown.", "icon": "LucideSparkle", "category": "Hero", "kind": "block", @@ -3444,15 +3444,10 @@ const componentDefinitions = [ "type": "ReactNode", "description": "Headline content. Defaults to \"The backbone of global commerce\"." }, - { - "name": "stats", - "type": "{ prefix?: string; value: number; decimals?: number; suffix?: string; label: string }[]", - "description": "Stat row. Each value counts up on mount; `label` supports newlines. Defaults to the four Stripe-style stats." - }, { "name": "burstProps", - "type": "{ className?: string; density?: number }", - "description": "Forwarded to the canvas burst layer (`RadialBurst`). `density` scales ray count (0.4–2)." + "type": "{ className?: string; density?: number; interactive?: boolean }", + "description": "Forwarded to the canvas burst layer (`RadialBurst`). `density` scales ray count (0.4–2); set `interactive` to false to disable pointer reactivity." }, { "name": "className", @@ -3460,7 +3455,7 @@ const componentDefinitions = [ "description": "Classes for the outer `
    `." } ], - "usageCode": "import { RadialBurstHero } from \"@/components/ui/hero-radial-burst\";\n\nexport default function Hero() {\n return ;\n}\n\n// Or drive the time-of-day theme + content yourself:\n// Built for\\n global scale}\n// stats={[\n// { value: 135, suffix: \"+\", label: \"currencies supported\" },\n// { prefix: \"US$\", value: 1.9, decimals: 1, suffix: \"tn\", label: \"processed in 2025\" },\n// { value: 99.999, decimals: 3, suffix: \"%\", label: \"historical uptime\" },\n// { value: 200, suffix: \"M+\", label: \"active subscriptions\" },\n// ]}\n// />" + "usageCode": "import { RadialBurstHero } from \"@/components/ui/hero-radial-burst\";\n\nexport default function Hero() {\n return (\n \n The backbone\n
    \n of global commerce\n \n }\n />\n );\n}" } ] satisfies ComponentDefinition[]; diff --git a/apps/www/config/demos.tsx b/apps/www/config/demos.tsx index e153e7ad..1073eccf 100644 --- a/apps/www/config/demos.tsx +++ b/apps/www/config/demos.tsx @@ -3836,6 +3836,15 @@ export const componentDemos: Record = { ), "hero-radial-burst": ({ theme = "dark" }) => ( - + + The backbone +
    + of global commerce + + } + /> ), }; diff --git a/apps/www/config/docs-scenarios.ts b/apps/www/config/docs-scenarios.ts index 542cb7db..7624c63c 100644 --- a/apps/www/config/docs-scenarios.ts +++ b/apps/www/config/docs-scenarios.ts @@ -1129,7 +1129,7 @@ export const docsScenarios: Record = { }, "hero-radial-burst": { "slug": "hero-radial-burst", - "overview": "A canvas radial burst rises from a bright bottom-center core: ~200 fine rays fan across the upper semicircle (longest near vertical, forming a dome), each a base-bright→tip-faint gradient with dots drifting outward. Above it sit a headline and a count-up stat row. Six time-of-day themes — Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night — are selectable from an in-block dropdown; switching crossfades the background gradient and eases the burst colors between palettes. `RadialBurst` is exported separately for reuse under your own layout.", + "overview": "An interactive fiber-optic burst rises from a bottom-center origin that touches the bottom edge: fine rays fan across the upper semicircle (longest near vertical, forming a soft dome), each a base-bright→tip-faint gradient drawn as a soft wide glow pass plus a crisp core, with a single glowing dot riding its tip. Every ray continuously grows, slightly over-extends, fades, and respawns with a fresh angle, length, speed, and opacity, so the loop is seamless with no global reset. Hovering the middle or tip of a fiber makes it and its neighbours brighten, stretch, and bend toward the cursor before easing back; the dense zone near the origin does not react. The burst is kept to a short lower band, masked so it never reaches the headline above. Six time-of-day themes — Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night — are selectable from an in-block dropdown; switching crossfades the background gradient and eases the burst colors between palettes. `RadialBurst` is exported separately for reuse under your own layout.", "scenarios": [] } }; diff --git a/apps/www/public/r/hero-radial-burst.json b/apps/www/public/r/hero-radial-burst.json index 82f5fb33..d7925902 100644 --- a/apps/www/public/r/hero-radial-burst.json +++ b/apps/www/public/r/hero-radial-burst.json @@ -3,7 +3,7 @@ "name": "hero-radial-burst", "type": "registry:ui", "title": "Radial Burst Hero", - "description": "A Stripe-style hero block: a canvas radial burst of fine rays — each fading from a bright bottom-center core to faint tips, with dots drifting outward along them — beneath a headline and a count-up stat row. Ships with six time-of-day themes (Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night) and an in-block switcher; the background crossfades and the burst colors lerp between themes. devicePixelRatio-aware, keyboard-accessible switcher, honors `prefers-reduced-motion`, and uses `motion` for the reveal, theme blend, count-ups, and dropdown.", + "description": "A Stripe-style hero block: an interactive fiber-optic radial burst on canvas. Fine glowing rays stream out of a bottom-center origin in a wide fan, each one continuously growing, over-extending, fading, and regenerating with fresh angle/length/speed/opacity, with a single glowing dot riding each fiber's tip. Hovering the middle or tip of a fiber makes it (and its neighbours) brighten, stretch, and bend toward the cursor, then ease back to their drift; the dense zone near the origin stays calm. The burst sits in a short lower band, masked so it fades out below the headline. Ships with six time-of-day themes (Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night) and an in-block switcher; the background crossfades and the burst colors lerp between themes. devicePixelRatio-aware, keyboard-accessible switcher, honors `prefers-reduced-motion` (renders a calm static frame), and uses `motion` for the intro, theme blend, and dropdown.", "dependencies": [ "motion", "lucide-react", @@ -13,7 +13,7 @@ "files": [ { "path": "components/ui/hero-radial-burst.tsx", - "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n type Variants,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n\ntype Ray = {\n angle: number;\n length: number; // fraction of maxRadius\n reveal: number; // 0..1 stagger delay\n phase: number; // twinkle offset\n dots: { p: number; r: number; phase: number }[];\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the canvas background (reusable on its own).\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n}: RadialBurstProps) {\n const canvasRef = useRef(null);\n const reduced = useReducedMotion();\n\n // Reveal progress driven by Motion — read inside the canvas rAF loop.\n const progress = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<() => void>(() => {});\n\n // Mount reveal animation (Motion).\n useEffect(() => {\n if (reduced) {\n progress.set(1);\n return;\n }\n const controls = animate(progress, 1, {\n duration: 1.7,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [progress, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current();\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n originY = h * 0.99;\n maxRadius = h * 0.94;\n const count = Math.round(\n Math.min(340, Math.max(120, w / 4.8)) *\n Math.min(2, Math.max(0.4, density)),\n );\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n rays = Array.from({ length: count }, (_, i) => {\n const t = count > 1 ? i / (count - 1) : 0.5;\n const angle = lerp(aMin, aMax, t) + (Math.random() - 0.5) * 0.012;\n // Closeness to vertical → longer rays → a dome silhouette.\n const vert = Math.sin(Math.min(Math.PI, Math.max(0, angle)));\n const length =\n (0.34 + 0.66 * vert) * (0.78 + Math.random() * 0.3) +\n (Math.random() < 0.06 ? 0.12 : 0);\n const dotCount = 1 + Math.floor(Math.random() * 4);\n return {\n angle,\n length: Math.min(1.05, length),\n reveal: (1 - vert) * 0.42 + Math.random() * 0.12,\n phase: Math.random() * Math.PI * 2,\n dots: Array.from({ length: dotCount }, () => ({\n p: 0.2 + Math.random() * 0.8,\n r: 0.6 + Math.random() * 0.9,\n phase: Math.random() * Math.PI * 2,\n })),\n };\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current();\n };\n\n const render = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const prog = progress.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n\n // Central bloom.\n const bloomR = Math.min(w * 0.22, maxRadius * 0.5) * (0.6 + 0.4 * prog);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, dark ? 0.7 : 0.6));\n bloom.addColorStop(0.45, rgba(disp.core, dark ? 0.22 : 0.18));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n ctx.lineWidth = 0.7;\n ctx.lineCap = \"round\";\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n const lp = easeOut(\n Math.min(1, Math.max(0, (prog - ray.reveal) / (1 - 0.54))),\n );\n if (lp <= 0) continue;\n const twinkle = reduced ? 1 : 0.72 + 0.28 * Math.sin(now * 1.4 + ray.phase);\n const dx = Math.cos(ray.angle);\n const dy = -Math.sin(ray.angle);\n const len = ray.length * maxRadius * lp;\n const ex = originX + dx * len;\n const ey = originY + dy * len;\n\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0.0));\n grad.addColorStop(0.05, rgba(disp.rayBase, 0.98 * twinkle));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.55 * twinkle));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Dots — drift slowly outward for a living \"data\" feel.\n for (let d = 0; d < ray.dots.length; d++) {\n const dot = ray.dots[d];\n const dp = reduced ? dot.p : (dot.p + now * 0.025) % 1;\n if (dp > lp) continue;\n const px = originX + dx * ray.length * maxRadius * dp;\n const py = originY + dy * ray.length * maxRadius * dp;\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + dot.phase);\n ctx.fillStyle = rgba(\n lerpRGB(disp.dotBase, disp.dotTip, dp),\n (dark ? 0.85 : 0.9) * (0.4 + 0.6 * dtw) * (1 - dp * 0.35),\n );\n ctx.beginPath();\n ctx.arc(px, py, dot.r, 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render();\n return () => ro.disconnect();\n }\n\n const loop = () => {\n render();\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n };\n }, [density, progress, reduced]);\n\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n
    \n setOpen((v) => !v)}\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n \n \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n
    \n );\n}\n\n/* ------------------------------------------------------------------ *\n * Count-up stat (Motion-driven).\n * ------------------------------------------------------------------ */\n\ntype Stat = {\n prefix?: string;\n value: number;\n decimals?: number;\n suffix?: string;\n label: string;\n};\n\nconst DEFAULT_STATS: Stat[] = [\n { value: 135, suffix: \"+\", label: \"currencies and payment\\nmethods supported\" },\n { prefix: \"US$\", value: 1.9, decimals: 1, suffix: \"tn\", label: \"in payments volume\\nprocessed in 2025\" },\n { value: 99.999, decimals: 3, suffix: \"%\", label: \"historical uptime\\nfor Stripe services\" },\n { value: 200, suffix: \"M+\", label: \"active subscriptions\\nmanaged on Stripe Billing\" },\n];\n\nfunction CountUp({\n value,\n decimals = 0,\n prefix = \"\",\n suffix = \"\",\n}: Stat) {\n const ref = useRef(null);\n const reduced = useReducedMotion();\n const fmt = (v: number) => {\n const fixed = v.toFixed(decimals);\n const [int, dec] = fixed.split(\".\");\n const withSep = int.replace(/\\B(?=(\\d{3})+(?!\\d))/g, \",\");\n return `${prefix}${dec !== undefined ? `${withSep}.${dec}` : withSep}${suffix}`;\n };\n\n useEffect(() => {\n const node = ref.current;\n if (!node) return;\n if (reduced) {\n node.textContent = fmt(value);\n return;\n }\n const controls = animate(0, value, {\n duration: 1.8,\n ease: [0.22, 1, 0.36, 1],\n onUpdate: (v) => {\n node.textContent = fmt(v);\n },\n });\n return () => controls.stop();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [value, decimals, reduced]);\n\n return (\n \n {fmt(value)}\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition.\n * ------------------------------------------------------------------ */\n\nconst containerVariants: Variants = {\n hidden: {},\n visible: { transition: { staggerChildren: 0.08, delayChildren: 0.1 } },\n};\nconst itemVariants: Variants = {\n hidden: { opacity: 0, y: 16 },\n visible: {\n opacity: 1,\n y: 0,\n transition: { type: \"spring\", stiffness: 140, damping: 20 },\n },\n};\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n stats?: Stat[];\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n stats = DEFAULT_STATS,\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Canvas burst. */}\n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Content. */}\n \n \n {title}\n \n\n \n {stats.map((stat, i) => (\n
    \n \n \n
    \n \n {stat.label}\n

    \n \n ))}\n \n \n
    \n );\n}\n\nexport default RadialBurstHero;\n", + "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n/** Wrap an angle into (-π, π]. */\nconst wrapAngle = (a: number) => {\n let x = a;\n while (x > Math.PI) x -= 2 * Math.PI;\n while (x < -Math.PI) x += 2 * Math.PI;\n return x;\n};\n/** Distance from point (px,py) to segment (ax,ay)-(bx,by). */\nconst segDist = (\n px: number,\n py: number,\n ax: number,\n ay: number,\n bx: number,\n by: number,\n) => {\n const dx = bx - ax;\n const dy = by - ay;\n const len2 = dx * dx + dy * dy || 1;\n const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1);\n const cx = ax + t * dx;\n const cy = ay + t * dy;\n return Math.hypot(px - cx, py - cy);\n};\n\n/** Horizontal spread multiplier — widens the fan without changing its height. */\nconst SPREAD_X = 1.3;\n\n/** One fiber: a streamline that grows, holds, extends, fades, then respawns. */\ntype Ray = {\n angle: number; // base emission angle (radians)\n maxLen: number; // target length as a fraction of maxRadius\n speed: number; // life units per second (→ lifetime ≈ 1/speed)\n life: number; // < 0 staggered delay, 0..1 active\n width: number; // core stroke width\n bright: number; // base brightness 0..1\n phase: number; // twinkle offset\n react: number; // smoothed pointer reaction 0..1\n bend: number; // smoothed angular bend toward the pointer\n hasDot: boolean; // whether a glowing dot rides this fiber's tip\n dotR: number; // tip-dot radius\n dotPhase: number; // tip-dot twinkle offset\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the interactive canvas background (reusable on its own).\n *\n * Fibers stream out of a bottom-center origin in a wide radial fan. Each\n * one continuously grows, slightly over-extends, fades, and regenerates\n * with fresh angle/length/speed/opacity, while glowing dots travel along\n * it. Rays near the pointer brighten, stretch, and bend toward it, then\n * ease back to their drift. The burst stays in the lower band so it never\n * reaches the headline above.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n /** Disable pointer reactivity. */\n interactive?: boolean;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n interactive = true,\n}: RadialBurstProps) {\n const canvasRef = useRef(null);\n const reduced = useReducedMotion();\n\n // Global intro fade, driven by Motion — read inside the canvas rAF loop.\n const intro = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<(dt: number) => void>(() => {});\n\n // Mount fade-in (Motion).\n useEffect(() => {\n if (reduced) {\n intro.set(1);\n return;\n }\n const controls = animate(intro, 1, {\n duration: 1.6,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [intro, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current(0);\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const pointer = { x: 0, y: 0, active: false };\n\n const respawn = (ray: Ray, initial: boolean) => {\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n const angle = lerp(aMin, aMax, Math.random()) + (Math.random() - 0.5) * 0.05;\n // Mild bias toward vertical, but side rays stay long so the fan fills\n // the full width along the bottom rather than tapering to a dome.\n const vert = Math.sin(clamp(angle, 0, Math.PI));\n ray.angle = angle;\n ray.maxLen = Math.min(\n 1.05,\n (0.8 + 0.2 * vert) * (0.6 + Math.random() * 0.45) +\n (Math.random() < 0.06 ? 0.12 : 0),\n );\n ray.speed = 0.085 + Math.random() * 0.13; // lifetime ≈ 4.5–11.8s\n ray.life = initial ? Math.random() : -Math.random() * 0.6;\n ray.width = 0.55 + Math.random() * 0.5;\n ray.bright = 0.5 + Math.random() * 0.5;\n ray.phase = Math.random() * Math.PI * 2;\n // A single glowing dot sits at the tip — it rides the growing tip, it\n // does not travel along the fiber.\n ray.hasDot = Math.random() < 0.72;\n ray.dotR = 0.8 + Math.random() * 1;\n ray.dotPhase = Math.random() * Math.PI * 2;\n };\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n // Origin sits on the bottom edge so the burst touches the bottom.\n originY = h;\n // Lower-band height — kept well below the headline, ~100px shorter.\n maxRadius = Math.max(h * 0.4, h * 0.6 - 100);\n const count = Math.round(\n Math.min(400, Math.max(220, w / 3.2)) * clamp(density, 0.4, 2),\n );\n rays = Array.from({ length: count }, () => {\n const ray: Ray = {\n angle: 0,\n maxLen: 0,\n speed: 0,\n life: 0,\n width: 1,\n bright: 1,\n phase: 0,\n react: 0,\n bend: 0,\n hasDot: false,\n dotR: 1,\n dotPhase: 0,\n };\n respawn(ray, true);\n return ray;\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current(0);\n };\n\n const render = (dt: number) => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const introA = intro.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n ctx.lineCap = \"round\";\n\n // Central bloom — a soft, diffuse glow rather than a hard bright disc.\n const bloomR =\n Math.min(w * 0.22, maxRadius * 0.6) * (0.7 + 0.3 * introA);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, (dark ? 0.5 : 0.44) * introA));\n bloom.addColorStop(0.3, rgba(disp.core, (dark ? 0.18 : 0.15) * introA));\n bloom.addColorStop(0.65, rgba(disp.core, (dark ? 0.06 : 0.05) * introA));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n const pointerOn = interactive && !reduced && pointer.active;\n const reactR = 170; // px radius of pointer influence\n // No reaction near the origin — only the middle/tip of fibers respond.\n const originGuard = maxRadius * 0.22;\n const pointerNearOrigin =\n Math.hypot(pointer.x - originX, pointer.y - originY) < originGuard;\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n\n if (!reduced) {\n ray.life += ray.speed * dt;\n if (ray.life >= 1) respawn(ray, false);\n }\n if (ray.life < 0) continue;\n\n const life = reduced ? 0.62 : ray.life;\n const growT = clamp(life / 0.7, 0, 1);\n const lenFrac = easeOut(growT);\n const extend = life > 0.7 ? (life - 0.7) / 0.3 : 0;\n const env = reduced\n ? 1\n : Math.min(1, life / 0.12) *\n (life > 0.8 ? clamp(1 - (life - 0.8) / 0.2, 0, 1) : 1);\n if (env <= 0) continue;\n\n const baseLen =\n ray.maxLen * maxRadius * lenFrac * (1 + 0.06 * extend);\n\n // Pointer reaction — engage quickly, return slowly. Hit-test only the\n // outer 35%→tip span so the dense near-origin zone stays calm.\n if (pointerOn && !pointerNearOrigin) {\n const cx = Math.cos(ray.angle) * SPREAD_X;\n const cy = -Math.sin(ray.angle);\n const ix = originX + cx * baseLen * 0.35;\n const iy = originY + cy * baseLen * 0.35;\n const bx = originX + cx * baseLen;\n const by = originY + cy * baseLen;\n const d = segDist(pointer.x, pointer.y, ix, iy, bx, by);\n let reactTarget = 0;\n let bendTarget = 0;\n if (d < reactR) {\n reactTarget = 1 - d / reactR;\n reactTarget *= reactTarget;\n const pAng = Math.atan2(\n -(pointer.y - originY),\n (pointer.x - originX) / SPREAD_X,\n );\n bendTarget = clamp(wrapAngle(pAng - ray.angle), -0.4, 0.4);\n }\n ray.react +=\n (reactTarget - ray.react) * (reactTarget > ray.react ? 0.14 : 0.06);\n ray.bend += (bendTarget * ray.react - ray.bend) * 0.1;\n } else if (ray.react !== 0 || ray.bend !== 0) {\n ray.react += -ray.react * 0.06;\n ray.bend += -ray.bend * 0.1;\n }\n\n const react = ray.react;\n const drawAngle = ray.angle + ray.bend;\n const dirx = Math.cos(drawAngle) * SPREAD_X;\n const diry = -Math.sin(drawAngle);\n const effLen = baseLen * (1 + 0.12 * react);\n const ex = originX + dirx * effLen;\n const ey = originY + diry * effLen;\n\n const twinkle = reduced ? 1 : 0.82 + 0.18 * Math.sin(now * 1.3 + ray.phase);\n const aBase = clamp(\n env * introA * ray.bright * twinkle * (1 + 0.9 * react),\n 0,\n 1,\n );\n\n // Brightness builds up *along* the fiber: near-zero through the dense\n // convergence zone at the origin (so overlapping starts don't blow out\n // to white), peaking once the fibers have fanned apart, fading at the tip.\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0));\n grad.addColorStop(0.16, rgba(disp.rayBase, 0.22 * aBase));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.85 * aBase));\n grad.addColorStop(0.75, rgba(disp.rayBase, 0.36 * aBase));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n\n // Soft wide pass → subtle blur/glow.\n ctx.lineWidth = ray.width * (dark ? 3.2 : 2.6);\n ctx.globalAlpha = dark ? 0.22 : 0.18;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Crisp core pass.\n ctx.lineWidth = ray.width;\n ctx.globalAlpha = 1;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // A single glowing dot sits at the very tip — it rides the growing\n // tip but never travels along the fiber.\n if (ray.hasDot) {\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + ray.dotPhase);\n // Dim dots whose tip still sits inside the convergence zone.\n const tipFade = clamp(effLen / (maxRadius * 0.28), 0, 1);\n ctx.fillStyle = rgba(\n disp.dotTip,\n clamp(\n env * introA * tipFade * (dark ? 0.95 : 1) * (0.45 + 0.55 * dtw) *\n (1 + 0.6 * react),\n 0,\n 1,\n ),\n );\n ctx.beginPath();\n ctx.arc(ex, ey, ray.dotR * (1 + 0.4 * react), 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalAlpha = 1;\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render(0);\n return () => ro.disconnect();\n }\n\n const onMove = (e: PointerEvent) => {\n const rect = canvas.getBoundingClientRect();\n const x = e.clientX - rect.left;\n const y = e.clientY - rect.top;\n if (x < 0 || y < 0 || x > rect.width || y > rect.height) {\n pointer.active = false;\n return;\n }\n pointer.x = x;\n pointer.y = y;\n pointer.active = true;\n };\n const onLeave = () => {\n pointer.active = false;\n };\n if (interactive) {\n window.addEventListener(\"pointermove\", onMove, { passive: true });\n window.addEventListener(\"blur\", onLeave);\n }\n\n let last = performance.now();\n const loop = (t: number) => {\n const dt = Math.min(0.05, (t - last) / 1000);\n last = t;\n render(dt);\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n if (interactive) {\n window.removeEventListener(\"pointermove\", onMove);\n window.removeEventListener(\"blur\", onLeave);\n }\n };\n }, [density, intro, reduced, interactive]);\n\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n
    \n setOpen((v) => !v)}\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n \n \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n
    \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition: headline over the interactive burst.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Interactive burst — masked so it fades out below the headline. */}\n \n \n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Headline. */}\n
    \n \n {title}\n \n
    \n \n );\n}\n\nexport default RadialBurstHero;\n", "type": "registry:component", "target": "components/ui/hero-radial-burst.tsx" } diff --git a/apps/www/public/r/registry.json b/apps/www/public/r/registry.json index db3e64b5..947c2577 100644 --- a/apps/www/public/r/registry.json +++ b/apps/www/public/r/registry.json @@ -1202,7 +1202,7 @@ "name": "hero-radial-burst", "type": "registry:ui", "title": "Radial Burst Hero", - "description": "A Stripe-style hero block: a canvas radial burst of fine rays — each fading from a bright bottom-center core to faint tips, with dots drifting outward along them — beneath a headline and a count-up stat row. Ships with six time-of-day themes (Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night) and an in-block switcher; the background crossfades and the burst colors lerp between themes. devicePixelRatio-aware, keyboard-accessible switcher, honors `prefers-reduced-motion`, and uses `motion` for the reveal, theme blend, count-ups, and dropdown.", + "description": "A Stripe-style hero block: an interactive fiber-optic radial burst on canvas. Fine glowing rays stream out of a bottom-center origin in a wide fan, each one continuously growing, over-extending, fading, and regenerating with fresh angle/length/speed/opacity, with a single glowing dot riding each fiber's tip. Hovering the middle or tip of a fiber makes it (and its neighbours) brighten, stretch, and bend toward the cursor, then ease back to their drift; the dense zone near the origin stays calm. The burst sits in a short lower band, masked so it fades out below the headline. Ships with six time-of-day themes (Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night) and an in-block switcher; the background crossfades and the burst colors lerp between themes. devicePixelRatio-aware, keyboard-accessible switcher, honors `prefers-reduced-motion` (renders a calm static frame), and uses `motion` for the intro, theme blend, and dropdown.", "dependencies": [ "motion", "lucide-react", diff --git a/apps/www/public/registry.json b/apps/www/public/registry.json index a8c21105..2e01c2a8 100644 --- a/apps/www/public/registry.json +++ b/apps/www/public/registry.json @@ -4113,9 +4113,9 @@ "files": [ { "path": "blocks/hero/radial-burst/component.tsx", - "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n type Variants,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n\ntype Ray = {\n angle: number;\n length: number; // fraction of maxRadius\n reveal: number; // 0..1 stagger delay\n phase: number; // twinkle offset\n dots: { p: number; r: number; phase: number }[];\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the canvas background (reusable on its own).\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n}: RadialBurstProps) {\n const canvasRef = useRef(null);\n const reduced = useReducedMotion();\n\n // Reveal progress driven by Motion — read inside the canvas rAF loop.\n const progress = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<() => void>(() => {});\n\n // Mount reveal animation (Motion).\n useEffect(() => {\n if (reduced) {\n progress.set(1);\n return;\n }\n const controls = animate(progress, 1, {\n duration: 1.7,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [progress, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current();\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n originY = h * 0.99;\n maxRadius = h * 0.94;\n const count = Math.round(\n Math.min(340, Math.max(120, w / 4.8)) *\n Math.min(2, Math.max(0.4, density)),\n );\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n rays = Array.from({ length: count }, (_, i) => {\n const t = count > 1 ? i / (count - 1) : 0.5;\n const angle = lerp(aMin, aMax, t) + (Math.random() - 0.5) * 0.012;\n // Closeness to vertical → longer rays → a dome silhouette.\n const vert = Math.sin(Math.min(Math.PI, Math.max(0, angle)));\n const length =\n (0.34 + 0.66 * vert) * (0.78 + Math.random() * 0.3) +\n (Math.random() < 0.06 ? 0.12 : 0);\n const dotCount = 1 + Math.floor(Math.random() * 4);\n return {\n angle,\n length: Math.min(1.05, length),\n reveal: (1 - vert) * 0.42 + Math.random() * 0.12,\n phase: Math.random() * Math.PI * 2,\n dots: Array.from({ length: dotCount }, () => ({\n p: 0.2 + Math.random() * 0.8,\n r: 0.6 + Math.random() * 0.9,\n phase: Math.random() * Math.PI * 2,\n })),\n };\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current();\n };\n\n const render = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const prog = progress.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n\n // Central bloom.\n const bloomR = Math.min(w * 0.22, maxRadius * 0.5) * (0.6 + 0.4 * prog);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, dark ? 0.7 : 0.6));\n bloom.addColorStop(0.45, rgba(disp.core, dark ? 0.22 : 0.18));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n ctx.lineWidth = 0.7;\n ctx.lineCap = \"round\";\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n const lp = easeOut(\n Math.min(1, Math.max(0, (prog - ray.reveal) / (1 - 0.54))),\n );\n if (lp <= 0) continue;\n const twinkle = reduced ? 1 : 0.72 + 0.28 * Math.sin(now * 1.4 + ray.phase);\n const dx = Math.cos(ray.angle);\n const dy = -Math.sin(ray.angle);\n const len = ray.length * maxRadius * lp;\n const ex = originX + dx * len;\n const ey = originY + dy * len;\n\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0.0));\n grad.addColorStop(0.05, rgba(disp.rayBase, 0.98 * twinkle));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.55 * twinkle));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Dots — drift slowly outward for a living \"data\" feel.\n for (let d = 0; d < ray.dots.length; d++) {\n const dot = ray.dots[d];\n const dp = reduced ? dot.p : (dot.p + now * 0.025) % 1;\n if (dp > lp) continue;\n const px = originX + dx * ray.length * maxRadius * dp;\n const py = originY + dy * ray.length * maxRadius * dp;\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + dot.phase);\n ctx.fillStyle = rgba(\n lerpRGB(disp.dotBase, disp.dotTip, dp),\n (dark ? 0.85 : 0.9) * (0.4 + 0.6 * dtw) * (1 - dp * 0.35),\n );\n ctx.beginPath();\n ctx.arc(px, py, dot.r, 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render();\n return () => ro.disconnect();\n }\n\n const loop = () => {\n render();\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n };\n }, [density, progress, reduced]);\n\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n
    \n setOpen((v) => !v)}\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n \n \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n
    \n );\n}\n\n/* ------------------------------------------------------------------ *\n * Count-up stat (Motion-driven).\n * ------------------------------------------------------------------ */\n\ntype Stat = {\n prefix?: string;\n value: number;\n decimals?: number;\n suffix?: string;\n label: string;\n};\n\nconst DEFAULT_STATS: Stat[] = [\n { value: 135, suffix: \"+\", label: \"currencies and payment\\nmethods supported\" },\n { prefix: \"US$\", value: 1.9, decimals: 1, suffix: \"tn\", label: \"in payments volume\\nprocessed in 2025\" },\n { value: 99.999, decimals: 3, suffix: \"%\", label: \"historical uptime\\nfor Stripe services\" },\n { value: 200, suffix: \"M+\", label: \"active subscriptions\\nmanaged on Stripe Billing\" },\n];\n\nfunction CountUp({\n value,\n decimals = 0,\n prefix = \"\",\n suffix = \"\",\n}: Stat) {\n const ref = useRef(null);\n const reduced = useReducedMotion();\n const fmt = (v: number) => {\n const fixed = v.toFixed(decimals);\n const [int, dec] = fixed.split(\".\");\n const withSep = int.replace(/\\B(?=(\\d{3})+(?!\\d))/g, \",\");\n return `${prefix}${dec !== undefined ? `${withSep}.${dec}` : withSep}${suffix}`;\n };\n\n useEffect(() => {\n const node = ref.current;\n if (!node) return;\n if (reduced) {\n node.textContent = fmt(value);\n return;\n }\n const controls = animate(0, value, {\n duration: 1.8,\n ease: [0.22, 1, 0.36, 1],\n onUpdate: (v) => {\n node.textContent = fmt(v);\n },\n });\n return () => controls.stop();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [value, decimals, reduced]);\n\n return (\n \n {fmt(value)}\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition.\n * ------------------------------------------------------------------ */\n\nconst containerVariants: Variants = {\n hidden: {},\n visible: { transition: { staggerChildren: 0.08, delayChildren: 0.1 } },\n};\nconst itemVariants: Variants = {\n hidden: { opacity: 0, y: 16 },\n visible: {\n opacity: 1,\n y: 0,\n transition: { type: \"spring\", stiffness: 140, damping: 20 },\n },\n};\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n stats?: Stat[];\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n stats = DEFAULT_STATS,\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Canvas burst. */}\n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Content. */}\n \n \n {title}\n \n\n \n {stats.map((stat, i) => (\n
    \n \n \n
    \n \n {stat.label}\n

    \n \n ))}\n \n \n \n );\n}\n\nexport default RadialBurstHero;\n", + "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n/** Wrap an angle into (-π, π]. */\nconst wrapAngle = (a: number) => {\n let x = a;\n while (x > Math.PI) x -= 2 * Math.PI;\n while (x < -Math.PI) x += 2 * Math.PI;\n return x;\n};\n/** Distance from point (px,py) to segment (ax,ay)-(bx,by). */\nconst segDist = (\n px: number,\n py: number,\n ax: number,\n ay: number,\n bx: number,\n by: number,\n) => {\n const dx = bx - ax;\n const dy = by - ay;\n const len2 = dx * dx + dy * dy || 1;\n const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1);\n const cx = ax + t * dx;\n const cy = ay + t * dy;\n return Math.hypot(px - cx, py - cy);\n};\n\n/** Horizontal spread multiplier — widens the fan without changing its height. */\nconst SPREAD_X = 1.3;\n\n/** One fiber: a streamline that grows, holds, extends, fades, then respawns. */\ntype Ray = {\n angle: number; // base emission angle (radians)\n maxLen: number; // target length as a fraction of maxRadius\n speed: number; // life units per second (→ lifetime ≈ 1/speed)\n life: number; // < 0 staggered delay, 0..1 active\n width: number; // core stroke width\n bright: number; // base brightness 0..1\n phase: number; // twinkle offset\n react: number; // smoothed pointer reaction 0..1\n bend: number; // smoothed angular bend toward the pointer\n hasDot: boolean; // whether a glowing dot rides this fiber's tip\n dotR: number; // tip-dot radius\n dotPhase: number; // tip-dot twinkle offset\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the interactive canvas background (reusable on its own).\n *\n * Fibers stream out of a bottom-center origin in a wide radial fan. Each\n * one continuously grows, slightly over-extends, fades, and regenerates\n * with fresh angle/length/speed/opacity, while glowing dots travel along\n * it. Rays near the pointer brighten, stretch, and bend toward it, then\n * ease back to their drift. The burst stays in the lower band so it never\n * reaches the headline above.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n /** Disable pointer reactivity. */\n interactive?: boolean;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n interactive = true,\n}: RadialBurstProps) {\n const canvasRef = useRef(null);\n const reduced = useReducedMotion();\n\n // Global intro fade, driven by Motion — read inside the canvas rAF loop.\n const intro = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<(dt: number) => void>(() => {});\n\n // Mount fade-in (Motion).\n useEffect(() => {\n if (reduced) {\n intro.set(1);\n return;\n }\n const controls = animate(intro, 1, {\n duration: 1.6,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [intro, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current(0);\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const pointer = { x: 0, y: 0, active: false };\n\n const respawn = (ray: Ray, initial: boolean) => {\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n const angle = lerp(aMin, aMax, Math.random()) + (Math.random() - 0.5) * 0.05;\n // Mild bias toward vertical, but side rays stay long so the fan fills\n // the full width along the bottom rather than tapering to a dome.\n const vert = Math.sin(clamp(angle, 0, Math.PI));\n ray.angle = angle;\n ray.maxLen = Math.min(\n 1.05,\n (0.8 + 0.2 * vert) * (0.6 + Math.random() * 0.45) +\n (Math.random() < 0.06 ? 0.12 : 0),\n );\n ray.speed = 0.085 + Math.random() * 0.13; // lifetime ≈ 4.5–11.8s\n ray.life = initial ? Math.random() : -Math.random() * 0.6;\n ray.width = 0.55 + Math.random() * 0.5;\n ray.bright = 0.5 + Math.random() * 0.5;\n ray.phase = Math.random() * Math.PI * 2;\n // A single glowing dot sits at the tip — it rides the growing tip, it\n // does not travel along the fiber.\n ray.hasDot = Math.random() < 0.72;\n ray.dotR = 0.8 + Math.random() * 1;\n ray.dotPhase = Math.random() * Math.PI * 2;\n };\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n // Origin sits on the bottom edge so the burst touches the bottom.\n originY = h;\n // Lower-band height — kept well below the headline, ~100px shorter.\n maxRadius = Math.max(h * 0.4, h * 0.6 - 100);\n const count = Math.round(\n Math.min(400, Math.max(220, w / 3.2)) * clamp(density, 0.4, 2),\n );\n rays = Array.from({ length: count }, () => {\n const ray: Ray = {\n angle: 0,\n maxLen: 0,\n speed: 0,\n life: 0,\n width: 1,\n bright: 1,\n phase: 0,\n react: 0,\n bend: 0,\n hasDot: false,\n dotR: 1,\n dotPhase: 0,\n };\n respawn(ray, true);\n return ray;\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current(0);\n };\n\n const render = (dt: number) => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const introA = intro.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n ctx.lineCap = \"round\";\n\n // Central bloom — a soft, diffuse glow rather than a hard bright disc.\n const bloomR =\n Math.min(w * 0.22, maxRadius * 0.6) * (0.7 + 0.3 * introA);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, (dark ? 0.5 : 0.44) * introA));\n bloom.addColorStop(0.3, rgba(disp.core, (dark ? 0.18 : 0.15) * introA));\n bloom.addColorStop(0.65, rgba(disp.core, (dark ? 0.06 : 0.05) * introA));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n const pointerOn = interactive && !reduced && pointer.active;\n const reactR = 170; // px radius of pointer influence\n // No reaction near the origin — only the middle/tip of fibers respond.\n const originGuard = maxRadius * 0.22;\n const pointerNearOrigin =\n Math.hypot(pointer.x - originX, pointer.y - originY) < originGuard;\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n\n if (!reduced) {\n ray.life += ray.speed * dt;\n if (ray.life >= 1) respawn(ray, false);\n }\n if (ray.life < 0) continue;\n\n const life = reduced ? 0.62 : ray.life;\n const growT = clamp(life / 0.7, 0, 1);\n const lenFrac = easeOut(growT);\n const extend = life > 0.7 ? (life - 0.7) / 0.3 : 0;\n const env = reduced\n ? 1\n : Math.min(1, life / 0.12) *\n (life > 0.8 ? clamp(1 - (life - 0.8) / 0.2, 0, 1) : 1);\n if (env <= 0) continue;\n\n const baseLen =\n ray.maxLen * maxRadius * lenFrac * (1 + 0.06 * extend);\n\n // Pointer reaction — engage quickly, return slowly. Hit-test only the\n // outer 35%→tip span so the dense near-origin zone stays calm.\n if (pointerOn && !pointerNearOrigin) {\n const cx = Math.cos(ray.angle) * SPREAD_X;\n const cy = -Math.sin(ray.angle);\n const ix = originX + cx * baseLen * 0.35;\n const iy = originY + cy * baseLen * 0.35;\n const bx = originX + cx * baseLen;\n const by = originY + cy * baseLen;\n const d = segDist(pointer.x, pointer.y, ix, iy, bx, by);\n let reactTarget = 0;\n let bendTarget = 0;\n if (d < reactR) {\n reactTarget = 1 - d / reactR;\n reactTarget *= reactTarget;\n const pAng = Math.atan2(\n -(pointer.y - originY),\n (pointer.x - originX) / SPREAD_X,\n );\n bendTarget = clamp(wrapAngle(pAng - ray.angle), -0.4, 0.4);\n }\n ray.react +=\n (reactTarget - ray.react) * (reactTarget > ray.react ? 0.14 : 0.06);\n ray.bend += (bendTarget * ray.react - ray.bend) * 0.1;\n } else if (ray.react !== 0 || ray.bend !== 0) {\n ray.react += -ray.react * 0.06;\n ray.bend += -ray.bend * 0.1;\n }\n\n const react = ray.react;\n const drawAngle = ray.angle + ray.bend;\n const dirx = Math.cos(drawAngle) * SPREAD_X;\n const diry = -Math.sin(drawAngle);\n const effLen = baseLen * (1 + 0.12 * react);\n const ex = originX + dirx * effLen;\n const ey = originY + diry * effLen;\n\n const twinkle = reduced ? 1 : 0.82 + 0.18 * Math.sin(now * 1.3 + ray.phase);\n const aBase = clamp(\n env * introA * ray.bright * twinkle * (1 + 0.9 * react),\n 0,\n 1,\n );\n\n // Brightness builds up *along* the fiber: near-zero through the dense\n // convergence zone at the origin (so overlapping starts don't blow out\n // to white), peaking once the fibers have fanned apart, fading at the tip.\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0));\n grad.addColorStop(0.16, rgba(disp.rayBase, 0.22 * aBase));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.85 * aBase));\n grad.addColorStop(0.75, rgba(disp.rayBase, 0.36 * aBase));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n\n // Soft wide pass → subtle blur/glow.\n ctx.lineWidth = ray.width * (dark ? 3.2 : 2.6);\n ctx.globalAlpha = dark ? 0.22 : 0.18;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Crisp core pass.\n ctx.lineWidth = ray.width;\n ctx.globalAlpha = 1;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // A single glowing dot sits at the very tip — it rides the growing\n // tip but never travels along the fiber.\n if (ray.hasDot) {\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + ray.dotPhase);\n // Dim dots whose tip still sits inside the convergence zone.\n const tipFade = clamp(effLen / (maxRadius * 0.28), 0, 1);\n ctx.fillStyle = rgba(\n disp.dotTip,\n clamp(\n env * introA * tipFade * (dark ? 0.95 : 1) * (0.45 + 0.55 * dtw) *\n (1 + 0.6 * react),\n 0,\n 1,\n ),\n );\n ctx.beginPath();\n ctx.arc(ex, ey, ray.dotR * (1 + 0.4 * react), 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalAlpha = 1;\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render(0);\n return () => ro.disconnect();\n }\n\n const onMove = (e: PointerEvent) => {\n const rect = canvas.getBoundingClientRect();\n const x = e.clientX - rect.left;\n const y = e.clientY - rect.top;\n if (x < 0 || y < 0 || x > rect.width || y > rect.height) {\n pointer.active = false;\n return;\n }\n pointer.x = x;\n pointer.y = y;\n pointer.active = true;\n };\n const onLeave = () => {\n pointer.active = false;\n };\n if (interactive) {\n window.addEventListener(\"pointermove\", onMove, { passive: true });\n window.addEventListener(\"blur\", onLeave);\n }\n\n let last = performance.now();\n const loop = (t: number) => {\n const dt = Math.min(0.05, (t - last) / 1000);\n last = t;\n render(dt);\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n if (interactive) {\n window.removeEventListener(\"pointermove\", onMove);\n window.removeEventListener(\"blur\", onLeave);\n }\n };\n }, [density, intro, reduced, interactive]);\n\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n
    \n setOpen((v) => !v)}\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n \n \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n
    \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition: headline over the interactive burst.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Interactive burst — masked so it fades out below the headline. */}\n \n \n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Headline. */}\n
    \n \n {title}\n \n
    \n \n );\n}\n\nexport default RadialBurstHero;\n", "type": "registry:ui", - "integrity": "sha384-858wzmwi4WlpfzklkxQxjFDmEhxfmWG9OTc5CbN2ZvkxlulRZPmXVWJboYrK+/ux" + "integrity": "sha384-aXAa9+odE7/IaRbmsz2WioIdDJ2fZmNfX3oFBbMP64zo1eOGUiDtXumKLQ2ONpWK" }, { "path": "utils/cn.ts", @@ -4125,9 +4125,25 @@ } ], "meta": { - "version": "1.0.0" + "version": "2.0.0" }, "changelog": [ + { + "version": "2.0.0", + "date": "2026-06-03", + "changes": [ + "Rewrote the burst as a continuous, seamlessly looping fiber-optic engine: rays grow, over-extend, fade, and respawn with fresh angle/length/speed/opacity instead of a one-time reveal.", + "Added pointer reactivity — fibers near the cursor (middle→tip only) brighten, stretch, and bend toward it, then ease back; the dense zone near the origin stays calm.", + "Each fiber now carries a single glowing dot fixed at its tip instead of dots travelling along it.", + "Softened the origin: fibers fade to near-zero through the dense convergence zone and peak only after fanning apart, plus a diffuser core bloom, so the bottom-center no longer blows out to white.", + "Raised the fiber count for a fuller, more balanced spread.", + "Increased overall fiber count and shortened the burst (~100px), with the origin touching the bottom edge.", + "Widened the fan horizontally and lengthened the side fibers so it spreads to the full width along the bottom (height unchanged).", + "Constrained the burst to a masked lower band so it no longer reaches the headline.", + "Removed the count-up stat row; the hero now shows the headline only.", + "Added a `burstProps.interactive` flag to toggle pointer reactivity." + ] + }, { "version": "1.0.0", "date": "2026-06-02", @@ -4145,7 +4161,8 @@ "block", "canvas", "starburst", - "stats" + "interactive", + "particles" ], "compatibility": { "react": "18+", diff --git a/apps/www/public/registry/changelogs.json b/apps/www/public/registry/changelogs.json index 326194ce..c463074f 100644 --- a/apps/www/public/registry/changelogs.json +++ b/apps/www/public/registry/changelogs.json @@ -671,6 +671,22 @@ } ], "hero-radial-burst": [ + { + "version": "2.0.0", + "date": "2026-06-03", + "changes": [ + "Rewrote the burst as a continuous, seamlessly looping fiber-optic engine: rays grow, over-extend, fade, and respawn with fresh angle/length/speed/opacity instead of a one-time reveal.", + "Added pointer reactivity — fibers near the cursor (middle→tip only) brighten, stretch, and bend toward it, then ease back; the dense zone near the origin stays calm.", + "Each fiber now carries a single glowing dot fixed at its tip instead of dots travelling along it.", + "Softened the origin: fibers fade to near-zero through the dense convergence zone and peak only after fanning apart, plus a diffuser core bloom, so the bottom-center no longer blows out to white.", + "Raised the fiber count for a fuller, more balanced spread.", + "Increased overall fiber count and shortened the burst (~100px), with the origin touching the bottom edge.", + "Widened the fan horizontally and lengthened the side fibers so it spreads to the full width along the bottom (height unchanged).", + "Constrained the burst to a masked lower band so it no longer reaches the headline.", + "Removed the count-up stat row; the hero now shows the headline only.", + "Added a `burstProps.interactive` flag to toggle pointer reactivity." + ] + }, { "version": "1.0.0", "date": "2026-06-02", diff --git a/apps/www/public/registry/hero-radial-burst.json b/apps/www/public/registry/hero-radial-burst.json index 3c56d993..a53ab78a 100644 --- a/apps/www/public/registry/hero-radial-burst.json +++ b/apps/www/public/registry/hero-radial-burst.json @@ -9,9 +9,9 @@ "files": [ { "path": "blocks/hero/radial-burst/component.tsx", - "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n type Variants,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n\ntype Ray = {\n angle: number;\n length: number; // fraction of maxRadius\n reveal: number; // 0..1 stagger delay\n phase: number; // twinkle offset\n dots: { p: number; r: number; phase: number }[];\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the canvas background (reusable on its own).\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n}: RadialBurstProps) {\n const canvasRef = useRef(null);\n const reduced = useReducedMotion();\n\n // Reveal progress driven by Motion — read inside the canvas rAF loop.\n const progress = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<() => void>(() => {});\n\n // Mount reveal animation (Motion).\n useEffect(() => {\n if (reduced) {\n progress.set(1);\n return;\n }\n const controls = animate(progress, 1, {\n duration: 1.7,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [progress, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current();\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n originY = h * 0.99;\n maxRadius = h * 0.94;\n const count = Math.round(\n Math.min(340, Math.max(120, w / 4.8)) *\n Math.min(2, Math.max(0.4, density)),\n );\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n rays = Array.from({ length: count }, (_, i) => {\n const t = count > 1 ? i / (count - 1) : 0.5;\n const angle = lerp(aMin, aMax, t) + (Math.random() - 0.5) * 0.012;\n // Closeness to vertical → longer rays → a dome silhouette.\n const vert = Math.sin(Math.min(Math.PI, Math.max(0, angle)));\n const length =\n (0.34 + 0.66 * vert) * (0.78 + Math.random() * 0.3) +\n (Math.random() < 0.06 ? 0.12 : 0);\n const dotCount = 1 + Math.floor(Math.random() * 4);\n return {\n angle,\n length: Math.min(1.05, length),\n reveal: (1 - vert) * 0.42 + Math.random() * 0.12,\n phase: Math.random() * Math.PI * 2,\n dots: Array.from({ length: dotCount }, () => ({\n p: 0.2 + Math.random() * 0.8,\n r: 0.6 + Math.random() * 0.9,\n phase: Math.random() * Math.PI * 2,\n })),\n };\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current();\n };\n\n const render = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const prog = progress.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n\n // Central bloom.\n const bloomR = Math.min(w * 0.22, maxRadius * 0.5) * (0.6 + 0.4 * prog);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, dark ? 0.7 : 0.6));\n bloom.addColorStop(0.45, rgba(disp.core, dark ? 0.22 : 0.18));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n ctx.lineWidth = 0.7;\n ctx.lineCap = \"round\";\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n const lp = easeOut(\n Math.min(1, Math.max(0, (prog - ray.reveal) / (1 - 0.54))),\n );\n if (lp <= 0) continue;\n const twinkle = reduced ? 1 : 0.72 + 0.28 * Math.sin(now * 1.4 + ray.phase);\n const dx = Math.cos(ray.angle);\n const dy = -Math.sin(ray.angle);\n const len = ray.length * maxRadius * lp;\n const ex = originX + dx * len;\n const ey = originY + dy * len;\n\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0.0));\n grad.addColorStop(0.05, rgba(disp.rayBase, 0.98 * twinkle));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.55 * twinkle));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Dots — drift slowly outward for a living \"data\" feel.\n for (let d = 0; d < ray.dots.length; d++) {\n const dot = ray.dots[d];\n const dp = reduced ? dot.p : (dot.p + now * 0.025) % 1;\n if (dp > lp) continue;\n const px = originX + dx * ray.length * maxRadius * dp;\n const py = originY + dy * ray.length * maxRadius * dp;\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + dot.phase);\n ctx.fillStyle = rgba(\n lerpRGB(disp.dotBase, disp.dotTip, dp),\n (dark ? 0.85 : 0.9) * (0.4 + 0.6 * dtw) * (1 - dp * 0.35),\n );\n ctx.beginPath();\n ctx.arc(px, py, dot.r, 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render();\n return () => ro.disconnect();\n }\n\n const loop = () => {\n render();\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n };\n }, [density, progress, reduced]);\n\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n
    \n setOpen((v) => !v)}\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n \n \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n
    \n );\n}\n\n/* ------------------------------------------------------------------ *\n * Count-up stat (Motion-driven).\n * ------------------------------------------------------------------ */\n\ntype Stat = {\n prefix?: string;\n value: number;\n decimals?: number;\n suffix?: string;\n label: string;\n};\n\nconst DEFAULT_STATS: Stat[] = [\n { value: 135, suffix: \"+\", label: \"currencies and payment\\nmethods supported\" },\n { prefix: \"US$\", value: 1.9, decimals: 1, suffix: \"tn\", label: \"in payments volume\\nprocessed in 2025\" },\n { value: 99.999, decimals: 3, suffix: \"%\", label: \"historical uptime\\nfor Stripe services\" },\n { value: 200, suffix: \"M+\", label: \"active subscriptions\\nmanaged on Stripe Billing\" },\n];\n\nfunction CountUp({\n value,\n decimals = 0,\n prefix = \"\",\n suffix = \"\",\n}: Stat) {\n const ref = useRef(null);\n const reduced = useReducedMotion();\n const fmt = (v: number) => {\n const fixed = v.toFixed(decimals);\n const [int, dec] = fixed.split(\".\");\n const withSep = int.replace(/\\B(?=(\\d{3})+(?!\\d))/g, \",\");\n return `${prefix}${dec !== undefined ? `${withSep}.${dec}` : withSep}${suffix}`;\n };\n\n useEffect(() => {\n const node = ref.current;\n if (!node) return;\n if (reduced) {\n node.textContent = fmt(value);\n return;\n }\n const controls = animate(0, value, {\n duration: 1.8,\n ease: [0.22, 1, 0.36, 1],\n onUpdate: (v) => {\n node.textContent = fmt(v);\n },\n });\n return () => controls.stop();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [value, decimals, reduced]);\n\n return (\n \n {fmt(value)}\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition.\n * ------------------------------------------------------------------ */\n\nconst containerVariants: Variants = {\n hidden: {},\n visible: { transition: { staggerChildren: 0.08, delayChildren: 0.1 } },\n};\nconst itemVariants: Variants = {\n hidden: { opacity: 0, y: 16 },\n visible: {\n opacity: 1,\n y: 0,\n transition: { type: \"spring\", stiffness: 140, damping: 20 },\n },\n};\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n stats?: Stat[];\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n stats = DEFAULT_STATS,\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Canvas burst. */}\n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Content. */}\n \n \n {title}\n \n\n \n {stats.map((stat, i) => (\n
    \n \n \n
    \n \n {stat.label}\n

    \n \n ))}\n \n \n \n );\n}\n\nexport default RadialBurstHero;\n", + "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n/** Wrap an angle into (-π, π]. */\nconst wrapAngle = (a: number) => {\n let x = a;\n while (x > Math.PI) x -= 2 * Math.PI;\n while (x < -Math.PI) x += 2 * Math.PI;\n return x;\n};\n/** Distance from point (px,py) to segment (ax,ay)-(bx,by). */\nconst segDist = (\n px: number,\n py: number,\n ax: number,\n ay: number,\n bx: number,\n by: number,\n) => {\n const dx = bx - ax;\n const dy = by - ay;\n const len2 = dx * dx + dy * dy || 1;\n const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1);\n const cx = ax + t * dx;\n const cy = ay + t * dy;\n return Math.hypot(px - cx, py - cy);\n};\n\n/** Horizontal spread multiplier — widens the fan without changing its height. */\nconst SPREAD_X = 1.3;\n\n/** One fiber: a streamline that grows, holds, extends, fades, then respawns. */\ntype Ray = {\n angle: number; // base emission angle (radians)\n maxLen: number; // target length as a fraction of maxRadius\n speed: number; // life units per second (→ lifetime ≈ 1/speed)\n life: number; // < 0 staggered delay, 0..1 active\n width: number; // core stroke width\n bright: number; // base brightness 0..1\n phase: number; // twinkle offset\n react: number; // smoothed pointer reaction 0..1\n bend: number; // smoothed angular bend toward the pointer\n hasDot: boolean; // whether a glowing dot rides this fiber's tip\n dotR: number; // tip-dot radius\n dotPhase: number; // tip-dot twinkle offset\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the interactive canvas background (reusable on its own).\n *\n * Fibers stream out of a bottom-center origin in a wide radial fan. Each\n * one continuously grows, slightly over-extends, fades, and regenerates\n * with fresh angle/length/speed/opacity, while glowing dots travel along\n * it. Rays near the pointer brighten, stretch, and bend toward it, then\n * ease back to their drift. The burst stays in the lower band so it never\n * reaches the headline above.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n /** Disable pointer reactivity. */\n interactive?: boolean;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n interactive = true,\n}: RadialBurstProps) {\n const canvasRef = useRef(null);\n const reduced = useReducedMotion();\n\n // Global intro fade, driven by Motion — read inside the canvas rAF loop.\n const intro = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<(dt: number) => void>(() => {});\n\n // Mount fade-in (Motion).\n useEffect(() => {\n if (reduced) {\n intro.set(1);\n return;\n }\n const controls = animate(intro, 1, {\n duration: 1.6,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [intro, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current(0);\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const pointer = { x: 0, y: 0, active: false };\n\n const respawn = (ray: Ray, initial: boolean) => {\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n const angle = lerp(aMin, aMax, Math.random()) + (Math.random() - 0.5) * 0.05;\n // Mild bias toward vertical, but side rays stay long so the fan fills\n // the full width along the bottom rather than tapering to a dome.\n const vert = Math.sin(clamp(angle, 0, Math.PI));\n ray.angle = angle;\n ray.maxLen = Math.min(\n 1.05,\n (0.8 + 0.2 * vert) * (0.6 + Math.random() * 0.45) +\n (Math.random() < 0.06 ? 0.12 : 0),\n );\n ray.speed = 0.085 + Math.random() * 0.13; // lifetime ≈ 4.5–11.8s\n ray.life = initial ? Math.random() : -Math.random() * 0.6;\n ray.width = 0.55 + Math.random() * 0.5;\n ray.bright = 0.5 + Math.random() * 0.5;\n ray.phase = Math.random() * Math.PI * 2;\n // A single glowing dot sits at the tip — it rides the growing tip, it\n // does not travel along the fiber.\n ray.hasDot = Math.random() < 0.72;\n ray.dotR = 0.8 + Math.random() * 1;\n ray.dotPhase = Math.random() * Math.PI * 2;\n };\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n // Origin sits on the bottom edge so the burst touches the bottom.\n originY = h;\n // Lower-band height — kept well below the headline, ~100px shorter.\n maxRadius = Math.max(h * 0.4, h * 0.6 - 100);\n const count = Math.round(\n Math.min(400, Math.max(220, w / 3.2)) * clamp(density, 0.4, 2),\n );\n rays = Array.from({ length: count }, () => {\n const ray: Ray = {\n angle: 0,\n maxLen: 0,\n speed: 0,\n life: 0,\n width: 1,\n bright: 1,\n phase: 0,\n react: 0,\n bend: 0,\n hasDot: false,\n dotR: 1,\n dotPhase: 0,\n };\n respawn(ray, true);\n return ray;\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current(0);\n };\n\n const render = (dt: number) => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const introA = intro.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n ctx.lineCap = \"round\";\n\n // Central bloom — a soft, diffuse glow rather than a hard bright disc.\n const bloomR =\n Math.min(w * 0.22, maxRadius * 0.6) * (0.7 + 0.3 * introA);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, (dark ? 0.5 : 0.44) * introA));\n bloom.addColorStop(0.3, rgba(disp.core, (dark ? 0.18 : 0.15) * introA));\n bloom.addColorStop(0.65, rgba(disp.core, (dark ? 0.06 : 0.05) * introA));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n const pointerOn = interactive && !reduced && pointer.active;\n const reactR = 170; // px radius of pointer influence\n // No reaction near the origin — only the middle/tip of fibers respond.\n const originGuard = maxRadius * 0.22;\n const pointerNearOrigin =\n Math.hypot(pointer.x - originX, pointer.y - originY) < originGuard;\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n\n if (!reduced) {\n ray.life += ray.speed * dt;\n if (ray.life >= 1) respawn(ray, false);\n }\n if (ray.life < 0) continue;\n\n const life = reduced ? 0.62 : ray.life;\n const growT = clamp(life / 0.7, 0, 1);\n const lenFrac = easeOut(growT);\n const extend = life > 0.7 ? (life - 0.7) / 0.3 : 0;\n const env = reduced\n ? 1\n : Math.min(1, life / 0.12) *\n (life > 0.8 ? clamp(1 - (life - 0.8) / 0.2, 0, 1) : 1);\n if (env <= 0) continue;\n\n const baseLen =\n ray.maxLen * maxRadius * lenFrac * (1 + 0.06 * extend);\n\n // Pointer reaction — engage quickly, return slowly. Hit-test only the\n // outer 35%→tip span so the dense near-origin zone stays calm.\n if (pointerOn && !pointerNearOrigin) {\n const cx = Math.cos(ray.angle) * SPREAD_X;\n const cy = -Math.sin(ray.angle);\n const ix = originX + cx * baseLen * 0.35;\n const iy = originY + cy * baseLen * 0.35;\n const bx = originX + cx * baseLen;\n const by = originY + cy * baseLen;\n const d = segDist(pointer.x, pointer.y, ix, iy, bx, by);\n let reactTarget = 0;\n let bendTarget = 0;\n if (d < reactR) {\n reactTarget = 1 - d / reactR;\n reactTarget *= reactTarget;\n const pAng = Math.atan2(\n -(pointer.y - originY),\n (pointer.x - originX) / SPREAD_X,\n );\n bendTarget = clamp(wrapAngle(pAng - ray.angle), -0.4, 0.4);\n }\n ray.react +=\n (reactTarget - ray.react) * (reactTarget > ray.react ? 0.14 : 0.06);\n ray.bend += (bendTarget * ray.react - ray.bend) * 0.1;\n } else if (ray.react !== 0 || ray.bend !== 0) {\n ray.react += -ray.react * 0.06;\n ray.bend += -ray.bend * 0.1;\n }\n\n const react = ray.react;\n const drawAngle = ray.angle + ray.bend;\n const dirx = Math.cos(drawAngle) * SPREAD_X;\n const diry = -Math.sin(drawAngle);\n const effLen = baseLen * (1 + 0.12 * react);\n const ex = originX + dirx * effLen;\n const ey = originY + diry * effLen;\n\n const twinkle = reduced ? 1 : 0.82 + 0.18 * Math.sin(now * 1.3 + ray.phase);\n const aBase = clamp(\n env * introA * ray.bright * twinkle * (1 + 0.9 * react),\n 0,\n 1,\n );\n\n // Brightness builds up *along* the fiber: near-zero through the dense\n // convergence zone at the origin (so overlapping starts don't blow out\n // to white), peaking once the fibers have fanned apart, fading at the tip.\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0));\n grad.addColorStop(0.16, rgba(disp.rayBase, 0.22 * aBase));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.85 * aBase));\n grad.addColorStop(0.75, rgba(disp.rayBase, 0.36 * aBase));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n\n // Soft wide pass → subtle blur/glow.\n ctx.lineWidth = ray.width * (dark ? 3.2 : 2.6);\n ctx.globalAlpha = dark ? 0.22 : 0.18;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Crisp core pass.\n ctx.lineWidth = ray.width;\n ctx.globalAlpha = 1;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // A single glowing dot sits at the very tip — it rides the growing\n // tip but never travels along the fiber.\n if (ray.hasDot) {\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + ray.dotPhase);\n // Dim dots whose tip still sits inside the convergence zone.\n const tipFade = clamp(effLen / (maxRadius * 0.28), 0, 1);\n ctx.fillStyle = rgba(\n disp.dotTip,\n clamp(\n env * introA * tipFade * (dark ? 0.95 : 1) * (0.45 + 0.55 * dtw) *\n (1 + 0.6 * react),\n 0,\n 1,\n ),\n );\n ctx.beginPath();\n ctx.arc(ex, ey, ray.dotR * (1 + 0.4 * react), 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalAlpha = 1;\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render(0);\n return () => ro.disconnect();\n }\n\n const onMove = (e: PointerEvent) => {\n const rect = canvas.getBoundingClientRect();\n const x = e.clientX - rect.left;\n const y = e.clientY - rect.top;\n if (x < 0 || y < 0 || x > rect.width || y > rect.height) {\n pointer.active = false;\n return;\n }\n pointer.x = x;\n pointer.y = y;\n pointer.active = true;\n };\n const onLeave = () => {\n pointer.active = false;\n };\n if (interactive) {\n window.addEventListener(\"pointermove\", onMove, { passive: true });\n window.addEventListener(\"blur\", onLeave);\n }\n\n let last = performance.now();\n const loop = (t: number) => {\n const dt = Math.min(0.05, (t - last) / 1000);\n last = t;\n render(dt);\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n if (interactive) {\n window.removeEventListener(\"pointermove\", onMove);\n window.removeEventListener(\"blur\", onLeave);\n }\n };\n }, [density, intro, reduced, interactive]);\n\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n
    \n setOpen((v) => !v)}\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n \n \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n
    \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition: headline over the interactive burst.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Interactive burst — masked so it fades out below the headline. */}\n \n \n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Headline. */}\n
    \n \n {title}\n \n
    \n \n );\n}\n\nexport default RadialBurstHero;\n", "type": "registry:ui", - "integrity": "sha384-858wzmwi4WlpfzklkxQxjFDmEhxfmWG9OTc5CbN2ZvkxlulRZPmXVWJboYrK+/ux" + "integrity": "sha384-aXAa9+odE7/IaRbmsz2WioIdDJ2fZmNfX3oFBbMP64zo1eOGUiDtXumKLQ2ONpWK" }, { "path": "utils/cn.ts", @@ -21,9 +21,25 @@ } ], "meta": { - "version": "1.0.0" + "version": "2.0.0" }, "changelog": [ + { + "version": "2.0.0", + "date": "2026-06-03", + "changes": [ + "Rewrote the burst as a continuous, seamlessly looping fiber-optic engine: rays grow, over-extend, fade, and respawn with fresh angle/length/speed/opacity instead of a one-time reveal.", + "Added pointer reactivity — fibers near the cursor (middle→tip only) brighten, stretch, and bend toward it, then ease back; the dense zone near the origin stays calm.", + "Each fiber now carries a single glowing dot fixed at its tip instead of dots travelling along it.", + "Softened the origin: fibers fade to near-zero through the dense convergence zone and peak only after fanning apart, plus a diffuser core bloom, so the bottom-center no longer blows out to white.", + "Raised the fiber count for a fuller, more balanced spread.", + "Increased overall fiber count and shortened the burst (~100px), with the origin touching the bottom edge.", + "Widened the fan horizontally and lengthened the side fibers so it spreads to the full width along the bottom (height unchanged).", + "Constrained the burst to a masked lower band so it no longer reaches the headline.", + "Removed the count-up stat row; the hero now shows the headline only.", + "Added a `burstProps.interactive` flag to toggle pointer reactivity." + ] + }, { "version": "1.0.0", "date": "2026-06-02", @@ -41,7 +57,8 @@ "block", "canvas", "starburst", - "stats" + "interactive", + "particles" ], "compatibility": { "react": "18+", diff --git a/registry.json b/registry.json index a8c21105..2e01c2a8 100644 --- a/registry.json +++ b/registry.json @@ -4113,9 +4113,9 @@ "files": [ { "path": "blocks/hero/radial-burst/component.tsx", - "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n type Variants,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n\ntype Ray = {\n angle: number;\n length: number; // fraction of maxRadius\n reveal: number; // 0..1 stagger delay\n phase: number; // twinkle offset\n dots: { p: number; r: number; phase: number }[];\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the canvas background (reusable on its own).\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n}: RadialBurstProps) {\n const canvasRef = useRef(null);\n const reduced = useReducedMotion();\n\n // Reveal progress driven by Motion — read inside the canvas rAF loop.\n const progress = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<() => void>(() => {});\n\n // Mount reveal animation (Motion).\n useEffect(() => {\n if (reduced) {\n progress.set(1);\n return;\n }\n const controls = animate(progress, 1, {\n duration: 1.7,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [progress, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current();\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n originY = h * 0.99;\n maxRadius = h * 0.94;\n const count = Math.round(\n Math.min(340, Math.max(120, w / 4.8)) *\n Math.min(2, Math.max(0.4, density)),\n );\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n rays = Array.from({ length: count }, (_, i) => {\n const t = count > 1 ? i / (count - 1) : 0.5;\n const angle = lerp(aMin, aMax, t) + (Math.random() - 0.5) * 0.012;\n // Closeness to vertical → longer rays → a dome silhouette.\n const vert = Math.sin(Math.min(Math.PI, Math.max(0, angle)));\n const length =\n (0.34 + 0.66 * vert) * (0.78 + Math.random() * 0.3) +\n (Math.random() < 0.06 ? 0.12 : 0);\n const dotCount = 1 + Math.floor(Math.random() * 4);\n return {\n angle,\n length: Math.min(1.05, length),\n reveal: (1 - vert) * 0.42 + Math.random() * 0.12,\n phase: Math.random() * Math.PI * 2,\n dots: Array.from({ length: dotCount }, () => ({\n p: 0.2 + Math.random() * 0.8,\n r: 0.6 + Math.random() * 0.9,\n phase: Math.random() * Math.PI * 2,\n })),\n };\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current();\n };\n\n const render = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const prog = progress.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n\n // Central bloom.\n const bloomR = Math.min(w * 0.22, maxRadius * 0.5) * (0.6 + 0.4 * prog);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, dark ? 0.7 : 0.6));\n bloom.addColorStop(0.45, rgba(disp.core, dark ? 0.22 : 0.18));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n ctx.lineWidth = 0.7;\n ctx.lineCap = \"round\";\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n const lp = easeOut(\n Math.min(1, Math.max(0, (prog - ray.reveal) / (1 - 0.54))),\n );\n if (lp <= 0) continue;\n const twinkle = reduced ? 1 : 0.72 + 0.28 * Math.sin(now * 1.4 + ray.phase);\n const dx = Math.cos(ray.angle);\n const dy = -Math.sin(ray.angle);\n const len = ray.length * maxRadius * lp;\n const ex = originX + dx * len;\n const ey = originY + dy * len;\n\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0.0));\n grad.addColorStop(0.05, rgba(disp.rayBase, 0.98 * twinkle));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.55 * twinkle));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Dots — drift slowly outward for a living \"data\" feel.\n for (let d = 0; d < ray.dots.length; d++) {\n const dot = ray.dots[d];\n const dp = reduced ? dot.p : (dot.p + now * 0.025) % 1;\n if (dp > lp) continue;\n const px = originX + dx * ray.length * maxRadius * dp;\n const py = originY + dy * ray.length * maxRadius * dp;\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + dot.phase);\n ctx.fillStyle = rgba(\n lerpRGB(disp.dotBase, disp.dotTip, dp),\n (dark ? 0.85 : 0.9) * (0.4 + 0.6 * dtw) * (1 - dp * 0.35),\n );\n ctx.beginPath();\n ctx.arc(px, py, dot.r, 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render();\n return () => ro.disconnect();\n }\n\n const loop = () => {\n render();\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n };\n }, [density, progress, reduced]);\n\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n
    \n setOpen((v) => !v)}\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n \n \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n
    \n );\n}\n\n/* ------------------------------------------------------------------ *\n * Count-up stat (Motion-driven).\n * ------------------------------------------------------------------ */\n\ntype Stat = {\n prefix?: string;\n value: number;\n decimals?: number;\n suffix?: string;\n label: string;\n};\n\nconst DEFAULT_STATS: Stat[] = [\n { value: 135, suffix: \"+\", label: \"currencies and payment\\nmethods supported\" },\n { prefix: \"US$\", value: 1.9, decimals: 1, suffix: \"tn\", label: \"in payments volume\\nprocessed in 2025\" },\n { value: 99.999, decimals: 3, suffix: \"%\", label: \"historical uptime\\nfor Stripe services\" },\n { value: 200, suffix: \"M+\", label: \"active subscriptions\\nmanaged on Stripe Billing\" },\n];\n\nfunction CountUp({\n value,\n decimals = 0,\n prefix = \"\",\n suffix = \"\",\n}: Stat) {\n const ref = useRef(null);\n const reduced = useReducedMotion();\n const fmt = (v: number) => {\n const fixed = v.toFixed(decimals);\n const [int, dec] = fixed.split(\".\");\n const withSep = int.replace(/\\B(?=(\\d{3})+(?!\\d))/g, \",\");\n return `${prefix}${dec !== undefined ? `${withSep}.${dec}` : withSep}${suffix}`;\n };\n\n useEffect(() => {\n const node = ref.current;\n if (!node) return;\n if (reduced) {\n node.textContent = fmt(value);\n return;\n }\n const controls = animate(0, value, {\n duration: 1.8,\n ease: [0.22, 1, 0.36, 1],\n onUpdate: (v) => {\n node.textContent = fmt(v);\n },\n });\n return () => controls.stop();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [value, decimals, reduced]);\n\n return (\n \n {fmt(value)}\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition.\n * ------------------------------------------------------------------ */\n\nconst containerVariants: Variants = {\n hidden: {},\n visible: { transition: { staggerChildren: 0.08, delayChildren: 0.1 } },\n};\nconst itemVariants: Variants = {\n hidden: { opacity: 0, y: 16 },\n visible: {\n opacity: 1,\n y: 0,\n transition: { type: \"spring\", stiffness: 140, damping: 20 },\n },\n};\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n stats?: Stat[];\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n stats = DEFAULT_STATS,\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Canvas burst. */}\n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Content. */}\n \n \n {title}\n \n\n \n {stats.map((stat, i) => (\n
    \n \n \n
    \n \n {stat.label}\n

    \n \n ))}\n \n \n \n );\n}\n\nexport default RadialBurstHero;\n", + "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n/** Wrap an angle into (-π, π]. */\nconst wrapAngle = (a: number) => {\n let x = a;\n while (x > Math.PI) x -= 2 * Math.PI;\n while (x < -Math.PI) x += 2 * Math.PI;\n return x;\n};\n/** Distance from point (px,py) to segment (ax,ay)-(bx,by). */\nconst segDist = (\n px: number,\n py: number,\n ax: number,\n ay: number,\n bx: number,\n by: number,\n) => {\n const dx = bx - ax;\n const dy = by - ay;\n const len2 = dx * dx + dy * dy || 1;\n const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1);\n const cx = ax + t * dx;\n const cy = ay + t * dy;\n return Math.hypot(px - cx, py - cy);\n};\n\n/** Horizontal spread multiplier — widens the fan without changing its height. */\nconst SPREAD_X = 1.3;\n\n/** One fiber: a streamline that grows, holds, extends, fades, then respawns. */\ntype Ray = {\n angle: number; // base emission angle (radians)\n maxLen: number; // target length as a fraction of maxRadius\n speed: number; // life units per second (→ lifetime ≈ 1/speed)\n life: number; // < 0 staggered delay, 0..1 active\n width: number; // core stroke width\n bright: number; // base brightness 0..1\n phase: number; // twinkle offset\n react: number; // smoothed pointer reaction 0..1\n bend: number; // smoothed angular bend toward the pointer\n hasDot: boolean; // whether a glowing dot rides this fiber's tip\n dotR: number; // tip-dot radius\n dotPhase: number; // tip-dot twinkle offset\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the interactive canvas background (reusable on its own).\n *\n * Fibers stream out of a bottom-center origin in a wide radial fan. Each\n * one continuously grows, slightly over-extends, fades, and regenerates\n * with fresh angle/length/speed/opacity, while glowing dots travel along\n * it. Rays near the pointer brighten, stretch, and bend toward it, then\n * ease back to their drift. The burst stays in the lower band so it never\n * reaches the headline above.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n /** Disable pointer reactivity. */\n interactive?: boolean;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n interactive = true,\n}: RadialBurstProps) {\n const canvasRef = useRef(null);\n const reduced = useReducedMotion();\n\n // Global intro fade, driven by Motion — read inside the canvas rAF loop.\n const intro = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<(dt: number) => void>(() => {});\n\n // Mount fade-in (Motion).\n useEffect(() => {\n if (reduced) {\n intro.set(1);\n return;\n }\n const controls = animate(intro, 1, {\n duration: 1.6,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [intro, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current(0);\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const pointer = { x: 0, y: 0, active: false };\n\n const respawn = (ray: Ray, initial: boolean) => {\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n const angle = lerp(aMin, aMax, Math.random()) + (Math.random() - 0.5) * 0.05;\n // Mild bias toward vertical, but side rays stay long so the fan fills\n // the full width along the bottom rather than tapering to a dome.\n const vert = Math.sin(clamp(angle, 0, Math.PI));\n ray.angle = angle;\n ray.maxLen = Math.min(\n 1.05,\n (0.8 + 0.2 * vert) * (0.6 + Math.random() * 0.45) +\n (Math.random() < 0.06 ? 0.12 : 0),\n );\n ray.speed = 0.085 + Math.random() * 0.13; // lifetime ≈ 4.5–11.8s\n ray.life = initial ? Math.random() : -Math.random() * 0.6;\n ray.width = 0.55 + Math.random() * 0.5;\n ray.bright = 0.5 + Math.random() * 0.5;\n ray.phase = Math.random() * Math.PI * 2;\n // A single glowing dot sits at the tip — it rides the growing tip, it\n // does not travel along the fiber.\n ray.hasDot = Math.random() < 0.72;\n ray.dotR = 0.8 + Math.random() * 1;\n ray.dotPhase = Math.random() * Math.PI * 2;\n };\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n // Origin sits on the bottom edge so the burst touches the bottom.\n originY = h;\n // Lower-band height — kept well below the headline, ~100px shorter.\n maxRadius = Math.max(h * 0.4, h * 0.6 - 100);\n const count = Math.round(\n Math.min(400, Math.max(220, w / 3.2)) * clamp(density, 0.4, 2),\n );\n rays = Array.from({ length: count }, () => {\n const ray: Ray = {\n angle: 0,\n maxLen: 0,\n speed: 0,\n life: 0,\n width: 1,\n bright: 1,\n phase: 0,\n react: 0,\n bend: 0,\n hasDot: false,\n dotR: 1,\n dotPhase: 0,\n };\n respawn(ray, true);\n return ray;\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current(0);\n };\n\n const render = (dt: number) => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const introA = intro.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n ctx.lineCap = \"round\";\n\n // Central bloom — a soft, diffuse glow rather than a hard bright disc.\n const bloomR =\n Math.min(w * 0.22, maxRadius * 0.6) * (0.7 + 0.3 * introA);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, (dark ? 0.5 : 0.44) * introA));\n bloom.addColorStop(0.3, rgba(disp.core, (dark ? 0.18 : 0.15) * introA));\n bloom.addColorStop(0.65, rgba(disp.core, (dark ? 0.06 : 0.05) * introA));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n const pointerOn = interactive && !reduced && pointer.active;\n const reactR = 170; // px radius of pointer influence\n // No reaction near the origin — only the middle/tip of fibers respond.\n const originGuard = maxRadius * 0.22;\n const pointerNearOrigin =\n Math.hypot(pointer.x - originX, pointer.y - originY) < originGuard;\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n\n if (!reduced) {\n ray.life += ray.speed * dt;\n if (ray.life >= 1) respawn(ray, false);\n }\n if (ray.life < 0) continue;\n\n const life = reduced ? 0.62 : ray.life;\n const growT = clamp(life / 0.7, 0, 1);\n const lenFrac = easeOut(growT);\n const extend = life > 0.7 ? (life - 0.7) / 0.3 : 0;\n const env = reduced\n ? 1\n : Math.min(1, life / 0.12) *\n (life > 0.8 ? clamp(1 - (life - 0.8) / 0.2, 0, 1) : 1);\n if (env <= 0) continue;\n\n const baseLen =\n ray.maxLen * maxRadius * lenFrac * (1 + 0.06 * extend);\n\n // Pointer reaction — engage quickly, return slowly. Hit-test only the\n // outer 35%→tip span so the dense near-origin zone stays calm.\n if (pointerOn && !pointerNearOrigin) {\n const cx = Math.cos(ray.angle) * SPREAD_X;\n const cy = -Math.sin(ray.angle);\n const ix = originX + cx * baseLen * 0.35;\n const iy = originY + cy * baseLen * 0.35;\n const bx = originX + cx * baseLen;\n const by = originY + cy * baseLen;\n const d = segDist(pointer.x, pointer.y, ix, iy, bx, by);\n let reactTarget = 0;\n let bendTarget = 0;\n if (d < reactR) {\n reactTarget = 1 - d / reactR;\n reactTarget *= reactTarget;\n const pAng = Math.atan2(\n -(pointer.y - originY),\n (pointer.x - originX) / SPREAD_X,\n );\n bendTarget = clamp(wrapAngle(pAng - ray.angle), -0.4, 0.4);\n }\n ray.react +=\n (reactTarget - ray.react) * (reactTarget > ray.react ? 0.14 : 0.06);\n ray.bend += (bendTarget * ray.react - ray.bend) * 0.1;\n } else if (ray.react !== 0 || ray.bend !== 0) {\n ray.react += -ray.react * 0.06;\n ray.bend += -ray.bend * 0.1;\n }\n\n const react = ray.react;\n const drawAngle = ray.angle + ray.bend;\n const dirx = Math.cos(drawAngle) * SPREAD_X;\n const diry = -Math.sin(drawAngle);\n const effLen = baseLen * (1 + 0.12 * react);\n const ex = originX + dirx * effLen;\n const ey = originY + diry * effLen;\n\n const twinkle = reduced ? 1 : 0.82 + 0.18 * Math.sin(now * 1.3 + ray.phase);\n const aBase = clamp(\n env * introA * ray.bright * twinkle * (1 + 0.9 * react),\n 0,\n 1,\n );\n\n // Brightness builds up *along* the fiber: near-zero through the dense\n // convergence zone at the origin (so overlapping starts don't blow out\n // to white), peaking once the fibers have fanned apart, fading at the tip.\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0));\n grad.addColorStop(0.16, rgba(disp.rayBase, 0.22 * aBase));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.85 * aBase));\n grad.addColorStop(0.75, rgba(disp.rayBase, 0.36 * aBase));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n\n // Soft wide pass → subtle blur/glow.\n ctx.lineWidth = ray.width * (dark ? 3.2 : 2.6);\n ctx.globalAlpha = dark ? 0.22 : 0.18;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Crisp core pass.\n ctx.lineWidth = ray.width;\n ctx.globalAlpha = 1;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // A single glowing dot sits at the very tip — it rides the growing\n // tip but never travels along the fiber.\n if (ray.hasDot) {\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + ray.dotPhase);\n // Dim dots whose tip still sits inside the convergence zone.\n const tipFade = clamp(effLen / (maxRadius * 0.28), 0, 1);\n ctx.fillStyle = rgba(\n disp.dotTip,\n clamp(\n env * introA * tipFade * (dark ? 0.95 : 1) * (0.45 + 0.55 * dtw) *\n (1 + 0.6 * react),\n 0,\n 1,\n ),\n );\n ctx.beginPath();\n ctx.arc(ex, ey, ray.dotR * (1 + 0.4 * react), 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalAlpha = 1;\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render(0);\n return () => ro.disconnect();\n }\n\n const onMove = (e: PointerEvent) => {\n const rect = canvas.getBoundingClientRect();\n const x = e.clientX - rect.left;\n const y = e.clientY - rect.top;\n if (x < 0 || y < 0 || x > rect.width || y > rect.height) {\n pointer.active = false;\n return;\n }\n pointer.x = x;\n pointer.y = y;\n pointer.active = true;\n };\n const onLeave = () => {\n pointer.active = false;\n };\n if (interactive) {\n window.addEventListener(\"pointermove\", onMove, { passive: true });\n window.addEventListener(\"blur\", onLeave);\n }\n\n let last = performance.now();\n const loop = (t: number) => {\n const dt = Math.min(0.05, (t - last) / 1000);\n last = t;\n render(dt);\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n if (interactive) {\n window.removeEventListener(\"pointermove\", onMove);\n window.removeEventListener(\"blur\", onLeave);\n }\n };\n }, [density, intro, reduced, interactive]);\n\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n
    \n setOpen((v) => !v)}\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n \n \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n
    \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition: headline over the interactive burst.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Interactive burst — masked so it fades out below the headline. */}\n \n \n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Headline. */}\n
    \n \n {title}\n \n
    \n \n );\n}\n\nexport default RadialBurstHero;\n", "type": "registry:ui", - "integrity": "sha384-858wzmwi4WlpfzklkxQxjFDmEhxfmWG9OTc5CbN2ZvkxlulRZPmXVWJboYrK+/ux" + "integrity": "sha384-aXAa9+odE7/IaRbmsz2WioIdDJ2fZmNfX3oFBbMP64zo1eOGUiDtXumKLQ2ONpWK" }, { "path": "utils/cn.ts", @@ -4125,9 +4125,25 @@ } ], "meta": { - "version": "1.0.0" + "version": "2.0.0" }, "changelog": [ + { + "version": "2.0.0", + "date": "2026-06-03", + "changes": [ + "Rewrote the burst as a continuous, seamlessly looping fiber-optic engine: rays grow, over-extend, fade, and respawn with fresh angle/length/speed/opacity instead of a one-time reveal.", + "Added pointer reactivity — fibers near the cursor (middle→tip only) brighten, stretch, and bend toward it, then ease back; the dense zone near the origin stays calm.", + "Each fiber now carries a single glowing dot fixed at its tip instead of dots travelling along it.", + "Softened the origin: fibers fade to near-zero through the dense convergence zone and peak only after fanning apart, plus a diffuser core bloom, so the bottom-center no longer blows out to white.", + "Raised the fiber count for a fuller, more balanced spread.", + "Increased overall fiber count and shortened the burst (~100px), with the origin touching the bottom edge.", + "Widened the fan horizontally and lengthened the side fibers so it spreads to the full width along the bottom (height unchanged).", + "Constrained the burst to a masked lower band so it no longer reaches the headline.", + "Removed the count-up stat row; the hero now shows the headline only.", + "Added a `burstProps.interactive` flag to toggle pointer reactivity." + ] + }, { "version": "1.0.0", "date": "2026-06-02", @@ -4145,7 +4161,8 @@ "block", "canvas", "starburst", - "stats" + "interactive", + "particles" ], "compatibility": { "react": "18+", diff --git a/registry/blocks/hero/radial-burst/component.tsx b/registry/blocks/hero/radial-burst/component.tsx index 179656c8..6b07441b 100644 --- a/registry/blocks/hero/radial-burst/component.tsx +++ b/registry/blocks/hero/radial-burst/component.tsx @@ -13,7 +13,6 @@ import { animate, useMotionValue, useReducedMotion, - type Variants, } from "motion/react"; import { CloudMoon, @@ -167,18 +166,61 @@ const lerpRGB = (a: RGB, b: RGB, t: number): RGB => [ ]; const rgba = (c: RGB, a: number) => `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`; +const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v)); const easeOut = (t: number) => 1 - Math.pow(1 - t, 3); +/** Wrap an angle into (-π, π]. */ +const wrapAngle = (a: number) => { + let x = a; + while (x > Math.PI) x -= 2 * Math.PI; + while (x < -Math.PI) x += 2 * Math.PI; + return x; +}; +/** Distance from point (px,py) to segment (ax,ay)-(bx,by). */ +const segDist = ( + px: number, + py: number, + ax: number, + ay: number, + bx: number, + by: number, +) => { + const dx = bx - ax; + const dy = by - ay; + const len2 = dx * dx + dy * dy || 1; + const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1); + const cx = ax + t * dx; + const cy = ay + t * dy; + return Math.hypot(px - cx, py - cy); +}; +/** Horizontal spread multiplier — widens the fan without changing its height. */ +const SPREAD_X = 1.3; + +/** One fiber: a streamline that grows, holds, extends, fades, then respawns. */ type Ray = { - angle: number; - length: number; // fraction of maxRadius - reveal: number; // 0..1 stagger delay + angle: number; // base emission angle (radians) + maxLen: number; // target length as a fraction of maxRadius + speed: number; // life units per second (→ lifetime ≈ 1/speed) + life: number; // < 0 staggered delay, 0..1 active + width: number; // core stroke width + bright: number; // base brightness 0..1 phase: number; // twinkle offset - dots: { p: number; r: number; phase: number }[]; + react: number; // smoothed pointer reaction 0..1 + bend: number; // smoothed angular bend toward the pointer + hasDot: boolean; // whether a glowing dot rides this fiber's tip + dotR: number; // tip-dot radius + dotPhase: number; // tip-dot twinkle offset }; /* ------------------------------------------------------------------ * - * RadialBurst — the canvas background (reusable on its own). + * RadialBurst — the interactive canvas background (reusable on its own). + * + * Fibers stream out of a bottom-center origin in a wide radial fan. Each + * one continuously grows, slightly over-extends, fades, and regenerates + * with fresh angle/length/speed/opacity, while glowing dots travel along + * it. Rays near the pointer brighten, stretch, and bend toward it, then + * ease back to their drift. The burst stays in the lower band so it never + * reaches the headline above. * ------------------------------------------------------------------ */ export type RadialBurstProps = { @@ -187,18 +229,21 @@ export type RadialBurstProps = { theme?: RadialBurstThemeId; /** Ray-count multiplier (0.4–2). */ density?: number; + /** Disable pointer reactivity. */ + interactive?: boolean; }; export function RadialBurst({ className, theme = "night", density = 1, + interactive = true, }: RadialBurstProps) { const canvasRef = useRef(null); const reduced = useReducedMotion(); - // Reveal progress driven by Motion — read inside the canvas rAF loop. - const progress = useMotionValue(reduced ? 1 : 0); + // Global intro fade, driven by Motion — read inside the canvas rAF loop. + const intro = useMotionValue(reduced ? 1 : 0); // Target palette + a smoothed "displayed" palette so theme switches lerp. const targetRef = useRef(THEME_BY_ID[theme].palette); @@ -215,20 +260,20 @@ export function RadialBurst({ dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB, dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB, }); - const renderRef = useRef<() => void>(() => {}); + const renderRef = useRef<(dt: number) => void>(() => {}); - // Mount reveal animation (Motion). + // Mount fade-in (Motion). useEffect(() => { if (reduced) { - progress.set(1); + intro.set(1); return; } - const controls = animate(progress, 1, { - duration: 1.7, + const controls = animate(intro, 1, { + duration: 1.6, ease: [0.22, 1, 0.36, 1], }); return () => controls.stop(); - }, [progress, reduced]); + }, [intro, reduced]); // Update the target palette when the theme changes; redraw if static. useEffect(() => { @@ -241,7 +286,7 @@ export function RadialBurst({ d.rayTip = [...p.rayTip] as RGB; d.dotBase = [...p.dotBase] as RGB; d.dotTip = [...p.dotTip] as RGB; - renderRef.current(); + renderRef.current(0); } }, [theme, reduced]); @@ -257,38 +302,61 @@ export function RadialBurst({ let originX = 0; let originY = 0; + const pointer = { x: 0, y: 0, active: false }; + + const respawn = (ray: Ray, initial: boolean) => { + const aMin = -0.06 * Math.PI; + const aMax = 1.06 * Math.PI; + const angle = lerp(aMin, aMax, Math.random()) + (Math.random() - 0.5) * 0.05; + // Mild bias toward vertical, but side rays stay long so the fan fills + // the full width along the bottom rather than tapering to a dome. + const vert = Math.sin(clamp(angle, 0, Math.PI)); + ray.angle = angle; + ray.maxLen = Math.min( + 1.05, + (0.8 + 0.2 * vert) * (0.6 + Math.random() * 0.45) + + (Math.random() < 0.06 ? 0.12 : 0), + ); + ray.speed = 0.085 + Math.random() * 0.13; // lifetime ≈ 4.5–11.8s + ray.life = initial ? Math.random() : -Math.random() * 0.6; + ray.width = 0.55 + Math.random() * 0.5; + ray.bright = 0.5 + Math.random() * 0.5; + ray.phase = Math.random() * Math.PI * 2; + // A single glowing dot sits at the tip — it rides the growing tip, it + // does not travel along the fiber. + ray.hasDot = Math.random() < 0.72; + ray.dotR = 0.8 + Math.random() * 1; + ray.dotPhase = Math.random() * Math.PI * 2; + }; + const seed = () => { const w = canvas.clientWidth; const h = canvas.clientHeight; originX = w / 2; - originY = h * 0.99; - maxRadius = h * 0.94; + // Origin sits on the bottom edge so the burst touches the bottom. + originY = h; + // Lower-band height — kept well below the headline, ~100px shorter. + maxRadius = Math.max(h * 0.4, h * 0.6 - 100); const count = Math.round( - Math.min(340, Math.max(120, w / 4.8)) * - Math.min(2, Math.max(0.4, density)), + Math.min(400, Math.max(220, w / 3.2)) * clamp(density, 0.4, 2), ); - const aMin = -0.06 * Math.PI; - const aMax = 1.06 * Math.PI; - rays = Array.from({ length: count }, (_, i) => { - const t = count > 1 ? i / (count - 1) : 0.5; - const angle = lerp(aMin, aMax, t) + (Math.random() - 0.5) * 0.012; - // Closeness to vertical → longer rays → a dome silhouette. - const vert = Math.sin(Math.min(Math.PI, Math.max(0, angle))); - const length = - (0.34 + 0.66 * vert) * (0.78 + Math.random() * 0.3) + - (Math.random() < 0.06 ? 0.12 : 0); - const dotCount = 1 + Math.floor(Math.random() * 4); - return { - angle, - length: Math.min(1.05, length), - reveal: (1 - vert) * 0.42 + Math.random() * 0.12, - phase: Math.random() * Math.PI * 2, - dots: Array.from({ length: dotCount }, () => ({ - p: 0.2 + Math.random() * 0.8, - r: 0.6 + Math.random() * 0.9, - phase: Math.random() * Math.PI * 2, - })), + rays = Array.from({ length: count }, () => { + const ray: Ray = { + angle: 0, + maxLen: 0, + speed: 0, + life: 0, + width: 1, + bright: 1, + phase: 0, + react: 0, + bend: 0, + hasDot: false, + dotR: 1, + dotPhase: 0, }; + respawn(ray, true); + return ray; }); }; @@ -299,14 +367,14 @@ export function RadialBurst({ canvas.height = Math.round(clientHeight * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); seed(); - if (reduced) renderRef.current(); + if (reduced) renderRef.current(0); }; - const render = () => { + const render = (dt: number) => { const w = canvas.clientWidth; const h = canvas.clientHeight; const now = performance.now() / 1000; - const prog = progress.get(); + const introA = intro.get(); const disp = dispRef.current; const target = targetRef.current; @@ -322,9 +390,11 @@ export function RadialBurst({ const dark = target.mode === "dark"; // Dark themes glow additively; light themes paint normally. ctx.globalCompositeOperation = dark ? "lighter" : "source-over"; + ctx.lineCap = "round"; - // Central bloom. - const bloomR = Math.min(w * 0.22, maxRadius * 0.5) * (0.6 + 0.4 * prog); + // Central bloom — a soft, diffuse glow rather than a hard bright disc. + const bloomR = + Math.min(w * 0.22, maxRadius * 0.6) * (0.7 + 0.3 * introA); const bloom = ctx.createRadialGradient( originX, originY, @@ -333,59 +403,137 @@ export function RadialBurst({ originY, bloomR, ); - bloom.addColorStop(0, rgba(disp.core, dark ? 0.7 : 0.6)); - bloom.addColorStop(0.45, rgba(disp.core, dark ? 0.22 : 0.18)); + bloom.addColorStop(0, rgba(disp.core, (dark ? 0.5 : 0.44) * introA)); + bloom.addColorStop(0.3, rgba(disp.core, (dark ? 0.18 : 0.15) * introA)); + bloom.addColorStop(0.65, rgba(disp.core, (dark ? 0.06 : 0.05) * introA)); bloom.addColorStop(1, rgba(disp.core, 0)); ctx.fillStyle = bloom; ctx.beginPath(); ctx.arc(originX, originY, bloomR, 0, Math.PI * 2); ctx.fill(); - ctx.lineWidth = 0.7; - ctx.lineCap = "round"; + const pointerOn = interactive && !reduced && pointer.active; + const reactR = 170; // px radius of pointer influence + // No reaction near the origin — only the middle/tip of fibers respond. + const originGuard = maxRadius * 0.22; + const pointerNearOrigin = + Math.hypot(pointer.x - originX, pointer.y - originY) < originGuard; for (let i = 0; i < rays.length; i++) { const ray = rays[i]; - const lp = easeOut( - Math.min(1, Math.max(0, (prog - ray.reveal) / (1 - 0.54))), + + if (!reduced) { + ray.life += ray.speed * dt; + if (ray.life >= 1) respawn(ray, false); + } + if (ray.life < 0) continue; + + const life = reduced ? 0.62 : ray.life; + const growT = clamp(life / 0.7, 0, 1); + const lenFrac = easeOut(growT); + const extend = life > 0.7 ? (life - 0.7) / 0.3 : 0; + const env = reduced + ? 1 + : Math.min(1, life / 0.12) * + (life > 0.8 ? clamp(1 - (life - 0.8) / 0.2, 0, 1) : 1); + if (env <= 0) continue; + + const baseLen = + ray.maxLen * maxRadius * lenFrac * (1 + 0.06 * extend); + + // Pointer reaction — engage quickly, return slowly. Hit-test only the + // outer 35%→tip span so the dense near-origin zone stays calm. + if (pointerOn && !pointerNearOrigin) { + const cx = Math.cos(ray.angle) * SPREAD_X; + const cy = -Math.sin(ray.angle); + const ix = originX + cx * baseLen * 0.35; + const iy = originY + cy * baseLen * 0.35; + const bx = originX + cx * baseLen; + const by = originY + cy * baseLen; + const d = segDist(pointer.x, pointer.y, ix, iy, bx, by); + let reactTarget = 0; + let bendTarget = 0; + if (d < reactR) { + reactTarget = 1 - d / reactR; + reactTarget *= reactTarget; + const pAng = Math.atan2( + -(pointer.y - originY), + (pointer.x - originX) / SPREAD_X, + ); + bendTarget = clamp(wrapAngle(pAng - ray.angle), -0.4, 0.4); + } + ray.react += + (reactTarget - ray.react) * (reactTarget > ray.react ? 0.14 : 0.06); + ray.bend += (bendTarget * ray.react - ray.bend) * 0.1; + } else if (ray.react !== 0 || ray.bend !== 0) { + ray.react += -ray.react * 0.06; + ray.bend += -ray.bend * 0.1; + } + + const react = ray.react; + const drawAngle = ray.angle + ray.bend; + const dirx = Math.cos(drawAngle) * SPREAD_X; + const diry = -Math.sin(drawAngle); + const effLen = baseLen * (1 + 0.12 * react); + const ex = originX + dirx * effLen; + const ey = originY + diry * effLen; + + const twinkle = reduced ? 1 : 0.82 + 0.18 * Math.sin(now * 1.3 + ray.phase); + const aBase = clamp( + env * introA * ray.bright * twinkle * (1 + 0.9 * react), + 0, + 1, ); - if (lp <= 0) continue; - const twinkle = reduced ? 1 : 0.72 + 0.28 * Math.sin(now * 1.4 + ray.phase); - const dx = Math.cos(ray.angle); - const dy = -Math.sin(ray.angle); - const len = ray.length * maxRadius * lp; - const ex = originX + dx * len; - const ey = originY + dy * len; + // Brightness builds up *along* the fiber: near-zero through the dense + // convergence zone at the origin (so overlapping starts don't blow out + // to white), peaking once the fibers have fanned apart, fading at the tip. const grad = ctx.createLinearGradient(originX, originY, ex, ey); - grad.addColorStop(0, rgba(disp.rayBase, 0.0)); - grad.addColorStop(0.05, rgba(disp.rayBase, 0.98 * twinkle)); - grad.addColorStop(0.4, rgba(disp.rayBase, 0.55 * twinkle)); + grad.addColorStop(0, rgba(disp.rayBase, 0)); + grad.addColorStop(0.16, rgba(disp.rayBase, 0.22 * aBase)); + grad.addColorStop(0.4, rgba(disp.rayBase, 0.85 * aBase)); + grad.addColorStop(0.75, rgba(disp.rayBase, 0.36 * aBase)); grad.addColorStop(1, rgba(disp.rayTip, 0)); ctx.strokeStyle = grad; + + // Soft wide pass → subtle blur/glow. + ctx.lineWidth = ray.width * (dark ? 3.2 : 2.6); + ctx.globalAlpha = dark ? 0.22 : 0.18; + ctx.beginPath(); + ctx.moveTo(originX, originY); + ctx.lineTo(ex, ey); + ctx.stroke(); + + // Crisp core pass. + ctx.lineWidth = ray.width; + ctx.globalAlpha = 1; ctx.beginPath(); ctx.moveTo(originX, originY); ctx.lineTo(ex, ey); ctx.stroke(); - // Dots — drift slowly outward for a living "data" feel. - for (let d = 0; d < ray.dots.length; d++) { - const dot = ray.dots[d]; - const dp = reduced ? dot.p : (dot.p + now * 0.025) % 1; - if (dp > lp) continue; - const px = originX + dx * ray.length * maxRadius * dp; - const py = originY + dy * ray.length * maxRadius * dp; - const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + dot.phase); + // A single glowing dot sits at the very tip — it rides the growing + // tip but never travels along the fiber. + if (ray.hasDot) { + const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + ray.dotPhase); + // Dim dots whose tip still sits inside the convergence zone. + const tipFade = clamp(effLen / (maxRadius * 0.28), 0, 1); ctx.fillStyle = rgba( - lerpRGB(disp.dotBase, disp.dotTip, dp), - (dark ? 0.85 : 0.9) * (0.4 + 0.6 * dtw) * (1 - dp * 0.35), + disp.dotTip, + clamp( + env * introA * tipFade * (dark ? 0.95 : 1) * (0.45 + 0.55 * dtw) * + (1 + 0.6 * react), + 0, + 1, + ), ); ctx.beginPath(); - ctx.arc(px, py, dot.r, 0, Math.PI * 2); + ctx.arc(ex, ey, ray.dotR * (1 + 0.4 * react), 0, Math.PI * 2); ctx.fill(); } } + ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over"; }; renderRef.current = render; @@ -395,12 +543,35 @@ export function RadialBurst({ ro.observe(canvas); if (reduced) { - render(); + render(0); return () => ro.disconnect(); } - const loop = () => { - render(); + const onMove = (e: PointerEvent) => { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + if (x < 0 || y < 0 || x > rect.width || y > rect.height) { + pointer.active = false; + return; + } + pointer.x = x; + pointer.y = y; + pointer.active = true; + }; + const onLeave = () => { + pointer.active = false; + }; + if (interactive) { + window.addEventListener("pointermove", onMove, { passive: true }); + window.addEventListener("blur", onLeave); + } + + let last = performance.now(); + const loop = (t: number) => { + const dt = Math.min(0.05, (t - last) / 1000); + last = t; + render(dt); raf = requestAnimationFrame(loop); }; raf = requestAnimationFrame(loop); @@ -408,8 +579,12 @@ export function RadialBurst({ return () => { cancelAnimationFrame(raf); ro.disconnect(); + if (interactive) { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("blur", onLeave); + } }; - }, [density, progress, reduced]); + }, [density, intro, reduced, interactive]); return ( (null); - const reduced = useReducedMotion(); - const fmt = (v: number) => { - const fixed = v.toFixed(decimals); - const [int, dec] = fixed.split("."); - const withSep = int.replace(/\B(?=(\d{3})+(?!\d))/g, ","); - return `${prefix}${dec !== undefined ? `${withSep}.${dec}` : withSep}${suffix}`; - }; - - useEffect(() => { - const node = ref.current; - if (!node) return; - if (reduced) { - node.textContent = fmt(value); - return; - } - const controls = animate(0, value, { - duration: 1.8, - ease: [0.22, 1, 0.36, 1], - onUpdate: (v) => { - node.textContent = fmt(v); - }, - }); - return () => controls.stop(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value, decimals, reduced]); - - return ( - - {fmt(value)} - - ); -} - -/* ------------------------------------------------------------------ * - * RadialBurstHero — full composition. + * RadialBurstHero — full composition: headline over the interactive burst. * ------------------------------------------------------------------ */ -const containerVariants: Variants = { - hidden: {}, - visible: { transition: { staggerChildren: 0.08, delayChildren: 0.1 } }, -}; -const itemVariants: Variants = { - hidden: { opacity: 0, y: 16 }, - visible: { - opacity: 1, - y: 0, - transition: { type: "spring", stiffness: 140, damping: 20 }, - }, -}; - export type RadialBurstHeroProps = Omit< ComponentProps<"section">, "children" | "title" @@ -608,7 +711,6 @@ export type RadialBurstHeroProps = Omit< /** Initial theme; also synced if it changes (e.g. from a site toggle). */ defaultTheme?: RadialBurstThemeId; title?: ReactNode; - stats?: Stat[]; burstProps?: Omit; }; @@ -622,7 +724,6 @@ export function RadialBurstHero({ of global commerce ), - stats = DEFAULT_STATS, burstProps, ...rest }: RadialBurstHeroProps) { @@ -661,63 +762,36 @@ export function RadialBurstHero({ /> - {/* Canvas burst. */} - + {/* Interactive burst — masked so it fades out below the headline. */} +
    + +
    {/* Theme switcher. */}
    - {/* Content. */} - + {/* Headline. */} +
    {title} - - - {stats.map((stat, i) => ( -
    -
    - -
    -

    - {stat.label} -

    -
    - ))} -
    - +
    ); } diff --git a/registry/blocks/hero/radial-burst/demo.tsx b/registry/blocks/hero/radial-burst/demo.tsx index dbcef9bc..529f3a9b 100644 --- a/registry/blocks/hero/radial-burst/demo.tsx +++ b/registry/blocks/hero/radial-burst/demo.tsx @@ -1,9 +1,20 @@ // Demo entries for docs previews — merged by `pnpm build:registry`. // Do not import this file directly from apps/www. +// Renders the same composition shown in this block's `docs.usageCode` +// (registry/components/hero-radial-burst.json) so Preview and Usage match. // `theme` is supplied by ComponentPreview and follows the site theme toggle; // it seeds the initial time-of-day theme (the in-block switcher takes over). export const demoEntries = { "hero-radial-burst": ({ theme = "dark" }) => ( - + + The backbone +
    + of global commerce + + } + /> )} as const; diff --git a/registry/components/hero-radial-burst.json b/registry/components/hero-radial-burst.json index 199a3748..f1eab3e7 100644 --- a/registry/components/hero-radial-burst.json +++ b/registry/components/hero-radial-burst.json @@ -16,7 +16,7 @@ }, "docs": { "name": "Radial Burst Hero", - "description": "A Stripe-style hero block: a canvas radial burst of fine rays — each fading from a bright bottom-center core to faint tips, with dots drifting outward along them — beneath a headline and a count-up stat row. Ships with six time-of-day themes (Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night) and an in-block switcher; the background crossfades and the burst colors lerp between themes. devicePixelRatio-aware, keyboard-accessible switcher, honors `prefers-reduced-motion`, and uses `motion` for the reveal, theme blend, count-ups, and dropdown.", + "description": "A Stripe-style hero block: an interactive fiber-optic radial burst on canvas. Fine glowing rays stream out of a bottom-center origin in a wide fan, each one continuously growing, over-extending, fading, and regenerating with fresh angle/length/speed/opacity, with a single glowing dot riding each fiber's tip. Hovering the middle or tip of a fiber makes it (and its neighbours) brighten, stretch, and bend toward the cursor, then ease back to their drift; the dense zone near the origin stays calm. The burst sits in a short lower band, masked so it fades out below the headline. Ships with six time-of-day themes (Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night) and an in-block switcher; the background crossfades and the burst colors lerp between themes. devicePixelRatio-aware, keyboard-accessible switcher, honors `prefers-reduced-motion` (renders a calm static frame), and uses `motion` for the intro, theme blend, and dropdown.", "icon": "LucideSparkle", "category": "Hero", "kind": "block", @@ -33,15 +33,10 @@ "type": "ReactNode", "description": "Headline content. Defaults to \"The backbone of global commerce\"." }, - { - "name": "stats", - "type": "{ prefix?: string; value: number; decimals?: number; suffix?: string; label: string }[]", - "description": "Stat row. Each value counts up on mount; `label` supports newlines. Defaults to the four Stripe-style stats." - }, { "name": "burstProps", - "type": "{ className?: string; density?: number }", - "description": "Forwarded to the canvas burst layer (`RadialBurst`). `density` scales ray count (0.4–2)." + "type": "{ className?: string; density?: number; interactive?: boolean }", + "description": "Forwarded to the canvas burst layer (`RadialBurst`). `density` scales ray count (0.4–2); set `interactive` to false to disable pointer reactivity." }, { "name": "className", @@ -49,9 +44,9 @@ "description": "Classes for the outer `
    `." } ], - "usageCode": "import { RadialBurstHero } from \"@/components/ui/hero-radial-burst\";\n\nexport default function Hero() {\n return ;\n}\n\n// Or drive the time-of-day theme + content yourself:\n// Built for\\n global scale}\n// stats={[\n// { value: 135, suffix: \"+\", label: \"currencies supported\" },\n// { prefix: \"US$\", value: 1.9, decimals: 1, suffix: \"tn\", label: \"processed in 2025\" },\n// { value: 99.999, decimals: 3, suffix: \"%\", label: \"historical uptime\" },\n// { value: 200, suffix: \"M+\", label: \"active subscriptions\" },\n// ]}\n// />", + "usageCode": "import { RadialBurstHero } from \"@/components/ui/hero-radial-burst\";\n\nexport default function Hero() {\n return (\n \n The backbone\n
    \n of global commerce\n \n }\n />\n );\n}", "docs": { - "overview": "A canvas radial burst rises from a bright bottom-center core: ~200 fine rays fan across the upper semicircle (longest near vertical, forming a dome), each a base-bright→tip-faint gradient with dots drifting outward. Above it sit a headline and a count-up stat row. Six time-of-day themes — Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night — are selectable from an in-block dropdown; switching crossfades the background gradient and eases the burst colors between palettes. `RadialBurst` is exported separately for reuse under your own layout.", + "overview": "An interactive fiber-optic burst rises from a bottom-center origin that touches the bottom edge: fine rays fan across the upper semicircle (longest near vertical, forming a soft dome), each a base-bright→tip-faint gradient drawn as a soft wide glow pass plus a crisp core, with a single glowing dot riding its tip. Every ray continuously grows, slightly over-extends, fades, and respawns with a fresh angle, length, speed, and opacity, so the loop is seamless with no global reset. Hovering the middle or tip of a fiber makes it and its neighbours brighten, stretch, and bend toward the cursor before easing back; the dense zone near the origin does not react. The burst is kept to a short lower band, masked so it never reaches the headline above. Six time-of-day themes — Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night — are selectable from an in-block dropdown; switching crossfades the background gradient and eases the burst colors between palettes. `RadialBurst` is exported separately for reuse under your own layout.", "scenarios": [] } }, @@ -60,7 +55,8 @@ "block", "canvas", "starburst", - "stats" + "interactive", + "particles" ], "peerDependencies": [ "react", @@ -77,6 +73,22 @@ "status": "unaudited" }, "changelog": [ + { + "version": "2.0.0", + "date": "2026-06-03", + "changes": [ + "Rewrote the burst as a continuous, seamlessly looping fiber-optic engine: rays grow, over-extend, fade, and respawn with fresh angle/length/speed/opacity instead of a one-time reveal.", + "Added pointer reactivity — fibers near the cursor (middle→tip only) brighten, stretch, and bend toward it, then ease back; the dense zone near the origin stays calm.", + "Each fiber now carries a single glowing dot fixed at its tip instead of dots travelling along it.", + "Softened the origin: fibers fade to near-zero through the dense convergence zone and peak only after fanning apart, plus a diffuser core bloom, so the bottom-center no longer blows out to white.", + "Raised the fiber count for a fuller, more balanced spread.", + "Increased overall fiber count and shortened the burst (~100px), with the origin touching the bottom edge.", + "Widened the fan horizontally and lengthened the side fibers so it spreads to the full width along the bottom (height unchanged).", + "Constrained the burst to a masked lower band so it no longer reaches the headline.", + "Removed the count-up stat row; the hero now shows the headline only.", + "Added a `burstProps.interactive` flag to toggle pointer reactivity." + ] + }, { "version": "1.0.0", "date": "2026-06-02", From c5230eda0cfbfb45d178524f7d7597ded6da7fde Mon Sep 17 00:00:00 2001 From: pras75299 Date: Fri, 5 Jun 2026 20:29:40 +0530 Subject: [PATCH 3/3] fix(hero-radial-burst): use plain button-menu a11y for theme switcher --- apps/www/components/ui/hero-radial-burst.tsx | 5 +---- apps/www/public/r/hero-radial-burst.json | 2 +- apps/www/public/registry.json | 13 ++++++++++--- apps/www/public/registry/changelogs.json | 7 +++++++ apps/www/public/registry/hero-radial-burst.json | 13 ++++++++++--- registry.json | 13 ++++++++++--- registry/blocks/hero/radial-burst/component.tsx | 5 +---- registry/components/hero-radial-burst.json | 7 +++++++ 8 files changed, 47 insertions(+), 18 deletions(-) diff --git a/apps/www/components/ui/hero-radial-burst.tsx b/apps/www/components/ui/hero-radial-burst.tsx index 6b07441b..a71984f0 100644 --- a/apps/www/components/ui/hero-radial-burst.tsx +++ b/apps/www/components/ui/hero-radial-burst.tsx @@ -634,7 +634,6 @@ function ThemeSwitcher({ \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition: headline over the interactive burst.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Interactive burst — masked so it fades out below the headline. */}\n \n \n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Headline. */}\n
    \n \n {title}\n \n
    \n
    \n );\n}\n\nexport default RadialBurstHero;\n", + "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n/** Wrap an angle into (-π, π]. */\nconst wrapAngle = (a: number) => {\n let x = a;\n while (x > Math.PI) x -= 2 * Math.PI;\n while (x < -Math.PI) x += 2 * Math.PI;\n return x;\n};\n/** Distance from point (px,py) to segment (ax,ay)-(bx,by). */\nconst segDist = (\n px: number,\n py: number,\n ax: number,\n ay: number,\n bx: number,\n by: number,\n) => {\n const dx = bx - ax;\n const dy = by - ay;\n const len2 = dx * dx + dy * dy || 1;\n const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1);\n const cx = ax + t * dx;\n const cy = ay + t * dy;\n return Math.hypot(px - cx, py - cy);\n};\n\n/** Horizontal spread multiplier — widens the fan without changing its height. */\nconst SPREAD_X = 1.3;\n\n/** One fiber: a streamline that grows, holds, extends, fades, then respawns. */\ntype Ray = {\n angle: number; // base emission angle (radians)\n maxLen: number; // target length as a fraction of maxRadius\n speed: number; // life units per second (→ lifetime ≈ 1/speed)\n life: number; // < 0 staggered delay, 0..1 active\n width: number; // core stroke width\n bright: number; // base brightness 0..1\n phase: number; // twinkle offset\n react: number; // smoothed pointer reaction 0..1\n bend: number; // smoothed angular bend toward the pointer\n hasDot: boolean; // whether a glowing dot rides this fiber's tip\n dotR: number; // tip-dot radius\n dotPhase: number; // tip-dot twinkle offset\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the interactive canvas background (reusable on its own).\n *\n * Fibers stream out of a bottom-center origin in a wide radial fan. Each\n * one continuously grows, slightly over-extends, fades, and regenerates\n * with fresh angle/length/speed/opacity, while glowing dots travel along\n * it. Rays near the pointer brighten, stretch, and bend toward it, then\n * ease back to their drift. The burst stays in the lower band so it never\n * reaches the headline above.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n /** Disable pointer reactivity. */\n interactive?: boolean;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n interactive = true,\n}: RadialBurstProps) {\n const canvasRef = useRef(null);\n const reduced = useReducedMotion();\n\n // Global intro fade, driven by Motion — read inside the canvas rAF loop.\n const intro = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<(dt: number) => void>(() => {});\n\n // Mount fade-in (Motion).\n useEffect(() => {\n if (reduced) {\n intro.set(1);\n return;\n }\n const controls = animate(intro, 1, {\n duration: 1.6,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [intro, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current(0);\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const pointer = { x: 0, y: 0, active: false };\n\n const respawn = (ray: Ray, initial: boolean) => {\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n const angle = lerp(aMin, aMax, Math.random()) + (Math.random() - 0.5) * 0.05;\n // Mild bias toward vertical, but side rays stay long so the fan fills\n // the full width along the bottom rather than tapering to a dome.\n const vert = Math.sin(clamp(angle, 0, Math.PI));\n ray.angle = angle;\n ray.maxLen = Math.min(\n 1.05,\n (0.8 + 0.2 * vert) * (0.6 + Math.random() * 0.45) +\n (Math.random() < 0.06 ? 0.12 : 0),\n );\n ray.speed = 0.085 + Math.random() * 0.13; // lifetime ≈ 4.5–11.8s\n ray.life = initial ? Math.random() : -Math.random() * 0.6;\n ray.width = 0.55 + Math.random() * 0.5;\n ray.bright = 0.5 + Math.random() * 0.5;\n ray.phase = Math.random() * Math.PI * 2;\n // A single glowing dot sits at the tip — it rides the growing tip, it\n // does not travel along the fiber.\n ray.hasDot = Math.random() < 0.72;\n ray.dotR = 0.8 + Math.random() * 1;\n ray.dotPhase = Math.random() * Math.PI * 2;\n };\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n // Origin sits on the bottom edge so the burst touches the bottom.\n originY = h;\n // Lower-band height — kept well below the headline, ~100px shorter.\n maxRadius = Math.max(h * 0.4, h * 0.6 - 100);\n const count = Math.round(\n Math.min(400, Math.max(220, w / 3.2)) * clamp(density, 0.4, 2),\n );\n rays = Array.from({ length: count }, () => {\n const ray: Ray = {\n angle: 0,\n maxLen: 0,\n speed: 0,\n life: 0,\n width: 1,\n bright: 1,\n phase: 0,\n react: 0,\n bend: 0,\n hasDot: false,\n dotR: 1,\n dotPhase: 0,\n };\n respawn(ray, true);\n return ray;\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current(0);\n };\n\n const render = (dt: number) => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const introA = intro.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n ctx.lineCap = \"round\";\n\n // Central bloom — a soft, diffuse glow rather than a hard bright disc.\n const bloomR =\n Math.min(w * 0.22, maxRadius * 0.6) * (0.7 + 0.3 * introA);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, (dark ? 0.5 : 0.44) * introA));\n bloom.addColorStop(0.3, rgba(disp.core, (dark ? 0.18 : 0.15) * introA));\n bloom.addColorStop(0.65, rgba(disp.core, (dark ? 0.06 : 0.05) * introA));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n const pointerOn = interactive && !reduced && pointer.active;\n const reactR = 170; // px radius of pointer influence\n // No reaction near the origin — only the middle/tip of fibers respond.\n const originGuard = maxRadius * 0.22;\n const pointerNearOrigin =\n Math.hypot(pointer.x - originX, pointer.y - originY) < originGuard;\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n\n if (!reduced) {\n ray.life += ray.speed * dt;\n if (ray.life >= 1) respawn(ray, false);\n }\n if (ray.life < 0) continue;\n\n const life = reduced ? 0.62 : ray.life;\n const growT = clamp(life / 0.7, 0, 1);\n const lenFrac = easeOut(growT);\n const extend = life > 0.7 ? (life - 0.7) / 0.3 : 0;\n const env = reduced\n ? 1\n : Math.min(1, life / 0.12) *\n (life > 0.8 ? clamp(1 - (life - 0.8) / 0.2, 0, 1) : 1);\n if (env <= 0) continue;\n\n const baseLen =\n ray.maxLen * maxRadius * lenFrac * (1 + 0.06 * extend);\n\n // Pointer reaction — engage quickly, return slowly. Hit-test only the\n // outer 35%→tip span so the dense near-origin zone stays calm.\n if (pointerOn && !pointerNearOrigin) {\n const cx = Math.cos(ray.angle) * SPREAD_X;\n const cy = -Math.sin(ray.angle);\n const ix = originX + cx * baseLen * 0.35;\n const iy = originY + cy * baseLen * 0.35;\n const bx = originX + cx * baseLen;\n const by = originY + cy * baseLen;\n const d = segDist(pointer.x, pointer.y, ix, iy, bx, by);\n let reactTarget = 0;\n let bendTarget = 0;\n if (d < reactR) {\n reactTarget = 1 - d / reactR;\n reactTarget *= reactTarget;\n const pAng = Math.atan2(\n -(pointer.y - originY),\n (pointer.x - originX) / SPREAD_X,\n );\n bendTarget = clamp(wrapAngle(pAng - ray.angle), -0.4, 0.4);\n }\n ray.react +=\n (reactTarget - ray.react) * (reactTarget > ray.react ? 0.14 : 0.06);\n ray.bend += (bendTarget * ray.react - ray.bend) * 0.1;\n } else if (ray.react !== 0 || ray.bend !== 0) {\n ray.react += -ray.react * 0.06;\n ray.bend += -ray.bend * 0.1;\n }\n\n const react = ray.react;\n const drawAngle = ray.angle + ray.bend;\n const dirx = Math.cos(drawAngle) * SPREAD_X;\n const diry = -Math.sin(drawAngle);\n const effLen = baseLen * (1 + 0.12 * react);\n const ex = originX + dirx * effLen;\n const ey = originY + diry * effLen;\n\n const twinkle = reduced ? 1 : 0.82 + 0.18 * Math.sin(now * 1.3 + ray.phase);\n const aBase = clamp(\n env * introA * ray.bright * twinkle * (1 + 0.9 * react),\n 0,\n 1,\n );\n\n // Brightness builds up *along* the fiber: near-zero through the dense\n // convergence zone at the origin (so overlapping starts don't blow out\n // to white), peaking once the fibers have fanned apart, fading at the tip.\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0));\n grad.addColorStop(0.16, rgba(disp.rayBase, 0.22 * aBase));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.85 * aBase));\n grad.addColorStop(0.75, rgba(disp.rayBase, 0.36 * aBase));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n\n // Soft wide pass → subtle blur/glow.\n ctx.lineWidth = ray.width * (dark ? 3.2 : 2.6);\n ctx.globalAlpha = dark ? 0.22 : 0.18;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Crisp core pass.\n ctx.lineWidth = ray.width;\n ctx.globalAlpha = 1;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // A single glowing dot sits at the very tip — it rides the growing\n // tip but never travels along the fiber.\n if (ray.hasDot) {\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + ray.dotPhase);\n // Dim dots whose tip still sits inside the convergence zone.\n const tipFade = clamp(effLen / (maxRadius * 0.28), 0, 1);\n ctx.fillStyle = rgba(\n disp.dotTip,\n clamp(\n env * introA * tipFade * (dark ? 0.95 : 1) * (0.45 + 0.55 * dtw) *\n (1 + 0.6 * react),\n 0,\n 1,\n ),\n );\n ctx.beginPath();\n ctx.arc(ex, ey, ray.dotR * (1 + 0.4 * react), 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalAlpha = 1;\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render(0);\n return () => ro.disconnect();\n }\n\n const onMove = (e: PointerEvent) => {\n const rect = canvas.getBoundingClientRect();\n const x = e.clientX - rect.left;\n const y = e.clientY - rect.top;\n if (x < 0 || y < 0 || x > rect.width || y > rect.height) {\n pointer.active = false;\n return;\n }\n pointer.x = x;\n pointer.y = y;\n pointer.active = true;\n };\n const onLeave = () => {\n pointer.active = false;\n };\n if (interactive) {\n window.addEventListener(\"pointermove\", onMove, { passive: true });\n window.addEventListener(\"blur\", onLeave);\n }\n\n let last = performance.now();\n const loop = (t: number) => {\n const dt = Math.min(0.05, (t - last) / 1000);\n last = t;\n render(dt);\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n if (interactive) {\n window.removeEventListener(\"pointermove\", onMove);\n window.removeEventListener(\"blur\", onLeave);\n }\n };\n }, [density, intro, reduced, interactive]);\n\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n
    \n setOpen((v) => !v)}\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n \n \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n
    \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition: headline over the interactive burst.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Interactive burst — masked so it fades out below the headline. */}\n \n \n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Headline. */}\n
    \n \n {title}\n \n
    \n \n );\n}\n\nexport default RadialBurstHero;\n", "type": "registry:component", "target": "components/ui/hero-radial-burst.tsx" } diff --git a/apps/www/public/registry.json b/apps/www/public/registry.json index 2e01c2a8..f001c75b 100644 --- a/apps/www/public/registry.json +++ b/apps/www/public/registry.json @@ -4113,9 +4113,9 @@ "files": [ { "path": "blocks/hero/radial-burst/component.tsx", - "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n/** Wrap an angle into (-π, π]. */\nconst wrapAngle = (a: number) => {\n let x = a;\n while (x > Math.PI) x -= 2 * Math.PI;\n while (x < -Math.PI) x += 2 * Math.PI;\n return x;\n};\n/** Distance from point (px,py) to segment (ax,ay)-(bx,by). */\nconst segDist = (\n px: number,\n py: number,\n ax: number,\n ay: number,\n bx: number,\n by: number,\n) => {\n const dx = bx - ax;\n const dy = by - ay;\n const len2 = dx * dx + dy * dy || 1;\n const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1);\n const cx = ax + t * dx;\n const cy = ay + t * dy;\n return Math.hypot(px - cx, py - cy);\n};\n\n/** Horizontal spread multiplier — widens the fan without changing its height. */\nconst SPREAD_X = 1.3;\n\n/** One fiber: a streamline that grows, holds, extends, fades, then respawns. */\ntype Ray = {\n angle: number; // base emission angle (radians)\n maxLen: number; // target length as a fraction of maxRadius\n speed: number; // life units per second (→ lifetime ≈ 1/speed)\n life: number; // < 0 staggered delay, 0..1 active\n width: number; // core stroke width\n bright: number; // base brightness 0..1\n phase: number; // twinkle offset\n react: number; // smoothed pointer reaction 0..1\n bend: number; // smoothed angular bend toward the pointer\n hasDot: boolean; // whether a glowing dot rides this fiber's tip\n dotR: number; // tip-dot radius\n dotPhase: number; // tip-dot twinkle offset\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the interactive canvas background (reusable on its own).\n *\n * Fibers stream out of a bottom-center origin in a wide radial fan. Each\n * one continuously grows, slightly over-extends, fades, and regenerates\n * with fresh angle/length/speed/opacity, while glowing dots travel along\n * it. Rays near the pointer brighten, stretch, and bend toward it, then\n * ease back to their drift. The burst stays in the lower band so it never\n * reaches the headline above.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n /** Disable pointer reactivity. */\n interactive?: boolean;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n interactive = true,\n}: RadialBurstProps) {\n const canvasRef = useRef(null);\n const reduced = useReducedMotion();\n\n // Global intro fade, driven by Motion — read inside the canvas rAF loop.\n const intro = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<(dt: number) => void>(() => {});\n\n // Mount fade-in (Motion).\n useEffect(() => {\n if (reduced) {\n intro.set(1);\n return;\n }\n const controls = animate(intro, 1, {\n duration: 1.6,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [intro, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current(0);\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const pointer = { x: 0, y: 0, active: false };\n\n const respawn = (ray: Ray, initial: boolean) => {\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n const angle = lerp(aMin, aMax, Math.random()) + (Math.random() - 0.5) * 0.05;\n // Mild bias toward vertical, but side rays stay long so the fan fills\n // the full width along the bottom rather than tapering to a dome.\n const vert = Math.sin(clamp(angle, 0, Math.PI));\n ray.angle = angle;\n ray.maxLen = Math.min(\n 1.05,\n (0.8 + 0.2 * vert) * (0.6 + Math.random() * 0.45) +\n (Math.random() < 0.06 ? 0.12 : 0),\n );\n ray.speed = 0.085 + Math.random() * 0.13; // lifetime ≈ 4.5–11.8s\n ray.life = initial ? Math.random() : -Math.random() * 0.6;\n ray.width = 0.55 + Math.random() * 0.5;\n ray.bright = 0.5 + Math.random() * 0.5;\n ray.phase = Math.random() * Math.PI * 2;\n // A single glowing dot sits at the tip — it rides the growing tip, it\n // does not travel along the fiber.\n ray.hasDot = Math.random() < 0.72;\n ray.dotR = 0.8 + Math.random() * 1;\n ray.dotPhase = Math.random() * Math.PI * 2;\n };\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n // Origin sits on the bottom edge so the burst touches the bottom.\n originY = h;\n // Lower-band height — kept well below the headline, ~100px shorter.\n maxRadius = Math.max(h * 0.4, h * 0.6 - 100);\n const count = Math.round(\n Math.min(400, Math.max(220, w / 3.2)) * clamp(density, 0.4, 2),\n );\n rays = Array.from({ length: count }, () => {\n const ray: Ray = {\n angle: 0,\n maxLen: 0,\n speed: 0,\n life: 0,\n width: 1,\n bright: 1,\n phase: 0,\n react: 0,\n bend: 0,\n hasDot: false,\n dotR: 1,\n dotPhase: 0,\n };\n respawn(ray, true);\n return ray;\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current(0);\n };\n\n const render = (dt: number) => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const introA = intro.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n ctx.lineCap = \"round\";\n\n // Central bloom — a soft, diffuse glow rather than a hard bright disc.\n const bloomR =\n Math.min(w * 0.22, maxRadius * 0.6) * (0.7 + 0.3 * introA);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, (dark ? 0.5 : 0.44) * introA));\n bloom.addColorStop(0.3, rgba(disp.core, (dark ? 0.18 : 0.15) * introA));\n bloom.addColorStop(0.65, rgba(disp.core, (dark ? 0.06 : 0.05) * introA));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n const pointerOn = interactive && !reduced && pointer.active;\n const reactR = 170; // px radius of pointer influence\n // No reaction near the origin — only the middle/tip of fibers respond.\n const originGuard = maxRadius * 0.22;\n const pointerNearOrigin =\n Math.hypot(pointer.x - originX, pointer.y - originY) < originGuard;\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n\n if (!reduced) {\n ray.life += ray.speed * dt;\n if (ray.life >= 1) respawn(ray, false);\n }\n if (ray.life < 0) continue;\n\n const life = reduced ? 0.62 : ray.life;\n const growT = clamp(life / 0.7, 0, 1);\n const lenFrac = easeOut(growT);\n const extend = life > 0.7 ? (life - 0.7) / 0.3 : 0;\n const env = reduced\n ? 1\n : Math.min(1, life / 0.12) *\n (life > 0.8 ? clamp(1 - (life - 0.8) / 0.2, 0, 1) : 1);\n if (env <= 0) continue;\n\n const baseLen =\n ray.maxLen * maxRadius * lenFrac * (1 + 0.06 * extend);\n\n // Pointer reaction — engage quickly, return slowly. Hit-test only the\n // outer 35%→tip span so the dense near-origin zone stays calm.\n if (pointerOn && !pointerNearOrigin) {\n const cx = Math.cos(ray.angle) * SPREAD_X;\n const cy = -Math.sin(ray.angle);\n const ix = originX + cx * baseLen * 0.35;\n const iy = originY + cy * baseLen * 0.35;\n const bx = originX + cx * baseLen;\n const by = originY + cy * baseLen;\n const d = segDist(pointer.x, pointer.y, ix, iy, bx, by);\n let reactTarget = 0;\n let bendTarget = 0;\n if (d < reactR) {\n reactTarget = 1 - d / reactR;\n reactTarget *= reactTarget;\n const pAng = Math.atan2(\n -(pointer.y - originY),\n (pointer.x - originX) / SPREAD_X,\n );\n bendTarget = clamp(wrapAngle(pAng - ray.angle), -0.4, 0.4);\n }\n ray.react +=\n (reactTarget - ray.react) * (reactTarget > ray.react ? 0.14 : 0.06);\n ray.bend += (bendTarget * ray.react - ray.bend) * 0.1;\n } else if (ray.react !== 0 || ray.bend !== 0) {\n ray.react += -ray.react * 0.06;\n ray.bend += -ray.bend * 0.1;\n }\n\n const react = ray.react;\n const drawAngle = ray.angle + ray.bend;\n const dirx = Math.cos(drawAngle) * SPREAD_X;\n const diry = -Math.sin(drawAngle);\n const effLen = baseLen * (1 + 0.12 * react);\n const ex = originX + dirx * effLen;\n const ey = originY + diry * effLen;\n\n const twinkle = reduced ? 1 : 0.82 + 0.18 * Math.sin(now * 1.3 + ray.phase);\n const aBase = clamp(\n env * introA * ray.bright * twinkle * (1 + 0.9 * react),\n 0,\n 1,\n );\n\n // Brightness builds up *along* the fiber: near-zero through the dense\n // convergence zone at the origin (so overlapping starts don't blow out\n // to white), peaking once the fibers have fanned apart, fading at the tip.\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0));\n grad.addColorStop(0.16, rgba(disp.rayBase, 0.22 * aBase));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.85 * aBase));\n grad.addColorStop(0.75, rgba(disp.rayBase, 0.36 * aBase));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n\n // Soft wide pass → subtle blur/glow.\n ctx.lineWidth = ray.width * (dark ? 3.2 : 2.6);\n ctx.globalAlpha = dark ? 0.22 : 0.18;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Crisp core pass.\n ctx.lineWidth = ray.width;\n ctx.globalAlpha = 1;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // A single glowing dot sits at the very tip — it rides the growing\n // tip but never travels along the fiber.\n if (ray.hasDot) {\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + ray.dotPhase);\n // Dim dots whose tip still sits inside the convergence zone.\n const tipFade = clamp(effLen / (maxRadius * 0.28), 0, 1);\n ctx.fillStyle = rgba(\n disp.dotTip,\n clamp(\n env * introA * tipFade * (dark ? 0.95 : 1) * (0.45 + 0.55 * dtw) *\n (1 + 0.6 * react),\n 0,\n 1,\n ),\n );\n ctx.beginPath();\n ctx.arc(ex, ey, ray.dotR * (1 + 0.4 * react), 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalAlpha = 1;\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render(0);\n return () => ro.disconnect();\n }\n\n const onMove = (e: PointerEvent) => {\n const rect = canvas.getBoundingClientRect();\n const x = e.clientX - rect.left;\n const y = e.clientY - rect.top;\n if (x < 0 || y < 0 || x > rect.width || y > rect.height) {\n pointer.active = false;\n return;\n }\n pointer.x = x;\n pointer.y = y;\n pointer.active = true;\n };\n const onLeave = () => {\n pointer.active = false;\n };\n if (interactive) {\n window.addEventListener(\"pointermove\", onMove, { passive: true });\n window.addEventListener(\"blur\", onLeave);\n }\n\n let last = performance.now();\n const loop = (t: number) => {\n const dt = Math.min(0.05, (t - last) / 1000);\n last = t;\n render(dt);\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n if (interactive) {\n window.removeEventListener(\"pointermove\", onMove);\n window.removeEventListener(\"blur\", onLeave);\n }\n };\n }, [density, intro, reduced, interactive]);\n\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n
    \n setOpen((v) => !v)}\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n \n \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n
    \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition: headline over the interactive burst.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Interactive burst — masked so it fades out below the headline. */}\n \n \n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Headline. */}\n
    \n \n {title}\n \n
    \n \n );\n}\n\nexport default RadialBurstHero;\n", + "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n/** Wrap an angle into (-π, π]. */\nconst wrapAngle = (a: number) => {\n let x = a;\n while (x > Math.PI) x -= 2 * Math.PI;\n while (x < -Math.PI) x += 2 * Math.PI;\n return x;\n};\n/** Distance from point (px,py) to segment (ax,ay)-(bx,by). */\nconst segDist = (\n px: number,\n py: number,\n ax: number,\n ay: number,\n bx: number,\n by: number,\n) => {\n const dx = bx - ax;\n const dy = by - ay;\n const len2 = dx * dx + dy * dy || 1;\n const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1);\n const cx = ax + t * dx;\n const cy = ay + t * dy;\n return Math.hypot(px - cx, py - cy);\n};\n\n/** Horizontal spread multiplier — widens the fan without changing its height. */\nconst SPREAD_X = 1.3;\n\n/** One fiber: a streamline that grows, holds, extends, fades, then respawns. */\ntype Ray = {\n angle: number; // base emission angle (radians)\n maxLen: number; // target length as a fraction of maxRadius\n speed: number; // life units per second (→ lifetime ≈ 1/speed)\n life: number; // < 0 staggered delay, 0..1 active\n width: number; // core stroke width\n bright: number; // base brightness 0..1\n phase: number; // twinkle offset\n react: number; // smoothed pointer reaction 0..1\n bend: number; // smoothed angular bend toward the pointer\n hasDot: boolean; // whether a glowing dot rides this fiber's tip\n dotR: number; // tip-dot radius\n dotPhase: number; // tip-dot twinkle offset\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the interactive canvas background (reusable on its own).\n *\n * Fibers stream out of a bottom-center origin in a wide radial fan. Each\n * one continuously grows, slightly over-extends, fades, and regenerates\n * with fresh angle/length/speed/opacity, while glowing dots travel along\n * it. Rays near the pointer brighten, stretch, and bend toward it, then\n * ease back to their drift. The burst stays in the lower band so it never\n * reaches the headline above.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n /** Disable pointer reactivity. */\n interactive?: boolean;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n interactive = true,\n}: RadialBurstProps) {\n const canvasRef = useRef(null);\n const reduced = useReducedMotion();\n\n // Global intro fade, driven by Motion — read inside the canvas rAF loop.\n const intro = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<(dt: number) => void>(() => {});\n\n // Mount fade-in (Motion).\n useEffect(() => {\n if (reduced) {\n intro.set(1);\n return;\n }\n const controls = animate(intro, 1, {\n duration: 1.6,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [intro, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current(0);\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const pointer = { x: 0, y: 0, active: false };\n\n const respawn = (ray: Ray, initial: boolean) => {\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n const angle = lerp(aMin, aMax, Math.random()) + (Math.random() - 0.5) * 0.05;\n // Mild bias toward vertical, but side rays stay long so the fan fills\n // the full width along the bottom rather than tapering to a dome.\n const vert = Math.sin(clamp(angle, 0, Math.PI));\n ray.angle = angle;\n ray.maxLen = Math.min(\n 1.05,\n (0.8 + 0.2 * vert) * (0.6 + Math.random() * 0.45) +\n (Math.random() < 0.06 ? 0.12 : 0),\n );\n ray.speed = 0.085 + Math.random() * 0.13; // lifetime ≈ 4.5–11.8s\n ray.life = initial ? Math.random() : -Math.random() * 0.6;\n ray.width = 0.55 + Math.random() * 0.5;\n ray.bright = 0.5 + Math.random() * 0.5;\n ray.phase = Math.random() * Math.PI * 2;\n // A single glowing dot sits at the tip — it rides the growing tip, it\n // does not travel along the fiber.\n ray.hasDot = Math.random() < 0.72;\n ray.dotR = 0.8 + Math.random() * 1;\n ray.dotPhase = Math.random() * Math.PI * 2;\n };\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n // Origin sits on the bottom edge so the burst touches the bottom.\n originY = h;\n // Lower-band height — kept well below the headline, ~100px shorter.\n maxRadius = Math.max(h * 0.4, h * 0.6 - 100);\n const count = Math.round(\n Math.min(400, Math.max(220, w / 3.2)) * clamp(density, 0.4, 2),\n );\n rays = Array.from({ length: count }, () => {\n const ray: Ray = {\n angle: 0,\n maxLen: 0,\n speed: 0,\n life: 0,\n width: 1,\n bright: 1,\n phase: 0,\n react: 0,\n bend: 0,\n hasDot: false,\n dotR: 1,\n dotPhase: 0,\n };\n respawn(ray, true);\n return ray;\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current(0);\n };\n\n const render = (dt: number) => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const introA = intro.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n ctx.lineCap = \"round\";\n\n // Central bloom — a soft, diffuse glow rather than a hard bright disc.\n const bloomR =\n Math.min(w * 0.22, maxRadius * 0.6) * (0.7 + 0.3 * introA);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, (dark ? 0.5 : 0.44) * introA));\n bloom.addColorStop(0.3, rgba(disp.core, (dark ? 0.18 : 0.15) * introA));\n bloom.addColorStop(0.65, rgba(disp.core, (dark ? 0.06 : 0.05) * introA));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n const pointerOn = interactive && !reduced && pointer.active;\n const reactR = 170; // px radius of pointer influence\n // No reaction near the origin — only the middle/tip of fibers respond.\n const originGuard = maxRadius * 0.22;\n const pointerNearOrigin =\n Math.hypot(pointer.x - originX, pointer.y - originY) < originGuard;\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n\n if (!reduced) {\n ray.life += ray.speed * dt;\n if (ray.life >= 1) respawn(ray, false);\n }\n if (ray.life < 0) continue;\n\n const life = reduced ? 0.62 : ray.life;\n const growT = clamp(life / 0.7, 0, 1);\n const lenFrac = easeOut(growT);\n const extend = life > 0.7 ? (life - 0.7) / 0.3 : 0;\n const env = reduced\n ? 1\n : Math.min(1, life / 0.12) *\n (life > 0.8 ? clamp(1 - (life - 0.8) / 0.2, 0, 1) : 1);\n if (env <= 0) continue;\n\n const baseLen =\n ray.maxLen * maxRadius * lenFrac * (1 + 0.06 * extend);\n\n // Pointer reaction — engage quickly, return slowly. Hit-test only the\n // outer 35%→tip span so the dense near-origin zone stays calm.\n if (pointerOn && !pointerNearOrigin) {\n const cx = Math.cos(ray.angle) * SPREAD_X;\n const cy = -Math.sin(ray.angle);\n const ix = originX + cx * baseLen * 0.35;\n const iy = originY + cy * baseLen * 0.35;\n const bx = originX + cx * baseLen;\n const by = originY + cy * baseLen;\n const d = segDist(pointer.x, pointer.y, ix, iy, bx, by);\n let reactTarget = 0;\n let bendTarget = 0;\n if (d < reactR) {\n reactTarget = 1 - d / reactR;\n reactTarget *= reactTarget;\n const pAng = Math.atan2(\n -(pointer.y - originY),\n (pointer.x - originX) / SPREAD_X,\n );\n bendTarget = clamp(wrapAngle(pAng - ray.angle), -0.4, 0.4);\n }\n ray.react +=\n (reactTarget - ray.react) * (reactTarget > ray.react ? 0.14 : 0.06);\n ray.bend += (bendTarget * ray.react - ray.bend) * 0.1;\n } else if (ray.react !== 0 || ray.bend !== 0) {\n ray.react += -ray.react * 0.06;\n ray.bend += -ray.bend * 0.1;\n }\n\n const react = ray.react;\n const drawAngle = ray.angle + ray.bend;\n const dirx = Math.cos(drawAngle) * SPREAD_X;\n const diry = -Math.sin(drawAngle);\n const effLen = baseLen * (1 + 0.12 * react);\n const ex = originX + dirx * effLen;\n const ey = originY + diry * effLen;\n\n const twinkle = reduced ? 1 : 0.82 + 0.18 * Math.sin(now * 1.3 + ray.phase);\n const aBase = clamp(\n env * introA * ray.bright * twinkle * (1 + 0.9 * react),\n 0,\n 1,\n );\n\n // Brightness builds up *along* the fiber: near-zero through the dense\n // convergence zone at the origin (so overlapping starts don't blow out\n // to white), peaking once the fibers have fanned apart, fading at the tip.\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0));\n grad.addColorStop(0.16, rgba(disp.rayBase, 0.22 * aBase));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.85 * aBase));\n grad.addColorStop(0.75, rgba(disp.rayBase, 0.36 * aBase));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n\n // Soft wide pass → subtle blur/glow.\n ctx.lineWidth = ray.width * (dark ? 3.2 : 2.6);\n ctx.globalAlpha = dark ? 0.22 : 0.18;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Crisp core pass.\n ctx.lineWidth = ray.width;\n ctx.globalAlpha = 1;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // A single glowing dot sits at the very tip — it rides the growing\n // tip but never travels along the fiber.\n if (ray.hasDot) {\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + ray.dotPhase);\n // Dim dots whose tip still sits inside the convergence zone.\n const tipFade = clamp(effLen / (maxRadius * 0.28), 0, 1);\n ctx.fillStyle = rgba(\n disp.dotTip,\n clamp(\n env * introA * tipFade * (dark ? 0.95 : 1) * (0.45 + 0.55 * dtw) *\n (1 + 0.6 * react),\n 0,\n 1,\n ),\n );\n ctx.beginPath();\n ctx.arc(ex, ey, ray.dotR * (1 + 0.4 * react), 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalAlpha = 1;\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render(0);\n return () => ro.disconnect();\n }\n\n const onMove = (e: PointerEvent) => {\n const rect = canvas.getBoundingClientRect();\n const x = e.clientX - rect.left;\n const y = e.clientY - rect.top;\n if (x < 0 || y < 0 || x > rect.width || y > rect.height) {\n pointer.active = false;\n return;\n }\n pointer.x = x;\n pointer.y = y;\n pointer.active = true;\n };\n const onLeave = () => {\n pointer.active = false;\n };\n if (interactive) {\n window.addEventListener(\"pointermove\", onMove, { passive: true });\n window.addEventListener(\"blur\", onLeave);\n }\n\n let last = performance.now();\n const loop = (t: number) => {\n const dt = Math.min(0.05, (t - last) / 1000);\n last = t;\n render(dt);\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n if (interactive) {\n window.removeEventListener(\"pointermove\", onMove);\n window.removeEventListener(\"blur\", onLeave);\n }\n };\n }, [density, intro, reduced, interactive]);\n\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n
    \n setOpen((v) => !v)}\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n \n \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n
    \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition: headline over the interactive burst.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Interactive burst — masked so it fades out below the headline. */}\n \n \n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Headline. */}\n
    \n \n {title}\n \n
    \n \n );\n}\n\nexport default RadialBurstHero;\n", "type": "registry:ui", - "integrity": "sha384-aXAa9+odE7/IaRbmsz2WioIdDJ2fZmNfX3oFBbMP64zo1eOGUiDtXumKLQ2ONpWK" + "integrity": "sha384-EKZ353lD8QVVT65OWGiGxklNlWw6yX/HOjFRAPpdAu3c+rv7mT8jDTqhQUf8n43G" }, { "path": "utils/cn.ts", @@ -4125,9 +4125,16 @@ } ], "meta": { - "version": "2.0.0" + "version": "2.0.1" }, "changelog": [ + { + "version": "2.0.1", + "date": "2026-06-05", + "changes": [ + "Theme switcher now uses a plain button-menu pattern (disclosure button + `aria-current` on the active theme) instead of `role=\"listbox\"`/`role=\"option\"`, which had promised listbox keyboard semantics the widget did not implement." + ] + }, { "version": "2.0.0", "date": "2026-06-03", diff --git a/apps/www/public/registry/changelogs.json b/apps/www/public/registry/changelogs.json index c463074f..1e46cf18 100644 --- a/apps/www/public/registry/changelogs.json +++ b/apps/www/public/registry/changelogs.json @@ -671,6 +671,13 @@ } ], "hero-radial-burst": [ + { + "version": "2.0.1", + "date": "2026-06-05", + "changes": [ + "Theme switcher now uses a plain button-menu pattern (disclosure button + `aria-current` on the active theme) instead of `role=\"listbox\"`/`role=\"option\"`, which had promised listbox keyboard semantics the widget did not implement." + ] + }, { "version": "2.0.0", "date": "2026-06-03", diff --git a/apps/www/public/registry/hero-radial-burst.json b/apps/www/public/registry/hero-radial-burst.json index a53ab78a..741e1f77 100644 --- a/apps/www/public/registry/hero-radial-burst.json +++ b/apps/www/public/registry/hero-radial-burst.json @@ -9,9 +9,9 @@ "files": [ { "path": "blocks/hero/radial-burst/component.tsx", - "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n/** Wrap an angle into (-π, π]. */\nconst wrapAngle = (a: number) => {\n let x = a;\n while (x > Math.PI) x -= 2 * Math.PI;\n while (x < -Math.PI) x += 2 * Math.PI;\n return x;\n};\n/** Distance from point (px,py) to segment (ax,ay)-(bx,by). */\nconst segDist = (\n px: number,\n py: number,\n ax: number,\n ay: number,\n bx: number,\n by: number,\n) => {\n const dx = bx - ax;\n const dy = by - ay;\n const len2 = dx * dx + dy * dy || 1;\n const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1);\n const cx = ax + t * dx;\n const cy = ay + t * dy;\n return Math.hypot(px - cx, py - cy);\n};\n\n/** Horizontal spread multiplier — widens the fan without changing its height. */\nconst SPREAD_X = 1.3;\n\n/** One fiber: a streamline that grows, holds, extends, fades, then respawns. */\ntype Ray = {\n angle: number; // base emission angle (radians)\n maxLen: number; // target length as a fraction of maxRadius\n speed: number; // life units per second (→ lifetime ≈ 1/speed)\n life: number; // < 0 staggered delay, 0..1 active\n width: number; // core stroke width\n bright: number; // base brightness 0..1\n phase: number; // twinkle offset\n react: number; // smoothed pointer reaction 0..1\n bend: number; // smoothed angular bend toward the pointer\n hasDot: boolean; // whether a glowing dot rides this fiber's tip\n dotR: number; // tip-dot radius\n dotPhase: number; // tip-dot twinkle offset\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the interactive canvas background (reusable on its own).\n *\n * Fibers stream out of a bottom-center origin in a wide radial fan. Each\n * one continuously grows, slightly over-extends, fades, and regenerates\n * with fresh angle/length/speed/opacity, while glowing dots travel along\n * it. Rays near the pointer brighten, stretch, and bend toward it, then\n * ease back to their drift. The burst stays in the lower band so it never\n * reaches the headline above.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n /** Disable pointer reactivity. */\n interactive?: boolean;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n interactive = true,\n}: RadialBurstProps) {\n const canvasRef = useRef(null);\n const reduced = useReducedMotion();\n\n // Global intro fade, driven by Motion — read inside the canvas rAF loop.\n const intro = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<(dt: number) => void>(() => {});\n\n // Mount fade-in (Motion).\n useEffect(() => {\n if (reduced) {\n intro.set(1);\n return;\n }\n const controls = animate(intro, 1, {\n duration: 1.6,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [intro, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current(0);\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const pointer = { x: 0, y: 0, active: false };\n\n const respawn = (ray: Ray, initial: boolean) => {\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n const angle = lerp(aMin, aMax, Math.random()) + (Math.random() - 0.5) * 0.05;\n // Mild bias toward vertical, but side rays stay long so the fan fills\n // the full width along the bottom rather than tapering to a dome.\n const vert = Math.sin(clamp(angle, 0, Math.PI));\n ray.angle = angle;\n ray.maxLen = Math.min(\n 1.05,\n (0.8 + 0.2 * vert) * (0.6 + Math.random() * 0.45) +\n (Math.random() < 0.06 ? 0.12 : 0),\n );\n ray.speed = 0.085 + Math.random() * 0.13; // lifetime ≈ 4.5–11.8s\n ray.life = initial ? Math.random() : -Math.random() * 0.6;\n ray.width = 0.55 + Math.random() * 0.5;\n ray.bright = 0.5 + Math.random() * 0.5;\n ray.phase = Math.random() * Math.PI * 2;\n // A single glowing dot sits at the tip — it rides the growing tip, it\n // does not travel along the fiber.\n ray.hasDot = Math.random() < 0.72;\n ray.dotR = 0.8 + Math.random() * 1;\n ray.dotPhase = Math.random() * Math.PI * 2;\n };\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n // Origin sits on the bottom edge so the burst touches the bottom.\n originY = h;\n // Lower-band height — kept well below the headline, ~100px shorter.\n maxRadius = Math.max(h * 0.4, h * 0.6 - 100);\n const count = Math.round(\n Math.min(400, Math.max(220, w / 3.2)) * clamp(density, 0.4, 2),\n );\n rays = Array.from({ length: count }, () => {\n const ray: Ray = {\n angle: 0,\n maxLen: 0,\n speed: 0,\n life: 0,\n width: 1,\n bright: 1,\n phase: 0,\n react: 0,\n bend: 0,\n hasDot: false,\n dotR: 1,\n dotPhase: 0,\n };\n respawn(ray, true);\n return ray;\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current(0);\n };\n\n const render = (dt: number) => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const introA = intro.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n ctx.lineCap = \"round\";\n\n // Central bloom — a soft, diffuse glow rather than a hard bright disc.\n const bloomR =\n Math.min(w * 0.22, maxRadius * 0.6) * (0.7 + 0.3 * introA);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, (dark ? 0.5 : 0.44) * introA));\n bloom.addColorStop(0.3, rgba(disp.core, (dark ? 0.18 : 0.15) * introA));\n bloom.addColorStop(0.65, rgba(disp.core, (dark ? 0.06 : 0.05) * introA));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n const pointerOn = interactive && !reduced && pointer.active;\n const reactR = 170; // px radius of pointer influence\n // No reaction near the origin — only the middle/tip of fibers respond.\n const originGuard = maxRadius * 0.22;\n const pointerNearOrigin =\n Math.hypot(pointer.x - originX, pointer.y - originY) < originGuard;\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n\n if (!reduced) {\n ray.life += ray.speed * dt;\n if (ray.life >= 1) respawn(ray, false);\n }\n if (ray.life < 0) continue;\n\n const life = reduced ? 0.62 : ray.life;\n const growT = clamp(life / 0.7, 0, 1);\n const lenFrac = easeOut(growT);\n const extend = life > 0.7 ? (life - 0.7) / 0.3 : 0;\n const env = reduced\n ? 1\n : Math.min(1, life / 0.12) *\n (life > 0.8 ? clamp(1 - (life - 0.8) / 0.2, 0, 1) : 1);\n if (env <= 0) continue;\n\n const baseLen =\n ray.maxLen * maxRadius * lenFrac * (1 + 0.06 * extend);\n\n // Pointer reaction — engage quickly, return slowly. Hit-test only the\n // outer 35%→tip span so the dense near-origin zone stays calm.\n if (pointerOn && !pointerNearOrigin) {\n const cx = Math.cos(ray.angle) * SPREAD_X;\n const cy = -Math.sin(ray.angle);\n const ix = originX + cx * baseLen * 0.35;\n const iy = originY + cy * baseLen * 0.35;\n const bx = originX + cx * baseLen;\n const by = originY + cy * baseLen;\n const d = segDist(pointer.x, pointer.y, ix, iy, bx, by);\n let reactTarget = 0;\n let bendTarget = 0;\n if (d < reactR) {\n reactTarget = 1 - d / reactR;\n reactTarget *= reactTarget;\n const pAng = Math.atan2(\n -(pointer.y - originY),\n (pointer.x - originX) / SPREAD_X,\n );\n bendTarget = clamp(wrapAngle(pAng - ray.angle), -0.4, 0.4);\n }\n ray.react +=\n (reactTarget - ray.react) * (reactTarget > ray.react ? 0.14 : 0.06);\n ray.bend += (bendTarget * ray.react - ray.bend) * 0.1;\n } else if (ray.react !== 0 || ray.bend !== 0) {\n ray.react += -ray.react * 0.06;\n ray.bend += -ray.bend * 0.1;\n }\n\n const react = ray.react;\n const drawAngle = ray.angle + ray.bend;\n const dirx = Math.cos(drawAngle) * SPREAD_X;\n const diry = -Math.sin(drawAngle);\n const effLen = baseLen * (1 + 0.12 * react);\n const ex = originX + dirx * effLen;\n const ey = originY + diry * effLen;\n\n const twinkle = reduced ? 1 : 0.82 + 0.18 * Math.sin(now * 1.3 + ray.phase);\n const aBase = clamp(\n env * introA * ray.bright * twinkle * (1 + 0.9 * react),\n 0,\n 1,\n );\n\n // Brightness builds up *along* the fiber: near-zero through the dense\n // convergence zone at the origin (so overlapping starts don't blow out\n // to white), peaking once the fibers have fanned apart, fading at the tip.\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0));\n grad.addColorStop(0.16, rgba(disp.rayBase, 0.22 * aBase));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.85 * aBase));\n grad.addColorStop(0.75, rgba(disp.rayBase, 0.36 * aBase));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n\n // Soft wide pass → subtle blur/glow.\n ctx.lineWidth = ray.width * (dark ? 3.2 : 2.6);\n ctx.globalAlpha = dark ? 0.22 : 0.18;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Crisp core pass.\n ctx.lineWidth = ray.width;\n ctx.globalAlpha = 1;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // A single glowing dot sits at the very tip — it rides the growing\n // tip but never travels along the fiber.\n if (ray.hasDot) {\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + ray.dotPhase);\n // Dim dots whose tip still sits inside the convergence zone.\n const tipFade = clamp(effLen / (maxRadius * 0.28), 0, 1);\n ctx.fillStyle = rgba(\n disp.dotTip,\n clamp(\n env * introA * tipFade * (dark ? 0.95 : 1) * (0.45 + 0.55 * dtw) *\n (1 + 0.6 * react),\n 0,\n 1,\n ),\n );\n ctx.beginPath();\n ctx.arc(ex, ey, ray.dotR * (1 + 0.4 * react), 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalAlpha = 1;\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render(0);\n return () => ro.disconnect();\n }\n\n const onMove = (e: PointerEvent) => {\n const rect = canvas.getBoundingClientRect();\n const x = e.clientX - rect.left;\n const y = e.clientY - rect.top;\n if (x < 0 || y < 0 || x > rect.width || y > rect.height) {\n pointer.active = false;\n return;\n }\n pointer.x = x;\n pointer.y = y;\n pointer.active = true;\n };\n const onLeave = () => {\n pointer.active = false;\n };\n if (interactive) {\n window.addEventListener(\"pointermove\", onMove, { passive: true });\n window.addEventListener(\"blur\", onLeave);\n }\n\n let last = performance.now();\n const loop = (t: number) => {\n const dt = Math.min(0.05, (t - last) / 1000);\n last = t;\n render(dt);\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n if (interactive) {\n window.removeEventListener(\"pointermove\", onMove);\n window.removeEventListener(\"blur\", onLeave);\n }\n };\n }, [density, intro, reduced, interactive]);\n\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n
    \n setOpen((v) => !v)}\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n \n \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n
    \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition: headline over the interactive burst.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Interactive burst — masked so it fades out below the headline. */}\n \n \n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Headline. */}\n
    \n \n {title}\n \n
    \n \n );\n}\n\nexport default RadialBurstHero;\n", + "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n/** Wrap an angle into (-π, π]. */\nconst wrapAngle = (a: number) => {\n let x = a;\n while (x > Math.PI) x -= 2 * Math.PI;\n while (x < -Math.PI) x += 2 * Math.PI;\n return x;\n};\n/** Distance from point (px,py) to segment (ax,ay)-(bx,by). */\nconst segDist = (\n px: number,\n py: number,\n ax: number,\n ay: number,\n bx: number,\n by: number,\n) => {\n const dx = bx - ax;\n const dy = by - ay;\n const len2 = dx * dx + dy * dy || 1;\n const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1);\n const cx = ax + t * dx;\n const cy = ay + t * dy;\n return Math.hypot(px - cx, py - cy);\n};\n\n/** Horizontal spread multiplier — widens the fan without changing its height. */\nconst SPREAD_X = 1.3;\n\n/** One fiber: a streamline that grows, holds, extends, fades, then respawns. */\ntype Ray = {\n angle: number; // base emission angle (radians)\n maxLen: number; // target length as a fraction of maxRadius\n speed: number; // life units per second (→ lifetime ≈ 1/speed)\n life: number; // < 0 staggered delay, 0..1 active\n width: number; // core stroke width\n bright: number; // base brightness 0..1\n phase: number; // twinkle offset\n react: number; // smoothed pointer reaction 0..1\n bend: number; // smoothed angular bend toward the pointer\n hasDot: boolean; // whether a glowing dot rides this fiber's tip\n dotR: number; // tip-dot radius\n dotPhase: number; // tip-dot twinkle offset\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the interactive canvas background (reusable on its own).\n *\n * Fibers stream out of a bottom-center origin in a wide radial fan. Each\n * one continuously grows, slightly over-extends, fades, and regenerates\n * with fresh angle/length/speed/opacity, while glowing dots travel along\n * it. Rays near the pointer brighten, stretch, and bend toward it, then\n * ease back to their drift. The burst stays in the lower band so it never\n * reaches the headline above.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n /** Disable pointer reactivity. */\n interactive?: boolean;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n interactive = true,\n}: RadialBurstProps) {\n const canvasRef = useRef(null);\n const reduced = useReducedMotion();\n\n // Global intro fade, driven by Motion — read inside the canvas rAF loop.\n const intro = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<(dt: number) => void>(() => {});\n\n // Mount fade-in (Motion).\n useEffect(() => {\n if (reduced) {\n intro.set(1);\n return;\n }\n const controls = animate(intro, 1, {\n duration: 1.6,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [intro, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current(0);\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const pointer = { x: 0, y: 0, active: false };\n\n const respawn = (ray: Ray, initial: boolean) => {\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n const angle = lerp(aMin, aMax, Math.random()) + (Math.random() - 0.5) * 0.05;\n // Mild bias toward vertical, but side rays stay long so the fan fills\n // the full width along the bottom rather than tapering to a dome.\n const vert = Math.sin(clamp(angle, 0, Math.PI));\n ray.angle = angle;\n ray.maxLen = Math.min(\n 1.05,\n (0.8 + 0.2 * vert) * (0.6 + Math.random() * 0.45) +\n (Math.random() < 0.06 ? 0.12 : 0),\n );\n ray.speed = 0.085 + Math.random() * 0.13; // lifetime ≈ 4.5–11.8s\n ray.life = initial ? Math.random() : -Math.random() * 0.6;\n ray.width = 0.55 + Math.random() * 0.5;\n ray.bright = 0.5 + Math.random() * 0.5;\n ray.phase = Math.random() * Math.PI * 2;\n // A single glowing dot sits at the tip — it rides the growing tip, it\n // does not travel along the fiber.\n ray.hasDot = Math.random() < 0.72;\n ray.dotR = 0.8 + Math.random() * 1;\n ray.dotPhase = Math.random() * Math.PI * 2;\n };\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n // Origin sits on the bottom edge so the burst touches the bottom.\n originY = h;\n // Lower-band height — kept well below the headline, ~100px shorter.\n maxRadius = Math.max(h * 0.4, h * 0.6 - 100);\n const count = Math.round(\n Math.min(400, Math.max(220, w / 3.2)) * clamp(density, 0.4, 2),\n );\n rays = Array.from({ length: count }, () => {\n const ray: Ray = {\n angle: 0,\n maxLen: 0,\n speed: 0,\n life: 0,\n width: 1,\n bright: 1,\n phase: 0,\n react: 0,\n bend: 0,\n hasDot: false,\n dotR: 1,\n dotPhase: 0,\n };\n respawn(ray, true);\n return ray;\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current(0);\n };\n\n const render = (dt: number) => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const introA = intro.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n ctx.lineCap = \"round\";\n\n // Central bloom — a soft, diffuse glow rather than a hard bright disc.\n const bloomR =\n Math.min(w * 0.22, maxRadius * 0.6) * (0.7 + 0.3 * introA);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, (dark ? 0.5 : 0.44) * introA));\n bloom.addColorStop(0.3, rgba(disp.core, (dark ? 0.18 : 0.15) * introA));\n bloom.addColorStop(0.65, rgba(disp.core, (dark ? 0.06 : 0.05) * introA));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n const pointerOn = interactive && !reduced && pointer.active;\n const reactR = 170; // px radius of pointer influence\n // No reaction near the origin — only the middle/tip of fibers respond.\n const originGuard = maxRadius * 0.22;\n const pointerNearOrigin =\n Math.hypot(pointer.x - originX, pointer.y - originY) < originGuard;\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n\n if (!reduced) {\n ray.life += ray.speed * dt;\n if (ray.life >= 1) respawn(ray, false);\n }\n if (ray.life < 0) continue;\n\n const life = reduced ? 0.62 : ray.life;\n const growT = clamp(life / 0.7, 0, 1);\n const lenFrac = easeOut(growT);\n const extend = life > 0.7 ? (life - 0.7) / 0.3 : 0;\n const env = reduced\n ? 1\n : Math.min(1, life / 0.12) *\n (life > 0.8 ? clamp(1 - (life - 0.8) / 0.2, 0, 1) : 1);\n if (env <= 0) continue;\n\n const baseLen =\n ray.maxLen * maxRadius * lenFrac * (1 + 0.06 * extend);\n\n // Pointer reaction — engage quickly, return slowly. Hit-test only the\n // outer 35%→tip span so the dense near-origin zone stays calm.\n if (pointerOn && !pointerNearOrigin) {\n const cx = Math.cos(ray.angle) * SPREAD_X;\n const cy = -Math.sin(ray.angle);\n const ix = originX + cx * baseLen * 0.35;\n const iy = originY + cy * baseLen * 0.35;\n const bx = originX + cx * baseLen;\n const by = originY + cy * baseLen;\n const d = segDist(pointer.x, pointer.y, ix, iy, bx, by);\n let reactTarget = 0;\n let bendTarget = 0;\n if (d < reactR) {\n reactTarget = 1 - d / reactR;\n reactTarget *= reactTarget;\n const pAng = Math.atan2(\n -(pointer.y - originY),\n (pointer.x - originX) / SPREAD_X,\n );\n bendTarget = clamp(wrapAngle(pAng - ray.angle), -0.4, 0.4);\n }\n ray.react +=\n (reactTarget - ray.react) * (reactTarget > ray.react ? 0.14 : 0.06);\n ray.bend += (bendTarget * ray.react - ray.bend) * 0.1;\n } else if (ray.react !== 0 || ray.bend !== 0) {\n ray.react += -ray.react * 0.06;\n ray.bend += -ray.bend * 0.1;\n }\n\n const react = ray.react;\n const drawAngle = ray.angle + ray.bend;\n const dirx = Math.cos(drawAngle) * SPREAD_X;\n const diry = -Math.sin(drawAngle);\n const effLen = baseLen * (1 + 0.12 * react);\n const ex = originX + dirx * effLen;\n const ey = originY + diry * effLen;\n\n const twinkle = reduced ? 1 : 0.82 + 0.18 * Math.sin(now * 1.3 + ray.phase);\n const aBase = clamp(\n env * introA * ray.bright * twinkle * (1 + 0.9 * react),\n 0,\n 1,\n );\n\n // Brightness builds up *along* the fiber: near-zero through the dense\n // convergence zone at the origin (so overlapping starts don't blow out\n // to white), peaking once the fibers have fanned apart, fading at the tip.\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0));\n grad.addColorStop(0.16, rgba(disp.rayBase, 0.22 * aBase));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.85 * aBase));\n grad.addColorStop(0.75, rgba(disp.rayBase, 0.36 * aBase));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n\n // Soft wide pass → subtle blur/glow.\n ctx.lineWidth = ray.width * (dark ? 3.2 : 2.6);\n ctx.globalAlpha = dark ? 0.22 : 0.18;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Crisp core pass.\n ctx.lineWidth = ray.width;\n ctx.globalAlpha = 1;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // A single glowing dot sits at the very tip — it rides the growing\n // tip but never travels along the fiber.\n if (ray.hasDot) {\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + ray.dotPhase);\n // Dim dots whose tip still sits inside the convergence zone.\n const tipFade = clamp(effLen / (maxRadius * 0.28), 0, 1);\n ctx.fillStyle = rgba(\n disp.dotTip,\n clamp(\n env * introA * tipFade * (dark ? 0.95 : 1) * (0.45 + 0.55 * dtw) *\n (1 + 0.6 * react),\n 0,\n 1,\n ),\n );\n ctx.beginPath();\n ctx.arc(ex, ey, ray.dotR * (1 + 0.4 * react), 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalAlpha = 1;\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render(0);\n return () => ro.disconnect();\n }\n\n const onMove = (e: PointerEvent) => {\n const rect = canvas.getBoundingClientRect();\n const x = e.clientX - rect.left;\n const y = e.clientY - rect.top;\n if (x < 0 || y < 0 || x > rect.width || y > rect.height) {\n pointer.active = false;\n return;\n }\n pointer.x = x;\n pointer.y = y;\n pointer.active = true;\n };\n const onLeave = () => {\n pointer.active = false;\n };\n if (interactive) {\n window.addEventListener(\"pointermove\", onMove, { passive: true });\n window.addEventListener(\"blur\", onLeave);\n }\n\n let last = performance.now();\n const loop = (t: number) => {\n const dt = Math.min(0.05, (t - last) / 1000);\n last = t;\n render(dt);\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n if (interactive) {\n window.removeEventListener(\"pointermove\", onMove);\n window.removeEventListener(\"blur\", onLeave);\n }\n };\n }, [density, intro, reduced, interactive]);\n\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n
    \n setOpen((v) => !v)}\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n \n \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n
    \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition: headline over the interactive burst.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Interactive burst — masked so it fades out below the headline. */}\n \n \n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Headline. */}\n
    \n \n {title}\n \n
    \n \n );\n}\n\nexport default RadialBurstHero;\n", "type": "registry:ui", - "integrity": "sha384-aXAa9+odE7/IaRbmsz2WioIdDJ2fZmNfX3oFBbMP64zo1eOGUiDtXumKLQ2ONpWK" + "integrity": "sha384-EKZ353lD8QVVT65OWGiGxklNlWw6yX/HOjFRAPpdAu3c+rv7mT8jDTqhQUf8n43G" }, { "path": "utils/cn.ts", @@ -21,9 +21,16 @@ } ], "meta": { - "version": "2.0.0" + "version": "2.0.1" }, "changelog": [ + { + "version": "2.0.1", + "date": "2026-06-05", + "changes": [ + "Theme switcher now uses a plain button-menu pattern (disclosure button + `aria-current` on the active theme) instead of `role=\"listbox\"`/`role=\"option\"`, which had promised listbox keyboard semantics the widget did not implement." + ] + }, { "version": "2.0.0", "date": "2026-06-03", diff --git a/registry.json b/registry.json index 2e01c2a8..f001c75b 100644 --- a/registry.json +++ b/registry.json @@ -4113,9 +4113,9 @@ "files": [ { "path": "blocks/hero/radial-burst/component.tsx", - "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n/** Wrap an angle into (-π, π]. */\nconst wrapAngle = (a: number) => {\n let x = a;\n while (x > Math.PI) x -= 2 * Math.PI;\n while (x < -Math.PI) x += 2 * Math.PI;\n return x;\n};\n/** Distance from point (px,py) to segment (ax,ay)-(bx,by). */\nconst segDist = (\n px: number,\n py: number,\n ax: number,\n ay: number,\n bx: number,\n by: number,\n) => {\n const dx = bx - ax;\n const dy = by - ay;\n const len2 = dx * dx + dy * dy || 1;\n const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1);\n const cx = ax + t * dx;\n const cy = ay + t * dy;\n return Math.hypot(px - cx, py - cy);\n};\n\n/** Horizontal spread multiplier — widens the fan without changing its height. */\nconst SPREAD_X = 1.3;\n\n/** One fiber: a streamline that grows, holds, extends, fades, then respawns. */\ntype Ray = {\n angle: number; // base emission angle (radians)\n maxLen: number; // target length as a fraction of maxRadius\n speed: number; // life units per second (→ lifetime ≈ 1/speed)\n life: number; // < 0 staggered delay, 0..1 active\n width: number; // core stroke width\n bright: number; // base brightness 0..1\n phase: number; // twinkle offset\n react: number; // smoothed pointer reaction 0..1\n bend: number; // smoothed angular bend toward the pointer\n hasDot: boolean; // whether a glowing dot rides this fiber's tip\n dotR: number; // tip-dot radius\n dotPhase: number; // tip-dot twinkle offset\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the interactive canvas background (reusable on its own).\n *\n * Fibers stream out of a bottom-center origin in a wide radial fan. Each\n * one continuously grows, slightly over-extends, fades, and regenerates\n * with fresh angle/length/speed/opacity, while glowing dots travel along\n * it. Rays near the pointer brighten, stretch, and bend toward it, then\n * ease back to their drift. The burst stays in the lower band so it never\n * reaches the headline above.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n /** Disable pointer reactivity. */\n interactive?: boolean;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n interactive = true,\n}: RadialBurstProps) {\n const canvasRef = useRef(null);\n const reduced = useReducedMotion();\n\n // Global intro fade, driven by Motion — read inside the canvas rAF loop.\n const intro = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<(dt: number) => void>(() => {});\n\n // Mount fade-in (Motion).\n useEffect(() => {\n if (reduced) {\n intro.set(1);\n return;\n }\n const controls = animate(intro, 1, {\n duration: 1.6,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [intro, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current(0);\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const pointer = { x: 0, y: 0, active: false };\n\n const respawn = (ray: Ray, initial: boolean) => {\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n const angle = lerp(aMin, aMax, Math.random()) + (Math.random() - 0.5) * 0.05;\n // Mild bias toward vertical, but side rays stay long so the fan fills\n // the full width along the bottom rather than tapering to a dome.\n const vert = Math.sin(clamp(angle, 0, Math.PI));\n ray.angle = angle;\n ray.maxLen = Math.min(\n 1.05,\n (0.8 + 0.2 * vert) * (0.6 + Math.random() * 0.45) +\n (Math.random() < 0.06 ? 0.12 : 0),\n );\n ray.speed = 0.085 + Math.random() * 0.13; // lifetime ≈ 4.5–11.8s\n ray.life = initial ? Math.random() : -Math.random() * 0.6;\n ray.width = 0.55 + Math.random() * 0.5;\n ray.bright = 0.5 + Math.random() * 0.5;\n ray.phase = Math.random() * Math.PI * 2;\n // A single glowing dot sits at the tip — it rides the growing tip, it\n // does not travel along the fiber.\n ray.hasDot = Math.random() < 0.72;\n ray.dotR = 0.8 + Math.random() * 1;\n ray.dotPhase = Math.random() * Math.PI * 2;\n };\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n // Origin sits on the bottom edge so the burst touches the bottom.\n originY = h;\n // Lower-band height — kept well below the headline, ~100px shorter.\n maxRadius = Math.max(h * 0.4, h * 0.6 - 100);\n const count = Math.round(\n Math.min(400, Math.max(220, w / 3.2)) * clamp(density, 0.4, 2),\n );\n rays = Array.from({ length: count }, () => {\n const ray: Ray = {\n angle: 0,\n maxLen: 0,\n speed: 0,\n life: 0,\n width: 1,\n bright: 1,\n phase: 0,\n react: 0,\n bend: 0,\n hasDot: false,\n dotR: 1,\n dotPhase: 0,\n };\n respawn(ray, true);\n return ray;\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current(0);\n };\n\n const render = (dt: number) => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const introA = intro.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n ctx.lineCap = \"round\";\n\n // Central bloom — a soft, diffuse glow rather than a hard bright disc.\n const bloomR =\n Math.min(w * 0.22, maxRadius * 0.6) * (0.7 + 0.3 * introA);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, (dark ? 0.5 : 0.44) * introA));\n bloom.addColorStop(0.3, rgba(disp.core, (dark ? 0.18 : 0.15) * introA));\n bloom.addColorStop(0.65, rgba(disp.core, (dark ? 0.06 : 0.05) * introA));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n const pointerOn = interactive && !reduced && pointer.active;\n const reactR = 170; // px radius of pointer influence\n // No reaction near the origin — only the middle/tip of fibers respond.\n const originGuard = maxRadius * 0.22;\n const pointerNearOrigin =\n Math.hypot(pointer.x - originX, pointer.y - originY) < originGuard;\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n\n if (!reduced) {\n ray.life += ray.speed * dt;\n if (ray.life >= 1) respawn(ray, false);\n }\n if (ray.life < 0) continue;\n\n const life = reduced ? 0.62 : ray.life;\n const growT = clamp(life / 0.7, 0, 1);\n const lenFrac = easeOut(growT);\n const extend = life > 0.7 ? (life - 0.7) / 0.3 : 0;\n const env = reduced\n ? 1\n : Math.min(1, life / 0.12) *\n (life > 0.8 ? clamp(1 - (life - 0.8) / 0.2, 0, 1) : 1);\n if (env <= 0) continue;\n\n const baseLen =\n ray.maxLen * maxRadius * lenFrac * (1 + 0.06 * extend);\n\n // Pointer reaction — engage quickly, return slowly. Hit-test only the\n // outer 35%→tip span so the dense near-origin zone stays calm.\n if (pointerOn && !pointerNearOrigin) {\n const cx = Math.cos(ray.angle) * SPREAD_X;\n const cy = -Math.sin(ray.angle);\n const ix = originX + cx * baseLen * 0.35;\n const iy = originY + cy * baseLen * 0.35;\n const bx = originX + cx * baseLen;\n const by = originY + cy * baseLen;\n const d = segDist(pointer.x, pointer.y, ix, iy, bx, by);\n let reactTarget = 0;\n let bendTarget = 0;\n if (d < reactR) {\n reactTarget = 1 - d / reactR;\n reactTarget *= reactTarget;\n const pAng = Math.atan2(\n -(pointer.y - originY),\n (pointer.x - originX) / SPREAD_X,\n );\n bendTarget = clamp(wrapAngle(pAng - ray.angle), -0.4, 0.4);\n }\n ray.react +=\n (reactTarget - ray.react) * (reactTarget > ray.react ? 0.14 : 0.06);\n ray.bend += (bendTarget * ray.react - ray.bend) * 0.1;\n } else if (ray.react !== 0 || ray.bend !== 0) {\n ray.react += -ray.react * 0.06;\n ray.bend += -ray.bend * 0.1;\n }\n\n const react = ray.react;\n const drawAngle = ray.angle + ray.bend;\n const dirx = Math.cos(drawAngle) * SPREAD_X;\n const diry = -Math.sin(drawAngle);\n const effLen = baseLen * (1 + 0.12 * react);\n const ex = originX + dirx * effLen;\n const ey = originY + diry * effLen;\n\n const twinkle = reduced ? 1 : 0.82 + 0.18 * Math.sin(now * 1.3 + ray.phase);\n const aBase = clamp(\n env * introA * ray.bright * twinkle * (1 + 0.9 * react),\n 0,\n 1,\n );\n\n // Brightness builds up *along* the fiber: near-zero through the dense\n // convergence zone at the origin (so overlapping starts don't blow out\n // to white), peaking once the fibers have fanned apart, fading at the tip.\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0));\n grad.addColorStop(0.16, rgba(disp.rayBase, 0.22 * aBase));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.85 * aBase));\n grad.addColorStop(0.75, rgba(disp.rayBase, 0.36 * aBase));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n\n // Soft wide pass → subtle blur/glow.\n ctx.lineWidth = ray.width * (dark ? 3.2 : 2.6);\n ctx.globalAlpha = dark ? 0.22 : 0.18;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Crisp core pass.\n ctx.lineWidth = ray.width;\n ctx.globalAlpha = 1;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // A single glowing dot sits at the very tip — it rides the growing\n // tip but never travels along the fiber.\n if (ray.hasDot) {\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + ray.dotPhase);\n // Dim dots whose tip still sits inside the convergence zone.\n const tipFade = clamp(effLen / (maxRadius * 0.28), 0, 1);\n ctx.fillStyle = rgba(\n disp.dotTip,\n clamp(\n env * introA * tipFade * (dark ? 0.95 : 1) * (0.45 + 0.55 * dtw) *\n (1 + 0.6 * react),\n 0,\n 1,\n ),\n );\n ctx.beginPath();\n ctx.arc(ex, ey, ray.dotR * (1 + 0.4 * react), 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalAlpha = 1;\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render(0);\n return () => ro.disconnect();\n }\n\n const onMove = (e: PointerEvent) => {\n const rect = canvas.getBoundingClientRect();\n const x = e.clientX - rect.left;\n const y = e.clientY - rect.top;\n if (x < 0 || y < 0 || x > rect.width || y > rect.height) {\n pointer.active = false;\n return;\n }\n pointer.x = x;\n pointer.y = y;\n pointer.active = true;\n };\n const onLeave = () => {\n pointer.active = false;\n };\n if (interactive) {\n window.addEventListener(\"pointermove\", onMove, { passive: true });\n window.addEventListener(\"blur\", onLeave);\n }\n\n let last = performance.now();\n const loop = (t: number) => {\n const dt = Math.min(0.05, (t - last) / 1000);\n last = t;\n render(dt);\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n if (interactive) {\n window.removeEventListener(\"pointermove\", onMove);\n window.removeEventListener(\"blur\", onLeave);\n }\n };\n }, [density, intro, reduced, interactive]);\n\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n
    \n setOpen((v) => !v)}\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n \n \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n
    \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition: headline over the interactive burst.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Interactive burst — masked so it fades out below the headline. */}\n \n \n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Headline. */}\n
    \n \n {title}\n \n
    \n \n );\n}\n\nexport default RadialBurstHero;\n", + "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n/** Wrap an angle into (-π, π]. */\nconst wrapAngle = (a: number) => {\n let x = a;\n while (x > Math.PI) x -= 2 * Math.PI;\n while (x < -Math.PI) x += 2 * Math.PI;\n return x;\n};\n/** Distance from point (px,py) to segment (ax,ay)-(bx,by). */\nconst segDist = (\n px: number,\n py: number,\n ax: number,\n ay: number,\n bx: number,\n by: number,\n) => {\n const dx = bx - ax;\n const dy = by - ay;\n const len2 = dx * dx + dy * dy || 1;\n const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1);\n const cx = ax + t * dx;\n const cy = ay + t * dy;\n return Math.hypot(px - cx, py - cy);\n};\n\n/** Horizontal spread multiplier — widens the fan without changing its height. */\nconst SPREAD_X = 1.3;\n\n/** One fiber: a streamline that grows, holds, extends, fades, then respawns. */\ntype Ray = {\n angle: number; // base emission angle (radians)\n maxLen: number; // target length as a fraction of maxRadius\n speed: number; // life units per second (→ lifetime ≈ 1/speed)\n life: number; // < 0 staggered delay, 0..1 active\n width: number; // core stroke width\n bright: number; // base brightness 0..1\n phase: number; // twinkle offset\n react: number; // smoothed pointer reaction 0..1\n bend: number; // smoothed angular bend toward the pointer\n hasDot: boolean; // whether a glowing dot rides this fiber's tip\n dotR: number; // tip-dot radius\n dotPhase: number; // tip-dot twinkle offset\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the interactive canvas background (reusable on its own).\n *\n * Fibers stream out of a bottom-center origin in a wide radial fan. Each\n * one continuously grows, slightly over-extends, fades, and regenerates\n * with fresh angle/length/speed/opacity, while glowing dots travel along\n * it. Rays near the pointer brighten, stretch, and bend toward it, then\n * ease back to their drift. The burst stays in the lower band so it never\n * reaches the headline above.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n /** Disable pointer reactivity. */\n interactive?: boolean;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n interactive = true,\n}: RadialBurstProps) {\n const canvasRef = useRef(null);\n const reduced = useReducedMotion();\n\n // Global intro fade, driven by Motion — read inside the canvas rAF loop.\n const intro = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<(dt: number) => void>(() => {});\n\n // Mount fade-in (Motion).\n useEffect(() => {\n if (reduced) {\n intro.set(1);\n return;\n }\n const controls = animate(intro, 1, {\n duration: 1.6,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [intro, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current(0);\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const pointer = { x: 0, y: 0, active: false };\n\n const respawn = (ray: Ray, initial: boolean) => {\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n const angle = lerp(aMin, aMax, Math.random()) + (Math.random() - 0.5) * 0.05;\n // Mild bias toward vertical, but side rays stay long so the fan fills\n // the full width along the bottom rather than tapering to a dome.\n const vert = Math.sin(clamp(angle, 0, Math.PI));\n ray.angle = angle;\n ray.maxLen = Math.min(\n 1.05,\n (0.8 + 0.2 * vert) * (0.6 + Math.random() * 0.45) +\n (Math.random() < 0.06 ? 0.12 : 0),\n );\n ray.speed = 0.085 + Math.random() * 0.13; // lifetime ≈ 4.5–11.8s\n ray.life = initial ? Math.random() : -Math.random() * 0.6;\n ray.width = 0.55 + Math.random() * 0.5;\n ray.bright = 0.5 + Math.random() * 0.5;\n ray.phase = Math.random() * Math.PI * 2;\n // A single glowing dot sits at the tip — it rides the growing tip, it\n // does not travel along the fiber.\n ray.hasDot = Math.random() < 0.72;\n ray.dotR = 0.8 + Math.random() * 1;\n ray.dotPhase = Math.random() * Math.PI * 2;\n };\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n // Origin sits on the bottom edge so the burst touches the bottom.\n originY = h;\n // Lower-band height — kept well below the headline, ~100px shorter.\n maxRadius = Math.max(h * 0.4, h * 0.6 - 100);\n const count = Math.round(\n Math.min(400, Math.max(220, w / 3.2)) * clamp(density, 0.4, 2),\n );\n rays = Array.from({ length: count }, () => {\n const ray: Ray = {\n angle: 0,\n maxLen: 0,\n speed: 0,\n life: 0,\n width: 1,\n bright: 1,\n phase: 0,\n react: 0,\n bend: 0,\n hasDot: false,\n dotR: 1,\n dotPhase: 0,\n };\n respawn(ray, true);\n return ray;\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current(0);\n };\n\n const render = (dt: number) => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const introA = intro.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n ctx.lineCap = \"round\";\n\n // Central bloom — a soft, diffuse glow rather than a hard bright disc.\n const bloomR =\n Math.min(w * 0.22, maxRadius * 0.6) * (0.7 + 0.3 * introA);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, (dark ? 0.5 : 0.44) * introA));\n bloom.addColorStop(0.3, rgba(disp.core, (dark ? 0.18 : 0.15) * introA));\n bloom.addColorStop(0.65, rgba(disp.core, (dark ? 0.06 : 0.05) * introA));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n const pointerOn = interactive && !reduced && pointer.active;\n const reactR = 170; // px radius of pointer influence\n // No reaction near the origin — only the middle/tip of fibers respond.\n const originGuard = maxRadius * 0.22;\n const pointerNearOrigin =\n Math.hypot(pointer.x - originX, pointer.y - originY) < originGuard;\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n\n if (!reduced) {\n ray.life += ray.speed * dt;\n if (ray.life >= 1) respawn(ray, false);\n }\n if (ray.life < 0) continue;\n\n const life = reduced ? 0.62 : ray.life;\n const growT = clamp(life / 0.7, 0, 1);\n const lenFrac = easeOut(growT);\n const extend = life > 0.7 ? (life - 0.7) / 0.3 : 0;\n const env = reduced\n ? 1\n : Math.min(1, life / 0.12) *\n (life > 0.8 ? clamp(1 - (life - 0.8) / 0.2, 0, 1) : 1);\n if (env <= 0) continue;\n\n const baseLen =\n ray.maxLen * maxRadius * lenFrac * (1 + 0.06 * extend);\n\n // Pointer reaction — engage quickly, return slowly. Hit-test only the\n // outer 35%→tip span so the dense near-origin zone stays calm.\n if (pointerOn && !pointerNearOrigin) {\n const cx = Math.cos(ray.angle) * SPREAD_X;\n const cy = -Math.sin(ray.angle);\n const ix = originX + cx * baseLen * 0.35;\n const iy = originY + cy * baseLen * 0.35;\n const bx = originX + cx * baseLen;\n const by = originY + cy * baseLen;\n const d = segDist(pointer.x, pointer.y, ix, iy, bx, by);\n let reactTarget = 0;\n let bendTarget = 0;\n if (d < reactR) {\n reactTarget = 1 - d / reactR;\n reactTarget *= reactTarget;\n const pAng = Math.atan2(\n -(pointer.y - originY),\n (pointer.x - originX) / SPREAD_X,\n );\n bendTarget = clamp(wrapAngle(pAng - ray.angle), -0.4, 0.4);\n }\n ray.react +=\n (reactTarget - ray.react) * (reactTarget > ray.react ? 0.14 : 0.06);\n ray.bend += (bendTarget * ray.react - ray.bend) * 0.1;\n } else if (ray.react !== 0 || ray.bend !== 0) {\n ray.react += -ray.react * 0.06;\n ray.bend += -ray.bend * 0.1;\n }\n\n const react = ray.react;\n const drawAngle = ray.angle + ray.bend;\n const dirx = Math.cos(drawAngle) * SPREAD_X;\n const diry = -Math.sin(drawAngle);\n const effLen = baseLen * (1 + 0.12 * react);\n const ex = originX + dirx * effLen;\n const ey = originY + diry * effLen;\n\n const twinkle = reduced ? 1 : 0.82 + 0.18 * Math.sin(now * 1.3 + ray.phase);\n const aBase = clamp(\n env * introA * ray.bright * twinkle * (1 + 0.9 * react),\n 0,\n 1,\n );\n\n // Brightness builds up *along* the fiber: near-zero through the dense\n // convergence zone at the origin (so overlapping starts don't blow out\n // to white), peaking once the fibers have fanned apart, fading at the tip.\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0));\n grad.addColorStop(0.16, rgba(disp.rayBase, 0.22 * aBase));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.85 * aBase));\n grad.addColorStop(0.75, rgba(disp.rayBase, 0.36 * aBase));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n\n // Soft wide pass → subtle blur/glow.\n ctx.lineWidth = ray.width * (dark ? 3.2 : 2.6);\n ctx.globalAlpha = dark ? 0.22 : 0.18;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Crisp core pass.\n ctx.lineWidth = ray.width;\n ctx.globalAlpha = 1;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // A single glowing dot sits at the very tip — it rides the growing\n // tip but never travels along the fiber.\n if (ray.hasDot) {\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + ray.dotPhase);\n // Dim dots whose tip still sits inside the convergence zone.\n const tipFade = clamp(effLen / (maxRadius * 0.28), 0, 1);\n ctx.fillStyle = rgba(\n disp.dotTip,\n clamp(\n env * introA * tipFade * (dark ? 0.95 : 1) * (0.45 + 0.55 * dtw) *\n (1 + 0.6 * react),\n 0,\n 1,\n ),\n );\n ctx.beginPath();\n ctx.arc(ex, ey, ray.dotR * (1 + 0.4 * react), 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalAlpha = 1;\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render(0);\n return () => ro.disconnect();\n }\n\n const onMove = (e: PointerEvent) => {\n const rect = canvas.getBoundingClientRect();\n const x = e.clientX - rect.left;\n const y = e.clientY - rect.top;\n if (x < 0 || y < 0 || x > rect.width || y > rect.height) {\n pointer.active = false;\n return;\n }\n pointer.x = x;\n pointer.y = y;\n pointer.active = true;\n };\n const onLeave = () => {\n pointer.active = false;\n };\n if (interactive) {\n window.addEventListener(\"pointermove\", onMove, { passive: true });\n window.addEventListener(\"blur\", onLeave);\n }\n\n let last = performance.now();\n const loop = (t: number) => {\n const dt = Math.min(0.05, (t - last) / 1000);\n last = t;\n render(dt);\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n if (interactive) {\n window.removeEventListener(\"pointermove\", onMove);\n window.removeEventListener(\"blur\", onLeave);\n }\n };\n }, [density, intro, reduced, interactive]);\n\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n
    \n setOpen((v) => !v)}\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n \n \n\n \n {open && (\n \n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n
  • \n {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n \n {t.label}\n \n
  • \n );\n })}\n \n )}\n
    \n
    \n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition: headline over the interactive burst.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n burstProps?: Omit;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n
    \n of global commerce\n \n ),\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n \n {/* Background gradient — crossfades between themes. */}\n \n \n \n\n {/* Interactive burst — masked so it fades out below the headline. */}\n \n \n \n\n {/* Theme switcher. */}\n
    \n \n
    \n\n {/* Headline. */}\n
    \n \n {title}\n \n
    \n \n );\n}\n\nexport default RadialBurstHero;\n", "type": "registry:ui", - "integrity": "sha384-aXAa9+odE7/IaRbmsz2WioIdDJ2fZmNfX3oFBbMP64zo1eOGUiDtXumKLQ2ONpWK" + "integrity": "sha384-EKZ353lD8QVVT65OWGiGxklNlWw6yX/HOjFRAPpdAu3c+rv7mT8jDTqhQUf8n43G" }, { "path": "utils/cn.ts", @@ -4125,9 +4125,16 @@ } ], "meta": { - "version": "2.0.0" + "version": "2.0.1" }, "changelog": [ + { + "version": "2.0.1", + "date": "2026-06-05", + "changes": [ + "Theme switcher now uses a plain button-menu pattern (disclosure button + `aria-current` on the active theme) instead of `role=\"listbox\"`/`role=\"option\"`, which had promised listbox keyboard semantics the widget did not implement." + ] + }, { "version": "2.0.0", "date": "2026-06-03", diff --git a/registry/blocks/hero/radial-burst/component.tsx b/registry/blocks/hero/radial-burst/component.tsx index 6b07441b..a71984f0 100644 --- a/registry/blocks/hero/radial-burst/component.tsx +++ b/registry/blocks/hero/radial-burst/component.tsx @@ -634,7 +634,6 @@ function ThemeSwitcher({