Spider WebAn organic silk web that breathes with a slow idle bow.
BackgroundsCanvasInteractive
Switch to light
Refresh
Full screen
'use client'
import { useLayoutEffect, useEffect, useRef, useState } from 'react'
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
// ─── Config ───────────────────────────────────────────────────────────────────
const N_RADIALS = 24
const N_RINGS = 14
// Idle organic sway (slow, broad)
const SWAY_AMP = 3.5 // px — node drift amplitude
const SWAY_SPD = 0.00045 // time step per frame
// Idle strand bow — curves every string even at rest
const BOW_AMP = 6 // px — perpendicular bow amplitude
const BOW_SPD = 0.55 // how fast the bow oscillates per strand
// Node spring physics — cursor pushes nodes, they bounce back
const NODE_PUSH_R = 90 // px — cursor repels nodes within this radius
const NODE_PUSH_STR = 3.2 // push impulse strength
const SPRING_K = 0.07 // spring return stiffness
const SPRING_DAMP = 0.84 // velocity retention per frame (lower = more bouncy)
// Hover bezier push on strands (very subtle — let node movement do the heavy work)
const PUSH_RADIUS = 280 // px
const PUSH_MAX = 18 // px — gentle, not dramatic
// ─── Types ────────────────────────────────────────────────────────────────────
interface WebNode {
bx: number; by: number
phase: number // personal sway phase
// spring state — offset from sway position
sx: number; sy: number
svx: number; svy: number
}
interface Strand {
a: number; b: number
kind: 'radial' | 'ring'
ring: number
// precomputed for idle bow
bowPhase: number // avg of both node phases
bowPerpX: number // unit perpendicular X (rotated 90° from strand direction)
bowPerpY: number // unit perpendicular Y
}
// ─── Build geometry ───────────────────────────────────────────────────────────
function buildWeb(W: number, H: number): { nodes: WebNode[]; strands: Strand[] } {
const cx = W / 2
const cy = H / 2
const maxR = Math.sqrt(W * W + H * H) * 0.56
const nodes: WebNode[] = []
// Node 0 = center
nodes.push({ bx: cx, by: cy, phase: 0, sx: 0, sy: 0, svx: 0, svy: 0 })
for (let r = 1; r <= N_RINGS; r++) {
const baseR = maxR * (r / N_RINGS)
for (let s = 0; s < N_RADIALS; s++) {
const angle = s * (2 * Math.PI / N_RADIALS) - Math.PI / 2
const jitter = 1 + (Math.random() - 0.5) * 0.18
nodes.push({
bx: cx + Math.cos(angle) * baseR * jitter,
by: cy + Math.sin(angle) * baseR * jitter,
phase: Math.random() * Math.PI * 2,
sx: 0, sy: 0, svx: 0, svy: 0,
})
}
}
const strands: Strand[] = []
function addStrand(a: number, b: number, kind: 'radial' | 'ring', ring: number) {
const na = nodes[a], nb = nodes[b]
const dx = nb.bx - na.bx, dy = nb.by - na.by
const len = Math.sqrt(dx * dx + dy * dy) || 1
// Perpendicular: rotate strand direction 90°
strands.push({
a, b, kind, ring,
bowPhase: (na.phase + nb.phase) / 2,
bowPerpX: -dy / len,
bowPerpY: dx / len,
})
}
// Radial strands
for (let s = 0; s < N_RADIALS; s++) {
addStrand(0, 1 + s, 'radial', 1)
for (let r = 1; r < N_RINGS; r++) {
const ai = 1 + (r - 1) * N_RADIALS + s
const bi = 1 + r * N_RADIALS + s
addStrand(ai, bi, 'radial', r + 1)
}
}
// Ring strands
for (let r = 1; r <= N_RINGS; r++) {
for (let s = 0; s < N_RADIALS; s++) {
const ai = 1 + (r - 1) * N_RADIALS + s
const bi = 1 + (r - 1) * N_RADIALS + (s + 1) % N_RADIALS
addStrand(ai, bi, 'ring', r)
}
}
return { nodes, strands }
}
// ─── Theme ────────────────────────────────────────────────────────────────────
function detectDark(el: HTMLElement): boolean {
const w = el.closest('[data-card-theme]')
if (w) return w.classList.contains('dark')
return document.documentElement.classList.contains('dark')
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function ParticleConstellation() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const mouseRef = useRef<{ x: number; y: number } | null>(null)
const webRef = useRef<{ nodes: WebNode[]; strands: Strand[] } | null>(null)
const rafRef = useRef<number>(0)
const [isDark, setIsDark] = useState(() => typeof window !== 'undefined' ? document.documentElement.classList.contains('dark') : false)
const isDarkRef = useRef(typeof window !== 'undefined' ? document.documentElement.classList.contains('dark') : false)
// ── Theme ──────────────────────────────────────────────────────────────────
useIsomorphicLayoutEffect(() => {
const el = containerRef.current
if (!el) return
const check = () => { const d = detectDark(el); setIsDark(d); isDarkRef.current = d }
check()
const mo = new MutationObserver(check)
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
const w = el.closest('[data-card-theme]')
if (w) mo.observe(w, { attributes: true, attributeFilter: ['class'] })
return () => mo.disconnect()
}, [])
// ── Main loop ──────────────────────────────────────────────────────────────
useIsomorphicLayoutEffect(() => {
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container) return
const ctx = canvas.getContext('2d')
if (!ctx) return
let alive = true
let time = 0
function resize() {
if (!canvas || !container) return
const dpr = window.devicePixelRatio || 1
const w = container.clientWidth
const h = container.clientHeight
canvas.width = w * dpr
canvas.height = h * dpr
canvas.style.width = `${w}px`
canvas.style.height = `${h}px`
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)
webRef.current = buildWeb(w, h)
}
resize()
const ro = new ResizeObserver(() => { if (alive) resize() })
ro.observe(container)
const mo = new MutationObserver(() => {
if (alive && container) isDarkRef.current = detectDark(container)
})
const wrapper = container.closest('[data-card-theme]')
if (wrapper) mo.observe(wrapper, { attributes: true, attributeFilter: ['class'] })
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
function tick() {
if (!alive || !canvas || !ctx) return
rafRef.current = requestAnimationFrame(tick)
time += SWAY_SPD
const mouse = mouseRef.current
const web = webRef.current
if (!web) return
const W = container!.clientWidth
const H = container!.clientHeight
// ── Update node spring physics ───────────────────────────────────────
for (const n of web.nodes) {
// Sway position (slow broad drift)
const swayX = n.bx + Math.sin(time * 1.1 + n.phase) * SWAY_AMP
const swayY = n.by + Math.cos(time * 0.9 + n.phase * 1.4) * SWAY_AMP
// Cursor repulsion impulse on the spring
if (mouse) {
const liveX = swayX + n.sx
const liveY = swayY + n.sy
const dx = liveX - mouse.x
const dy = liveY - mouse.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < NODE_PUSH_R && dist > 0.1) {
const force = (1 - dist / NODE_PUSH_R) * NODE_PUSH_STR
n.svx += (dx / dist) * force
n.svy += (dy / dist) * force
}
}
// Spring return toward zero offset + damping
n.svx += -n.sx * SPRING_K
n.svy += -n.sy * SPRING_K
n.svx *= SPRING_DAMP
n.svy *= SPRING_DAMP
n.sx += n.svx
n.sy += n.svy
}
// ── Compute live positions ───────────────────────────────────────────
const pos = web.nodes.map(n => ({
x: n.bx + Math.sin(time * 1.1 + n.phase) * SWAY_AMP + n.sx,
y: n.by + Math.cos(time * 0.9 + n.phase * 1.4) * SWAY_AMP + n.sy,
}))
// ── Draw ─────────────────────────────────────────────────────────────
const dark = isDarkRef.current
const fg = dark ? '255,255,255' : '28,25,22'
ctx.clearRect(0, 0, W, H)
ctx.fillStyle = dark ? '#110F0C' : '#F5F1EA'
ctx.fillRect(0, 0, W, H)
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
for (const s of web.strands) {
const pa = pos[s.a]
const pb = pos[s.b]
const mx = (pa.x + pb.x) / 2
const my = (pa.y + pb.y) / 2
// ── Always-on idle bow — never a straight line ─────────────────
const bow = BOW_AMP * Math.sin(time * BOW_SPD * 60 + s.bowPhase)
let cpx = mx + s.bowPerpX * bow
let cpy = my + s.bowPerpY * bow
// ── Subtle additional push on hover (small, additive) ──────────
if (mouse) {
const cdx = mouse.x - mx
const cdy = mouse.y - my
const cdist2 = cdx * cdx + cdy * cdy
const cdist = Math.sqrt(cdist2)
const falloff = Math.exp(-cdist2 / (PUSH_RADIUS * PUSH_RADIUS))
const bend = PUSH_MAX * falloff
if (cdist > 0.1) {
cpx -= (cdx / cdist) * bend
cpy -= (cdy / cdist) * bend
}
}
// ── Opacity ────────────────────────────────────────────────────
const depthFade = 1 - (s.ring - 1) / (N_RINGS + 2)
let alpha = s.kind === 'radial' ? 0.42 * depthFade : 0.20 * depthFade
if (mouse) {
const cdx = mouse.x - mx, cdy = mouse.y - my
const proximity = Math.exp(-(cdx * cdx + cdy * cdy) / (PUSH_RADIUS * PUSH_RADIUS * 0.35))
alpha = alpha * (1 - proximity * 0.82)
}
ctx.strokeStyle = `rgba(${fg},${alpha.toFixed(3)})`
ctx.lineWidth = s.kind === 'radial' ? 0.75 : 0.5
ctx.beginPath()
ctx.moveTo(pa.x, pa.y)
ctx.quadraticCurveTo(cpx, cpy, pb.x, pb.y)
ctx.stroke()
}
// ── Nodes ───────────────────────────────────────────────────────────
for (let i = 0; i < pos.length; i++) {
const p = pos[i]
let alpha = i === 0 ? 0.45 : 0.16
if (mouse) {
const dx = mouse.x - p.x, dy = mouse.y - p.y
const prox = Math.exp(-(dx * dx + dy * dy) / (160 * 160))
alpha = alpha * (1 - prox * 0.82)
}
ctx.beginPath()
ctx.arc(p.x, p.y, i === 0 ? 1.8 : 1.1, 0, Math.PI * 2)
ctx.fillStyle = `rgba(${fg},${alpha.toFixed(3)})`
ctx.fill()
}
}
rafRef.current = requestAnimationFrame(tick)
function onMove(e: MouseEvent) {
const rect = canvas!.getBoundingClientRect()
mouseRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top }
}
function onLeave() { mouseRef.current = null }
container.addEventListener('mousemove', onMove)
container.addEventListener('mouseleave', onLeave)
return () => {
alive = false
cancelAnimationFrame(rafRef.current)
ro.disconnect()
mo.disconnect()
container.removeEventListener('mousemove', onMove)
container.removeEventListener('mouseleave', onLeave)
}
}, [])
return (
<div
ref={containerRef}
className="relative min-h-screen w-full overflow-hidden"
style={{ background: isDark ? '#110F0C' : '#F5F1EA' }}
>
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
</div>
)
}
Add to your project
One command adds this component to your project.
1
Run the following command. New project? Run npx shadcn@latest init first to set up Tailwind and path aliases.
npx shadcn@latest add @aicanvas/particle-constellation2
For dark mode, add the dark class to your <html> element:
<html class="dark">

