星座粒子场

粒子在画布上缓慢漂移,当指针经过附近时,粒子之间会连出细线。

  • Canvas
  • 背景
  • 跟随指针
  • 收录于
把指针移到这里

AI 提示词

用 React <canvas> 制作一个约 60 个漂移粒子的背景,粒子在指针附近互相连线。用 ResizeObserver 让画布跟随父元素尺寸,并按 devicePixelRatio 缩放。每个粒子有随机位置和每帧不超过 0.3px 的速度,碰到边缘时从另一侧出现。每次 requestAnimationFrame 时,把粒子画成 1.5px 的圆点;对于两个粒子都在指针 120px 范围内的每一对,画一条随距离变淡的线。点和线的颜色从 CSS 自定义属性读取,以跟随主题。画布离开屏幕时暂停循环(IntersectionObserver),在 prefers-reduced-motion 下只绘制一帧静态画面。在上方叠加文字“把指针移到这里”。

源代码

Demo.tsx
'use client'

import { useEffect, useRef } from 'react'
import s from './demo.module.css'

const COUNT = 60
const LINK = 120

export default function ParticleField({ labels }: { labels: Record<string, string> }) {
  const ref = useRef<HTMLCanvasElement>(null)

  useEffect(() => {
    const canvas = ref.current!
    const ctx = canvas.getContext('2d')!
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
    const css = getComputedStyle(canvas)
    const dot = css.getPropertyValue('--dot').trim()
    const line = css.getPropertyValue('--line').trim()
    let w = 0
    let h = 0
    let raf = 0
    let visible = true
    const pointer = { x: -999, y: -999 }
    const pts = Array.from({ length: COUNT }, () => ({
      x: Math.random(),
      y: Math.random(),
      vx: (Math.random() - 0.5) * 0.6,
      vy: (Math.random() - 0.5) * 0.6,
    }))

    const resize = () => {
      const dpr = window.devicePixelRatio || 1
      w = canvas.clientWidth
      h = canvas.clientHeight
      canvas.width = w * dpr
      canvas.height = h * dpr
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
      // positions start as 0–1 fractions; convert once we know the size
      for (const p of pts) if (p.x <= 1 && p.y <= 1) Object.assign(p, { x: p.x * w, y: p.y * h })
      if (reduced) draw()
    }

    const draw = () => {
      ctx.clearRect(0, 0, w, h)
      const near = pts.filter((p) => Math.hypot(p.x - pointer.x, p.y - pointer.y) < LINK)
      ctx.strokeStyle = line
      for (let i = 0; i < near.length; i++)
        for (let j = i + 1; j < near.length; j++) {
          const d = Math.hypot(near[i].x - near[j].x, near[i].y - near[j].y)
          ctx.globalAlpha = Math.max(0, 1 - d / LINK)
          ctx.beginPath()
          ctx.moveTo(near[i].x, near[i].y)
          ctx.lineTo(near[j].x, near[j].y)
          ctx.stroke()
        }
      ctx.globalAlpha = 1
      ctx.fillStyle = dot
      for (const p of pts) {
        ctx.beginPath()
        ctx.arc(p.x, p.y, 1.5, 0, Math.PI * 2)
        ctx.fill()
      }
    }

    const loop = () => {
      for (const p of pts) {
        p.x = (p.x + p.vx + w) % w
        p.y = (p.y + p.vy + h) % h
      }
      draw()
      if (visible) raf = requestAnimationFrame(loop)
    }

    const onMove = (e: PointerEvent) => {
      const r = canvas.getBoundingClientRect()
      pointer.x = e.clientX - r.left
      pointer.y = e.clientY - r.top
      if (reduced) draw()
    }
    const onLeave = () => Object.assign(pointer, { x: -999, y: -999 })

    const ro = new ResizeObserver(resize)
    ro.observe(canvas)
    const io = new IntersectionObserver(([e]) => {
      visible = e.isIntersecting
      cancelAnimationFrame(raf)
      if (visible && !reduced) raf = requestAnimationFrame(loop)
    })
    io.observe(canvas)
    canvas.addEventListener('pointermove', onMove)
    canvas.addEventListener('pointerleave', onLeave)

    return () => {
      cancelAnimationFrame(raf)
      ro.disconnect()
      io.disconnect()
      canvas.removeEventListener('pointermove', onMove)
      canvas.removeEventListener('pointerleave', onLeave)
    }
  }, [])

  return (
    <div className={s.wrap}>
      <canvas ref={ref} className={s.canvas} aria-hidden="true" />
      <span className={s.caption}>{labels.caption}</span>
    </div>
  )
}
demo.module.css
.wrap {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  background: var(--stage-bg);
}

.canvas {
  --dot: var(--stage-ink);
  --line: var(--stage-accent);
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

.caption {
  position: relative;
  font: 500 var(--text-xs) / 1 var(--font-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--stage-muted);
  pointer-events: none;
}

投稿创意

看到想收录的动效或主题?简单描述一下,会打开一个预先填好的 GitHub Issue,确认后即可提交。

你想投稿的是?

需要 GitHub 账号,投稿内容会公开。