{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "grid-beam", "type": "registry:ui", "description": "Canvas grid beam animation with palette presets, SVG dividers, and composable headless pieces via useGridBeam", "files": [ { "path": "registry/default/ui/grid-beam.tsx", "content": "\"use client\"\n\nimport {\n forwardRef,\n useEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n type ComponentProps,\n type CSSProperties,\n type MutableRefObject,\n type RefObject,\n} from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n// ─── Color palettes ───────────────────────────────────────────────\n\nexport type RGB = readonly [number, number, number]\n\nexport type PaletteBand = Readonly<{\n color: RGB\n op: number\n}>\n\nexport type GridBeamPaletteLayers = Readonly<{\n h: readonly PaletteBand[]\n v: readonly PaletteBand[]\n}>\n\nexport const PALETTES = {\n colorful: {\n dark: {\n h: [\n { color: [255, 50, 100] as const, op: 0.38 },\n { color: [40, 180, 220] as const, op: 0.35 },\n { color: [50, 200, 80] as const, op: 0.38 },\n { color: [180, 40, 240] as const, op: 0.35 },\n { color: [255, 160, 30] as const, op: 0.38 },\n { color: [100, 70, 255] as const, op: 0.35 },\n ],\n v: [\n { color: [40, 140, 255] as const, op: 0.38 },\n { color: [240, 50, 180] as const, op: 0.35 },\n { color: [30, 185, 170] as const, op: 0.38 },\n { color: [255, 120, 40] as const, op: 0.38 },\n { color: [100, 70, 255] as const, op: 0.35 },\n { color: [50, 200, 80] as const, op: 0.38 },\n ],\n },\n light: {\n h: [\n { color: [200, 30, 70] as const, op: 0.28 },\n { color: [20, 140, 180] as const, op: 0.24 },\n { color: [30, 160, 50] as const, op: 0.28 },\n { color: [140, 20, 200] as const, op: 0.24 },\n { color: [210, 120, 10] as const, op: 0.28 },\n { color: [70, 40, 210] as const, op: 0.24 },\n ],\n v: [\n { color: [20, 100, 210] as const, op: 0.28 },\n { color: [200, 30, 140] as const, op: 0.24 },\n { color: [15, 145, 130] as const, op: 0.28 },\n { color: [210, 90, 20] as const, op: 0.28 },\n { color: [70, 40, 210] as const, op: 0.24 },\n { color: [30, 160, 50] as const, op: 0.28 },\n ],\n },\n },\n mono: {\n dark: {\n h: [\n { color: [200, 200, 200] as const, op: 0.16 },\n { color: [180, 180, 180] as const, op: 0.13 },\n { color: [190, 190, 190] as const, op: 0.16 },\n { color: [175, 175, 175] as const, op: 0.13 },\n { color: [195, 195, 195] as const, op: 0.16 },\n { color: [185, 185, 185] as const, op: 0.13 },\n ],\n v: [\n { color: [185, 185, 185] as const, op: 0.16 },\n { color: [170, 170, 170] as const, op: 0.13 },\n { color: [195, 195, 195] as const, op: 0.16 },\n { color: [180, 180, 180] as const, op: 0.13 },\n { color: [190, 190, 190] as const, op: 0.16 },\n { color: [175, 175, 175] as const, op: 0.13 },\n ],\n },\n light: {\n h: [\n { color: [90, 90, 90] as const, op: 0.13 },\n { color: [110, 110, 110] as const, op: 0.1 },\n { color: [80, 80, 80] as const, op: 0.13 },\n { color: [100, 100, 100] as const, op: 0.1 },\n { color: [85, 85, 85] as const, op: 0.13 },\n { color: [95, 95, 95] as const, op: 0.1 },\n ],\n v: [\n { color: [100, 100, 100] as const, op: 0.13 },\n { color: [80, 80, 80] as const, op: 0.1 },\n { color: [90, 90, 90] as const, op: 0.13 },\n { color: [110, 110, 110] as const, op: 0.1 },\n { color: [85, 85, 85] as const, op: 0.13 },\n { color: [95, 95, 95] as const, op: 0.1 },\n ],\n },\n },\n ocean: {\n dark: {\n h: [\n { color: [100, 80, 255] as const, op: 0.38 },\n { color: [60, 140, 255] as const, op: 0.35 },\n { color: [80, 100, 220] as const, op: 0.38 },\n { color: [130, 70, 255] as const, op: 0.35 },\n { color: [50, 120, 230] as const, op: 0.38 },\n { color: [110, 90, 240] as const, op: 0.35 },\n ],\n v: [\n { color: [70, 130, 255] as const, op: 0.38 },\n { color: [120, 80, 240] as const, op: 0.35 },\n { color: [90, 110, 230] as const, op: 0.38 },\n { color: [60, 100, 255] as const, op: 0.35 },\n { color: [140, 100, 255] as const, op: 0.38 },\n { color: [80, 120, 220] as const, op: 0.35 },\n ],\n },\n light: {\n h: [\n { color: [60, 50, 180] as const, op: 0.28 },\n { color: [40, 100, 210] as const, op: 0.24 },\n { color: [50, 70, 190] as const, op: 0.28 },\n { color: [90, 50, 200] as const, op: 0.24 },\n { color: [30, 80, 200] as const, op: 0.28 },\n { color: [70, 60, 210] as const, op: 0.24 },\n ],\n v: [\n { color: [50, 90, 220] as const, op: 0.28 },\n { color: [80, 60, 200] as const, op: 0.24 },\n { color: [60, 80, 210] as const, op: 0.28 },\n { color: [40, 70, 200] as const, op: 0.24 },\n { color: [100, 80, 230] as const, op: 0.28 },\n { color: [55, 85, 195] as const, op: 0.24 },\n ],\n },\n },\n sunset: {\n dark: {\n h: [\n { color: [255, 100, 60] as const, op: 0.38 },\n { color: [255, 180, 50] as const, op: 0.35 },\n { color: [255, 80, 80] as const, op: 0.38 },\n { color: [255, 140, 40] as const, op: 0.35 },\n { color: [255, 200, 60] as const, op: 0.38 },\n { color: [255, 120, 50] as const, op: 0.35 },\n ],\n v: [\n { color: [255, 160, 70] as const, op: 0.38 },\n { color: [255, 90, 60] as const, op: 0.35 },\n { color: [255, 200, 50] as const, op: 0.38 },\n { color: [255, 70, 70] as const, op: 0.35 },\n { color: [255, 150, 40] as const, op: 0.38 },\n { color: [255, 100, 80] as const, op: 0.35 },\n ],\n },\n light: {\n h: [\n { color: [200, 70, 30] as const, op: 0.28 },\n { color: [210, 140, 20] as const, op: 0.24 },\n { color: [190, 50, 50] as const, op: 0.28 },\n { color: [200, 100, 20] as const, op: 0.24 },\n { color: [220, 160, 30] as const, op: 0.28 },\n { color: [195, 80, 25] as const, op: 0.24 },\n ],\n v: [\n { color: [210, 120, 40] as const, op: 0.28 },\n { color: [190, 60, 40] as const, op: 0.24 },\n { color: [220, 160, 25] as const, op: 0.28 },\n { color: [185, 45, 45] as const, op: 0.24 },\n { color: [200, 110, 20] as const, op: 0.28 },\n { color: [195, 75, 50] as const, op: 0.24 },\n ],\n },\n },\n} as const satisfies Record<\n string,\n Record<\"dark\" | \"light\", GridBeamPaletteLayers>\n>\n\nexport type GridBeamPaletteKey = keyof typeof PALETTES\n\nexport type GridBeamColorScheme = \"dark\" | \"light\"\n\nexport type GridBeamThemeProp = GridBeamColorScheme | \"auto\"\n\nfunction subscribePreferredColorScheme(onChange: () => void) {\n const mq = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mq.addEventListener(\"change\", onChange)\n return () => mq.removeEventListener(\"change\", onChange)\n}\n\nfunction getPreferredColorSchemeSnapshot(): GridBeamColorScheme {\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n ? \"dark\"\n : \"light\"\n}\n\nfunction useResolvedColorScheme(theme: GridBeamThemeProp): GridBeamColorScheme {\n const systemScheme = useSyncExternalStore(\n subscribePreferredColorScheme,\n getPreferredColorSchemeSnapshot,\n () => \"light\" as GridBeamColorScheme\n )\n if (theme === \"auto\") {\n return systemScheme\n }\n return theme\n}\n\nexport function resolveGridBeamPalette(\n colorVariant: GridBeamPaletteKey,\n scheme: GridBeamColorScheme\n): GridBeamPaletteLayers {\n const variant = PALETTES[colorVariant]\n if (!variant) {\n return PALETTES.colorful.dark\n }\n return variant[scheme] ?? PALETTES.colorful.dark\n}\n\nfunction smoothstep(t: number): number {\n return t * t * (3 - 2 * t)\n}\n\nfunction gaussian(x: number, s: number): number {\n return Math.exp(-(x * x) / (2 * s * s))\n}\n\nexport type BeamCanvasRuntimeConfig = Readonly<{\n rows: number\n cols: number\n palette: GridBeamPaletteLayers\n active: boolean\n fadingOut: boolean\n fadeStart: number | null\n duration: number\n strength: number\n breathe: boolean\n}>\n\nfunction useBeamCanvas(\n canvasRef: RefObject,\n config: MutableRefObject\n) {\n const animRef = useRef(null)\n const startRef = useRef(null)\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: animation reads latest `config.current` each frame; only canvas mount should restart the loop.\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) {\n return\n }\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) {\n return\n }\n const dpr = window.devicePixelRatio || 1\n\n const resize = () => {\n const rect = canvas.getBoundingClientRect()\n canvas.width = rect.width * dpr\n canvas.height = rect.height * dpr\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n resize()\n const ro = new ResizeObserver(resize)\n ro.observe(canvas)\n startRef.current = performance.now()\n\n // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: single canvas frame renderer; splitting would obscure the math.\n const draw = (now: number) => {\n const {\n rows,\n cols,\n palette,\n active,\n fadingOut,\n fadeStart,\n duration,\n strength,\n breathe,\n } = config.current\n const rect = canvas.getBoundingClientRect()\n const w = rect.width\n const h = rect.height\n ctx.clearRect(0, 0, w, h)\n\n if (!(active || fadingOut)) {\n animRef.current = requestAnimationFrame(draw)\n return\n }\n\n const elapsed = (now - (startRef.current ?? now)) / 1000\n let fade = 1\n if (fadingOut && fadeStart) {\n fade = Math.max(0, 1 - (now - fadeStart) / 600)\n if (fade <= 0) {\n animRef.current = requestAnimationFrame(draw)\n return\n }\n } else if (active) {\n fade = smoothstep(Math.min(1, elapsed / 0.8))\n }\n\n const cellW = w / cols\n const cellH = h / rows\n const gs = fade * strength\n const br = breathe\n ? 0.85 + 0.3 * Math.sin(elapsed * 1.4) + 0.1 * Math.sin(elapsed * 2.3)\n : 1\n\n const rgba = (r: number, g: number, b: number, a: number) =>\n `rgba(${r},${g},${b},${Math.max(0, a).toFixed(4)})`\n\n for (let r = 1; r < rows; r++) {\n const y = r * cellH\n const pal = palette.h[r % palette.h.length]\n const [cr, cg, cb] = pal.color\n const op = pal.op\n const speed = 1 + (r % 3) * 0.12\n const offset = r * 0.21 + (r % 2) * 0.35\n const t = ((elapsed * speed) / duration + offset) % 1\n const x = t * w\n\n const bloomLen = cellW * 0.6 * br\n const bloomH = 4\n const bloomGrad = ctx.createRadialGradient(x, y, 0, x, y, bloomLen)\n bloomGrad.addColorStop(0, rgba(cr, cg, cb, op * 0.3 * gs))\n bloomGrad.addColorStop(0.4, rgba(cr, cg, cb, op * 0.12 * gs))\n bloomGrad.addColorStop(1, \"transparent\")\n ctx.save()\n ctx.scale(1, bloomH / bloomLen)\n ctx.fillStyle = bloomGrad\n ctx.beginPath()\n ctx.arc(x, (y * bloomLen) / bloomH, bloomLen, 0, Math.PI * 2)\n ctx.fill()\n ctx.restore()\n\n const coreLen = cellW * 0.55 * br\n const lineGrad = ctx.createLinearGradient(\n x - coreLen,\n y,\n x + coreLen,\n y\n )\n lineGrad.addColorStop(0, \"transparent\")\n lineGrad.addColorStop(0.12, rgba(cr, cg, cb, op * 0.4 * gs))\n lineGrad.addColorStop(\n 0.35,\n rgba(\n Math.min(255, cr + 60),\n Math.min(255, cg + 60),\n Math.min(255, cb + 60),\n op * 0.8 * gs\n )\n )\n lineGrad.addColorStop(\n 0.5,\n rgba(\n Math.min(255, cr + 100),\n Math.min(255, cg + 100),\n Math.min(255, cb + 100),\n op * 1.0 * gs\n )\n )\n lineGrad.addColorStop(\n 0.65,\n rgba(\n Math.min(255, cr + 60),\n Math.min(255, cg + 60),\n Math.min(255, cb + 60),\n op * 0.8 * gs\n )\n )\n lineGrad.addColorStop(0.88, rgba(cr, cg, cb, op * 0.4 * gs))\n lineGrad.addColorStop(1, \"transparent\")\n ctx.strokeStyle = lineGrad\n ctx.lineWidth = 1.5\n ctx.beginPath()\n ctx.moveTo(x - coreLen, y)\n ctx.lineTo(x + coreLen, y)\n ctx.stroke()\n }\n\n for (let c = 1; c < cols; c++) {\n const x = c * cellW\n const pal = palette.v[c % palette.v.length]\n const [cr, cg, cb] = pal.color\n const op = pal.op\n const speed = 1 + (c % 3) * 0.1\n const offset = c * 0.26 + (c % 2) * 0.4\n const t = ((elapsed * speed) / (duration * 1.2) + offset) % 1\n const y = t * h\n\n const bloomLen = cellH * 0.6 * br\n const bloomW = 4\n const bloomGrad = ctx.createRadialGradient(x, y, 0, x, y, bloomLen)\n bloomGrad.addColorStop(0, rgba(cr, cg, cb, op * 0.3 * gs))\n bloomGrad.addColorStop(0.4, rgba(cr, cg, cb, op * 0.12 * gs))\n bloomGrad.addColorStop(1, \"transparent\")\n ctx.save()\n ctx.scale(bloomW / bloomLen, 1)\n ctx.fillStyle = bloomGrad\n ctx.beginPath()\n ctx.arc((x * bloomLen) / bloomW, y, bloomLen, 0, Math.PI * 2)\n ctx.fill()\n ctx.restore()\n\n const coreLen = cellH * 0.55 * br\n const lineGrad = ctx.createLinearGradient(\n x,\n y - coreLen,\n x,\n y + coreLen\n )\n lineGrad.addColorStop(0, \"transparent\")\n lineGrad.addColorStop(0.12, rgba(cr, cg, cb, op * 0.4 * gs))\n lineGrad.addColorStop(\n 0.35,\n rgba(\n Math.min(255, cr + 60),\n Math.min(255, cg + 60),\n Math.min(255, cb + 60),\n op * 0.8 * gs\n )\n )\n lineGrad.addColorStop(\n 0.5,\n rgba(\n Math.min(255, cr + 100),\n Math.min(255, cg + 100),\n Math.min(255, cb + 100),\n op * 1.0 * gs\n )\n )\n lineGrad.addColorStop(\n 0.65,\n rgba(\n Math.min(255, cr + 60),\n Math.min(255, cg + 60),\n Math.min(255, cb + 60),\n op * 0.8 * gs\n )\n )\n lineGrad.addColorStop(0.88, rgba(cr, cg, cb, op * 0.4 * gs))\n lineGrad.addColorStop(1, \"transparent\")\n ctx.strokeStyle = lineGrad\n ctx.lineWidth = 1.5\n ctx.beginPath()\n ctx.moveTo(x, y - coreLen)\n ctx.lineTo(x, y + coreLen)\n ctx.stroke()\n }\n\n for (let r = 1; r < rows; r++) {\n for (let c = 1; c < cols; c++) {\n const ix = c * cellW\n const iy = r * cellH\n const hSpeed = 1 + (r % 3) * 0.12\n const hOffset = r * 0.21 + (r % 2) * 0.35\n const ht = ((elapsed * hSpeed) / duration + hOffset) % 1\n const hx = ht * w\n const vSpeed = 1 + (c % 3) * 0.1\n const vOffset = c * 0.26 + (c % 2) * 0.4\n const vt = ((elapsed * vSpeed) / (duration * 1.2) + vOffset) % 1\n const vy = vt * h\n\n const proxH = gaussian((hx - ix) / cellW, 0.25)\n const proxV = gaussian((vy - iy) / cellH, 0.25)\n const prox = proxH * proxV\n\n if (prox > 0.05) {\n const pH = palette.h[r % palette.h.length]\n const pV = palette.v[c % palette.v.length]\n const mr = Math.floor((pH.color[0] + pV.color[0]) / 2)\n const mg = Math.floor((pH.color[1] + pV.color[1]) / 2)\n const mb = Math.floor((pH.color[2] + pV.color[2]) / 2)\n const fr = 3.5 * Math.sqrt(prox)\n const fop = prox * 0.6 * gs\n\n const fg = ctx.createRadialGradient(ix, iy, 0, ix, iy, fr)\n fg.addColorStop(\n 0,\n rgba(\n Math.min(255, mr + 140),\n Math.min(255, mg + 140),\n Math.min(255, mb + 140),\n fop\n )\n )\n fg.addColorStop(0.5, rgba(mr, mg, mb, fop * 0.4))\n fg.addColorStop(1, \"transparent\")\n ctx.fillStyle = fg\n ctx.beginPath()\n ctx.arc(ix, iy, fr, 0, Math.PI * 2)\n ctx.fill()\n }\n }\n }\n\n animRef.current = requestAnimationFrame(draw)\n }\n\n animRef.current = requestAnimationFrame(draw)\n return () => {\n if (animRef.current !== null) {\n cancelAnimationFrame(animRef.current)\n }\n ro.disconnect()\n }\n }, [canvasRef])\n}\n\nexport type UseGridBeamOptions = Readonly<{\n rows?: number\n cols?: number\n colorVariant?: GridBeamPaletteKey\n /** Resolved automatically when `\"auto\"` via `prefers-color-scheme`. */\n theme?: GridBeamThemeProp\n active?: boolean\n duration?: number\n strength?: number\n breathe?: boolean\n}>\n\nexport type UseGridBeamResult = Readonly<{\n canvasRef: RefObject\n palette: GridBeamPaletteLayers\n resolvedScheme: GridBeamColorScheme\n rows: number\n cols: number\n fadingOut: boolean\n fadeStart: number | null\n}>\n\n/**\n * Headless state for the grid beam canvas: palette resolution, fade-out when `active` becomes false,\n * and a ref to attach to {@link GridBeamCanvas}.\n */\nexport function useGridBeam({\n rows: rowsProp = 3,\n cols: colsProp = 4,\n colorVariant = \"colorful\",\n theme = \"dark\",\n active = true,\n duration = 3,\n strength = 1,\n breathe = true,\n}: UseGridBeamOptions): UseGridBeamResult {\n const rows = Math.max(2, rowsProp)\n const cols = Math.max(2, colsProp)\n\n const canvasRef = useRef(null)\n const [fadingOut, setFadingOut] = useState(false)\n const [fadeStart, setFadeStart] = useState(null)\n const prevActive = useRef(active)\n\n const resolvedScheme = useResolvedColorScheme(theme)\n\n const palette = useMemo(\n () => resolveGridBeamPalette(colorVariant, resolvedScheme),\n [colorVariant, resolvedScheme]\n )\n\n useEffect(() => {\n if (prevActive.current && !active) {\n setFadingOut(true)\n setFadeStart(performance.now())\n const timer = window.setTimeout(() => setFadingOut(false), 700)\n prevActive.current = active\n return () => window.clearTimeout(timer)\n }\n prevActive.current = active\n }, [active])\n\n const configRef = useRef({\n rows,\n cols,\n palette,\n active,\n fadingOut,\n fadeStart,\n duration,\n strength,\n breathe,\n })\n configRef.current = {\n rows,\n cols,\n palette,\n active,\n fadingOut,\n fadeStart,\n duration,\n strength,\n breathe,\n }\n\n useBeamCanvas(canvasRef, configRef)\n\n return {\n canvasRef,\n palette,\n resolvedScheme,\n rows,\n cols,\n fadingOut,\n fadeStart,\n }\n}\n\nexport type GridBeamDividersProps = ComponentProps<\"svg\"> &\n Readonly<{\n rows: number\n cols: number\n /** Grid line color (default `var(--border)` from shadcn theme). */\n dividerStroke?: string\n }>\n\n/** SVG grid lines only — pair with {@link GridBeamCanvas} and your own layout. */\nexport function GridBeamDividers({\n rows,\n cols,\n dividerStroke = \"var(--border)\",\n className,\n ...props\n}: GridBeamDividersProps) {\n return (\n \n {Array.from({ length: rows - 1 }, (_, r) => {\n const y = `${((r + 1) / rows) * 100}%`\n return (\n \n )\n })}\n {Array.from({ length: cols - 1 }, (_, c) => {\n const x = `${((c + 1) / cols) * 100}%`\n return (\n \n )\n })}\n \n )\n}\n\nexport type GridBeamCanvasProps = ComponentProps<\"canvas\"> &\n Readonly<{\n borderRadius?: number\n }>\n\nexport const GridBeamCanvas = forwardRef<\n HTMLCanvasElement,\n GridBeamCanvasProps\n>(function GridBeamCanvas({ className, style, borderRadius, ...props }, ref) {\n return (\n \n )\n})\n\nexport type GridBeamContentProps = ComponentProps<\"div\">\n\n/** Slot above the beam layers — default `z-index` stacks content on top. */\nexport function GridBeamContent({ className, ...props }: GridBeamContentProps) {\n return (\n \n )\n}\n\nexport type GridBeamProps = UseGridBeamOptions &\n ComponentProps<\"div\"> &\n Readonly<{\n /** Corner radius for the root and canvas (divider SVG is rectangular). */\n borderRadius?: number\n }>\n\n/**\n * Opinionated composition: root + dividers + canvas + content slot.\n * For full control, call {@link useGridBeam} and assemble {@link GridBeamDividers}, {@link GridBeamCanvas}, {@link GridBeamContent} yourself.\n */\nexport function GridBeam({\n children,\n className,\n style,\n borderRadius,\n rows,\n cols,\n colorVariant,\n theme,\n active,\n duration,\n strength,\n breathe,\n ...props\n}: GridBeamProps) {\n const {\n canvasRef,\n rows: r,\n cols: c,\n } = useGridBeam({\n rows,\n cols,\n colorVariant,\n theme,\n active,\n duration,\n strength,\n breathe,\n })\n\n return (\n \n \n \n {children}\n \n )\n}\n", "type": "registry:ui" } ] }