From e625c9fb25448b07d6c650f9355c80678bec8cb4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 14:51:55 +0000 Subject: [PATCH] feat: add displacement control to Radiant Lines Restore frame-rate independent star travel after the dt fix collapsed streaks into filled heads, and expose a displacement prop so scroll warp distance (and streak length) can be tuned. Co-authored-by: Jay Sharma --- content/docs/components/radiant-lines.mdx | 3 +- public/r/radiant-lines-svelte.json | 2 +- public/r/radiant-lines.json | 2 +- registry/radiant-lines/radiant-lines-demo.tsx | 13 +++++++- .../radiant-lines/radiant-lines-vanilla.ts | 32 ++++++++++++++++--- registry/radiant-lines/radiant-lines.svelte | 3 ++ registry/radiant-lines/radiant-lines.tsx | 5 ++- 7 files changed, 51 insertions(+), 9 deletions(-) diff --git a/content/docs/components/radiant-lines.mdx b/content/docs/components/radiant-lines.mdx index fe639f1..a08fa91 100644 --- a/content/docs/components/radiant-lines.mdx +++ b/content/docs/components/radiant-lines.mdx @@ -5,7 +5,7 @@ description: A hyperspace starfield background — colored streaks from the cent import { RadiantLinesDemo } from "@/registry/radiant-lines/radiant-lines-demo" -Canvas starfield in the spirit of those GSAP hyperspace scroll demos: stars project from the center as streaks. Scroll velocity boosts warp; release and it eases back to a drift. +Canvas starfield in the spirit of those GSAP hyperspace scroll demos: stars project from the center as streaks. Scroll velocity boosts warp; `displacement` scales how far each star jumps. Release and it eases back to a drift. @@ -63,6 +63,7 @@ export function Hero() { | --- | --- | --- | | `colors` | `string[]` | coral / teal / amber / blue / pink / lime / slate | | `starCount` | `number` | `420` | +| `displacement` | `number` | `1` — how far each star jumps on warp / scroll | | `containerRef` | `RefObject` | React — window scroll | | `container` | `HTMLElement` | Svelte — window scroll | | `className` | `string` | React only | diff --git a/public/r/radiant-lines-svelte.json b/public/r/radiant-lines-svelte.json index c913522..7d7cb84 100644 --- a/public/r/radiant-lines-svelte.json +++ b/public/r/radiant-lines-svelte.json @@ -7,7 +7,7 @@ "files": [ { "path": "registry/radiant-lines/radiant-lines.svelte", - "content": "\n\n\n\n\n \n\n", + "content": "\n\n\n\n\n \n\n", "type": "registry:file", "target": "src/lib/components/ui/radiant-lines.svelte" } diff --git a/public/r/radiant-lines.json b/public/r/radiant-lines.json index 043dad5..c5c4bea 100644 --- a/public/r/radiant-lines.json +++ b/public/r/radiant-lines.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/radiant-lines/radiant-lines.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, type RefObject } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type RadiantLinesOptions = {\n /** Star / streak colors */\n colors?: string[]\n /** How many stars. Default 420 */\n starCount?: number\n /**\n * Scroll container. Omit / `null` to use the window.\n * Pass the overflow scroller when the component lives inside one.\n */\n container?: HTMLElement | null\n}\n\nexport type RadiantLinesInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nexport const DEFAULT_COLORS = [\n \"#FF6B4A\",\n \"#2DD4BF\",\n \"#FBBF24\",\n \"#60A5FA\",\n \"#F472B6\",\n \"#A3E635\",\n \"#94A3B8\",\n]\n\nconst DEPTH = 1000\nconst STIFFNESS = 70\nconst DAMPING = 32\nconst MASS = 0.5\n\ntype Star = {\n x: number\n y: number\n z: number\n pz: number\n color: string\n}\n\nfunction paletteOf(colors?: string[]) {\n return colors && colors.length > 0 ? colors : DEFAULT_COLORS\n}\n\nfunction createStars(count: number, colors: string[]): Star[] {\n const stars: Star[] = []\n for (let i = 0; i < count; i++) {\n const z = Math.random() * DEPTH\n stars.push({\n x: (Math.random() - 0.5) * DEPTH,\n y: (Math.random() - 0.5) * DEPTH,\n z,\n pz: z,\n color: colors[i % colors.length]!,\n })\n }\n return stars\n}\n\n/**\n * Hyperspace starfield — colored streaks radiate from the center.\n * Transparent canvas over `bg-background` (shadcn theme). Warp speed follows\n * scroll velocity (down → inward, up → outward).\n */\nexport function createRadiantLines(\n canvas: HTMLCanvasElement,\n initial: RadiantLinesOptions = {}\n): RadiantLinesInstance | null {\n let options: RadiantLinesOptions = {\n starCount: 420,\n colors: DEFAULT_COLORS,\n container: null,\n ...initial,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n let stars = createStars(options.starCount ?? 420, paletteOf(options.colors))\n const size = { w: 0, h: 0 }\n let speed = 0.28\n let speedVel = 0\n let speedTarget = 0.28\n let dir = -1\n let reduce = false\n let raf = 0\n let running = true\n let lastFrame = performance.now()\n let lastScrollY = 0\n let lastScrollT = performance.now()\n let attached: Window | HTMLElement | null = null\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n if (reduce) {\n speedTarget = 0.06\n dir = -1\n }\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const readScrollY = () => {\n if (options.container) return options.container.scrollTop\n return window.scrollY\n }\n\n const applyWarp = (v: number) => {\n if (reduce) {\n speedTarget = 0.06\n dir = -1\n return\n }\n // Flipped: scroll down → inward; scroll up → outward\n if (Math.abs(v) > 2) {\n dir = v > 0 ? 1 : -1\n }\n const boost = Math.min(8, Math.abs(v) / 80)\n speedTarget = 0.22 + boost\n }\n\n const onScroll = () => {\n const now = performance.now()\n const y = readScrollY()\n const dt = now - lastScrollT\n const v = dt > 0 ? ((y - lastScrollY) / dt) * 1000 : 0\n lastScrollY = y\n lastScrollT = now\n applyWarp(v)\n }\n\n const unbindScroll = () => {\n if (!attached) return\n attached.removeEventListener(\"scroll\", onScroll)\n attached = null\n }\n\n const bindScroll = () => {\n const next: Window | HTMLElement = options.container ?? window\n if (attached === next) return\n unbindScroll()\n attached = next\n lastScrollY = readScrollY()\n lastScrollT = performance.now()\n attached.addEventListener(\"scroll\", onScroll, { passive: true })\n }\n\n bindScroll()\n\n const tick = (now: number) => {\n if (!running) return\n\n const dt = Math.min(0.05, Math.max(0, (now - lastFrame) / 1000))\n lastFrame = now\n\n if (now - lastScrollT > 40 && !reduce) {\n applyWarp(0)\n }\n\n const accel = (STIFFNESS * (speedTarget - speed) - DAMPING * speedVel) / MASS\n speedVel += accel * dt\n speed += speedVel * dt\n\n const { w, h } = size\n if (w === 0 || h === 0) {\n raf = requestAnimationFrame(tick)\n return\n }\n\n const cx = w / 2\n const cy = h / 2\n const warp = reduce ? 0.05 : speed\n const focal = Math.max(w, h) * 0.5\n const colors = paletteOf(options.colors)\n\n ctx.clearRect(0, 0, w, h)\n\n for (let i = 0; i < stars.length; i++) {\n const star = stars[i]!\n star.pz = star.z\n star.z -= dir * warp * 2.4 * dt\n\n if (star.z <= 1) {\n star.z = DEPTH\n star.pz = DEPTH\n star.x = (Math.random() - 0.5) * DEPTH\n star.y = (Math.random() - 0.5) * DEPTH\n star.color = colors[Math.floor(Math.random() * colors.length)]!\n } else if (star.z >= DEPTH) {\n star.z = 1.5\n star.pz = 1.5\n star.x = (Math.random() - 0.5) * DEPTH\n star.y = (Math.random() - 0.5) * DEPTH\n star.color = colors[Math.floor(Math.random() * colors.length)]!\n }\n\n const k = focal / star.z\n const x = cx + star.x * k\n const y = cy + star.y * k\n\n const pk = focal / star.pz\n const px = cx + star.x * pk\n const py = cy + star.y * pk\n\n if (\n (x < -50 && px < -50) ||\n (x > w + 50 && px > w + 50) ||\n (y < -50 && py < -50) ||\n (y > h + 50 && py > h + 50)\n ) {\n continue\n }\n\n const near = 1 - star.z / DEPTH\n const lineW = Math.max(0.8, near * (1.8 + warp * 0.12))\n const alpha = 0.35 + near * 0.65\n\n ctx.beginPath()\n ctx.moveTo(px, py)\n ctx.lineTo(x, y)\n ctx.strokeStyle = star.color\n ctx.globalAlpha = alpha\n ctx.lineWidth = lineW\n ctx.lineCap = \"round\"\n ctx.stroke()\n\n if (near > 0.45) {\n ctx.beginPath()\n ctx.arc(x, y, lineW * 0.9, 0, Math.PI * 2)\n ctx.fillStyle = star.color\n ctx.fill()\n }\n }\n\n ctx.globalAlpha = 1\n raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n\n return {\n setOptions(next) {\n const prevCount = options.starCount ?? 420\n const prevColors = paletteOf(options.colors).join(\",\")\n options = { ...options, ...next }\n const nextCount = options.starCount ?? 420\n const nextColors = paletteOf(options.colors).join(\",\")\n if (nextCount !== prevCount || nextColors !== prevColors) {\n stars = createStars(nextCount, paletteOf(options.colors))\n }\n bindScroll()\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n unbindScroll()\n mqReduce.removeEventListener(\"change\", onReduce)\n },\n }\n}\n\nexport type RadiantLinesProps = Omit & {\n className?: string\n /**\n * Scroll container. Omit to use the window.\n * Pass a ref when the component lives inside an overflow scroller.\n */\n containerRef?: RefObject\n}\n\n/**\n * Hyperspace starfield — colored streaks radiate from the center.\n * Transparent canvas over `bg-background` (shadcn theme).\n */\nexport function RadiantLines({\n className,\n colors,\n starCount = 420,\n containerRef,\n}: RadiantLinesProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createRadiantLines(canvas, {\n colors,\n starCount,\n container: containerRef?.current ?? null,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n colors,\n starCount,\n container: containerRef?.current ?? null,\n })\n }, [colors, starCount, containerRef])\n\n return (\n \n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, type RefObject } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type RadiantLinesOptions = {\n /** Star / streak colors */\n colors?: string[]\n /** How many stars. Default 420 */\n starCount?: number\n /**\n * How far each star jumps when warping (idle drift and scroll).\n * `1` matches the original ~60fps travel; higher values lengthen streaks.\n * Default `1`.\n */\n displacement?: number\n /**\n * Scroll container. Omit / `null` to use the window.\n * Pass the overflow scroller when the component lives inside one.\n */\n container?: HTMLElement | null\n}\n\nexport type RadiantLinesInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nexport const DEFAULT_COLORS = [\n \"#FF6B4A\",\n \"#2DD4BF\",\n \"#FBBF24\",\n \"#60A5FA\",\n \"#F472B6\",\n \"#A3E635\",\n \"#94A3B8\",\n]\n\nconst DEPTH = 1000\nconst STIFFNESS = 70\nconst DAMPING = 32\nconst MASS = 0.5\n/** Original per-frame coefficient (`2.4`) converted to per-second at 60fps. */\nconst TRAVEL_PER_SEC = 2.4 * 60\n/** Draw streaks as a 60fps step so filled heads keep a visible tail at any fps. */\nconst TRAIL_DT = 1 / 60\n\ntype Star = {\n x: number\n y: number\n z: number\n pz: number\n color: string\n}\n\nfunction paletteOf(colors?: string[]) {\n return colors && colors.length > 0 ? colors : DEFAULT_COLORS\n}\n\nfunction displacementOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return 1\n return Math.max(0, value)\n}\n\nfunction createStars(count: number, colors: string[]): Star[] {\n const stars: Star[] = []\n for (let i = 0; i < count; i++) {\n const z = Math.random() * DEPTH\n stars.push({\n x: (Math.random() - 0.5) * DEPTH,\n y: (Math.random() - 0.5) * DEPTH,\n z,\n pz: z,\n color: colors[i % colors.length]!,\n })\n }\n return stars\n}\n\n/**\n * Hyperspace starfield — colored streaks radiate from the center.\n * Transparent canvas over `bg-background` (shadcn theme). Warp speed follows\n * scroll velocity (down → inward, up → outward). `displacement` scales how\n * far each star travels (and how long the filled heads' streaks get).\n */\nexport function createRadiantLines(\n canvas: HTMLCanvasElement,\n initial: RadiantLinesOptions = {}\n): RadiantLinesInstance | null {\n let options: RadiantLinesOptions = {\n starCount: 420,\n colors: DEFAULT_COLORS,\n displacement: 1,\n container: null,\n ...initial,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n let stars = createStars(options.starCount ?? 420, paletteOf(options.colors))\n const size = { w: 0, h: 0 }\n let speed = 0.28\n let speedVel = 0\n let speedTarget = 0.28\n let dir = -1\n let reduce = false\n let raf = 0\n let running = true\n let lastFrame = performance.now()\n let lastScrollY = 0\n let lastScrollT = performance.now()\n let attached: Window | HTMLElement | null = null\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n if (reduce) {\n speedTarget = 0.06\n dir = -1\n }\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const readScrollY = () => {\n if (options.container) return options.container.scrollTop\n return window.scrollY\n }\n\n const applyWarp = (v: number) => {\n if (reduce) {\n speedTarget = 0.06\n dir = -1\n return\n }\n // Flipped: scroll down → inward; scroll up → outward\n if (Math.abs(v) > 2) {\n dir = v > 0 ? 1 : -1\n }\n const boost = Math.min(8, Math.abs(v) / 80)\n speedTarget = 0.22 + boost\n }\n\n const onScroll = () => {\n const now = performance.now()\n const y = readScrollY()\n const dt = now - lastScrollT\n const v = dt > 0 ? ((y - lastScrollY) / dt) * 1000 : 0\n lastScrollY = y\n lastScrollT = now\n applyWarp(v)\n }\n\n const unbindScroll = () => {\n if (!attached) return\n attached.removeEventListener(\"scroll\", onScroll)\n attached = null\n }\n\n const bindScroll = () => {\n const next: Window | HTMLElement = options.container ?? window\n if (attached === next) return\n unbindScroll()\n attached = next\n lastScrollY = readScrollY()\n lastScrollT = performance.now()\n attached.addEventListener(\"scroll\", onScroll, { passive: true })\n }\n\n bindScroll()\n\n const tick = (now: number) => {\n if (!running) return\n\n const dt = Math.min(0.05, Math.max(0, (now - lastFrame) / 1000))\n lastFrame = now\n\n if (now - lastScrollT > 40 && !reduce) {\n applyWarp(0)\n }\n\n const accel =\n (STIFFNESS * (speedTarget - speed) - DAMPING * speedVel) / MASS\n speedVel += accel * dt\n speed += speedVel * dt\n\n const { w, h } = size\n if (w === 0 || h === 0) {\n raf = requestAnimationFrame(tick)\n return\n }\n\n const cx = w / 2\n const cy = h / 2\n const warp = reduce ? 0.05 : speed\n const focal = Math.max(w, h) * 0.5\n const colors = paletteOf(options.colors)\n const travel = warp * TRAVEL_PER_SEC * displacementOf(options.displacement)\n\n ctx.clearRect(0, 0, w, h)\n\n for (let i = 0; i < stars.length; i++) {\n const star = stars[i]!\n star.z -= dir * travel * dt\n\n if (star.z <= 1) {\n star.z = DEPTH\n star.pz = DEPTH\n star.x = (Math.random() - 0.5) * DEPTH\n star.y = (Math.random() - 0.5) * DEPTH\n star.color = colors[Math.floor(Math.random() * colors.length)]!\n } else if (star.z >= DEPTH) {\n star.z = 1.5\n star.pz = 1.5\n star.x = (Math.random() - 0.5) * DEPTH\n star.y = (Math.random() - 0.5) * DEPTH\n star.color = colors[Math.floor(Math.random() * colors.length)]!\n } else {\n // FPS-independent tail so filled heads keep a visible streak.\n star.pz = Math.min(\n DEPTH,\n Math.max(1.01, star.z + dir * travel * TRAIL_DT)\n )\n }\n\n const k = focal / star.z\n const x = cx + star.x * k\n const y = cy + star.y * k\n\n const pk = focal / star.pz\n const px = cx + star.x * pk\n const py = cy + star.y * pk\n\n if (\n (x < -50 && px < -50) ||\n (x > w + 50 && px > w + 50) ||\n (y < -50 && py < -50) ||\n (y > h + 50 && py > h + 50)\n ) {\n continue\n }\n\n const near = 1 - star.z / DEPTH\n const lineW = Math.max(0.8, near * (1.8 + warp * 0.12))\n const alpha = 0.35 + near * 0.65\n\n ctx.beginPath()\n ctx.moveTo(px, py)\n ctx.lineTo(x, y)\n ctx.strokeStyle = star.color\n ctx.globalAlpha = alpha\n ctx.lineWidth = lineW\n ctx.lineCap = \"round\"\n ctx.stroke()\n\n if (near > 0.45) {\n ctx.beginPath()\n ctx.arc(x, y, lineW * 0.9, 0, Math.PI * 2)\n ctx.fillStyle = star.color\n ctx.fill()\n }\n }\n\n ctx.globalAlpha = 1\n raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n\n return {\n setOptions(next) {\n const prevCount = options.starCount ?? 420\n const prevColors = paletteOf(options.colors).join(\",\")\n options = { ...options, ...next }\n const nextCount = options.starCount ?? 420\n const nextColors = paletteOf(options.colors).join(\",\")\n if (nextCount !== prevCount || nextColors !== prevColors) {\n stars = createStars(nextCount, paletteOf(options.colors))\n }\n bindScroll()\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n unbindScroll()\n mqReduce.removeEventListener(\"change\", onReduce)\n },\n }\n}\n\nexport type RadiantLinesProps = Omit & {\n className?: string\n /**\n * Scroll container. Omit to use the window.\n * Pass a ref when the component lives inside an overflow scroller.\n */\n containerRef?: RefObject\n}\n\n/**\n * Hyperspace starfield — colored streaks radiate from the center.\n * Transparent canvas over `bg-background` (shadcn theme).\n */\nexport function RadiantLines({\n className,\n colors,\n starCount = 420,\n displacement = 1,\n containerRef,\n}: RadiantLinesProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createRadiantLines(canvas, {\n colors,\n starCount,\n displacement,\n container: containerRef?.current ?? null,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n colors,\n starCount,\n displacement,\n container: containerRef?.current ?? null,\n })\n }, [colors, starCount, displacement, containerRef])\n\n return (\n \n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/radiant-lines.tsx" } diff --git a/registry/radiant-lines/radiant-lines-demo.tsx b/registry/radiant-lines/radiant-lines-demo.tsx index 899dd20..c7be8b8 100644 --- a/registry/radiant-lines/radiant-lines-demo.tsx +++ b/registry/radiant-lines/radiant-lines-demo.tsx @@ -13,6 +13,7 @@ import { RadiantLines } from "@/registry/radiant-lines/radiant-lines" const DEFAULTS = { starCount: 420, + displacement: 1, colors: [ "#FF6B4A", "#2DD4BF", @@ -55,12 +56,13 @@ export function RadiantLinesDemo() { >
@@ -94,6 +96,7 @@ export function RadiantLinesDemo() { component="RadiantLines" snippetProps={{ starCount: props.starCount, + displacement: props.displacement, colors: props.colors, }} > @@ -140,6 +143,14 @@ export function RadiantLinesDemo() { step={20} onChange={(v) => updateProp("starCount", v)} /> + updateProp("displacement", v)} + /> ) diff --git a/registry/radiant-lines/radiant-lines-vanilla.ts b/registry/radiant-lines/radiant-lines-vanilla.ts index c19065b..aac88b7 100644 --- a/registry/radiant-lines/radiant-lines-vanilla.ts +++ b/registry/radiant-lines/radiant-lines-vanilla.ts @@ -3,6 +3,12 @@ export type RadiantLinesOptions = { colors?: string[] /** How many stars. Default 420 */ starCount?: number + /** + * How far each star jumps when warping (idle drift and scroll). + * `1` matches the original ~60fps travel; higher values lengthen streaks. + * Default `1`. + */ + displacement?: number /** * Scroll container. Omit / `null` to use the window. * Pass the overflow scroller when the component lives inside one. @@ -29,6 +35,10 @@ const DEPTH = 1000 const STIFFNESS = 70 const DAMPING = 32 const MASS = 0.5 +/** Original per-frame coefficient (`2.4`) converted to per-second at 60fps. */ +const TRAVEL_PER_SEC = 2.4 * 60 +/** Draw streaks as a 60fps step so filled heads keep a visible tail at any fps. */ +const TRAIL_DT = 1 / 60 type Star = { x: number @@ -42,6 +52,11 @@ function paletteOf(colors?: string[]) { return colors && colors.length > 0 ? colors : DEFAULT_COLORS } +function displacementOf(value?: number) { + if (typeof value !== "number" || !Number.isFinite(value)) return 1 + return Math.max(0, value) +} + function createStars(count: number, colors: string[]): Star[] { const stars: Star[] = [] for (let i = 0; i < count; i++) { @@ -60,7 +75,8 @@ function createStars(count: number, colors: string[]): Star[] { /** * Hyperspace starfield — colored streaks radiate from the center. * Transparent canvas over `bg-background` (shadcn theme). Warp speed follows - * scroll velocity (down → inward, up → outward). + * scroll velocity (down → inward, up → outward). `displacement` scales how + * far each star travels (and how long the filled heads' streaks get). */ export function createRadiantLines( canvas: HTMLCanvasElement, @@ -69,6 +85,7 @@ export function createRadiantLines( let options: RadiantLinesOptions = { starCount: 420, colors: DEFAULT_COLORS, + displacement: 1, container: null, ...initial, } @@ -177,7 +194,8 @@ export function createRadiantLines( applyWarp(0) } - const accel = (STIFFNESS * (speedTarget - speed) - DAMPING * speedVel) / MASS + const accel = + (STIFFNESS * (speedTarget - speed) - DAMPING * speedVel) / MASS speedVel += accel * dt speed += speedVel * dt @@ -192,13 +210,13 @@ export function createRadiantLines( const warp = reduce ? 0.05 : speed const focal = Math.max(w, h) * 0.5 const colors = paletteOf(options.colors) + const travel = warp * TRAVEL_PER_SEC * displacementOf(options.displacement) ctx.clearRect(0, 0, w, h) for (let i = 0; i < stars.length; i++) { const star = stars[i]! - star.pz = star.z - star.z -= dir * warp * 2.4 * dt + star.z -= dir * travel * dt if (star.z <= 1) { star.z = DEPTH @@ -212,6 +230,12 @@ export function createRadiantLines( star.x = (Math.random() - 0.5) * DEPTH star.y = (Math.random() - 0.5) * DEPTH star.color = colors[Math.floor(Math.random() * colors.length)]! + } else { + // FPS-independent tail so filled heads keep a visible streak. + star.pz = Math.min( + DEPTH, + Math.max(1.01, star.z + dir * travel * TRAIL_DT) + ) } const k = focal / star.z diff --git a/registry/radiant-lines/radiant-lines.svelte b/registry/radiant-lines/radiant-lines.svelte index 00b52e6..3ca0525 100644 --- a/registry/radiant-lines/radiant-lines.svelte +++ b/registry/radiant-lines/radiant-lines.svelte @@ -23,6 +23,7 @@ class: className = "", colors = DEFAULT_COLORS, starCount = 420, + displacement = 1, container, }: Props = $props() @@ -34,6 +35,7 @@ instance = createRadiantLines(canvas, { colors, starCount, + displacement, container: container ?? null, }) return () => { @@ -46,6 +48,7 @@ instance?.setOptions({ colors, starCount, + displacement, container: container ?? null, }) }) diff --git a/registry/radiant-lines/radiant-lines.tsx b/registry/radiant-lines/radiant-lines.tsx index 598c706..f407bf9 100644 --- a/registry/radiant-lines/radiant-lines.tsx +++ b/registry/radiant-lines/radiant-lines.tsx @@ -33,6 +33,7 @@ export function RadiantLines({ className, colors, starCount = 420, + displacement = 1, containerRef, }: RadiantLinesProps) { const canvasRef = useRef(null) @@ -44,6 +45,7 @@ export function RadiantLines({ instanceRef.current = createRadiantLines(canvas, { colors, starCount, + displacement, container: containerRef?.current ?? null, }) return () => { @@ -58,9 +60,10 @@ export function RadiantLines({ instanceRef.current?.setOptions({ colors, starCount, + displacement, container: containerRef?.current ?? null, }) - }, [colors, starCount, containerRef]) + }, [colors, starCount, displacement, containerRef]) return (