响应式点阵

点阵会在指针附近膨胀并被推开,像手掌下的一片针。

  • Canvas
  • 背景
  • 跟随指针
  • 收录于
在点阵上移动指针

AI 提示词

用 React <canvas> 制作一个会响应指针的点阵背景。用 ResizeObserver 和 devicePixelRatio 让画布跟随父元素尺寸。每隔 18px 排一个点。每一帧中,计算每个点到指针的距离 d;在 90px 以内时,沿该方向把点推开 (1 − d/90)² × 10px,半径从 1.2px 增大到 3px,颜色从灰色过渡到强调色。用 0.18 的 lerp 让每个点平滑地靠近目标位置,指针离开时也能自然回位。颜色从 CSS 自定义属性读取。画布离开屏幕时停止 requestAnimationFrame 循环(IntersectionObserver)。在 prefers-reduced-motion 下绘制静态点阵,只高亮最近的点,不做位移。上方叠加文字“在点阵上移动指针”。

源代码

Demo.tsx
'use client'

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

const GAP = 18
const REACH = 90

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

  useEffect(() => {
    const canvas = ref.current!
    const ctx = canvas.getContext('2d')!
    const css = getComputedStyle(canvas)
    const muted = css.getPropertyValue('--dot').trim()
    const hot = css.getPropertyValue('--hot').trim()
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
    const pointer = { x: -999, y: -999 }
    let dots: { hx: number; hy: number; x: number; y: number }[] = []
    let w = 0
    let h = 0
    let raf = 0
    let visible = true

    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)
      dots = []
      for (let y = GAP / 2; y < h; y += GAP) for (let x = GAP / 2; x < w; x += GAP) dots.push({ hx: x, hy: y, x, y })
      draw()
    }

    const draw = () => {
      ctx.clearRect(0, 0, w, h)
      for (const d of dots) {
        const dx = d.hx - pointer.x
        const dy = d.hy - pointer.y
        const dist = Math.hypot(dx, dy)
        const k = dist < REACH ? (1 - dist / REACH) ** 2 : 0
        const tx = reduced ? d.hx : d.hx + (dx / (dist || 1)) * k * 10
        const ty = reduced ? d.hy : d.hy + (dy / (dist || 1)) * k * 10
        d.x += (tx - d.x) * 0.18
        d.y += (ty - d.y) * 0.18
        ctx.fillStyle = k > 0.15 ? hot : muted
        ctx.beginPath()
        ctx.arc(d.x, d.y, 1.2 + k * 1.8, 0, Math.PI * 2)
        ctx.fill()
      }
    }

    const loop = () => {
      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
    }
    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) 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: end center;
  padding-bottom: 14px;
  background: var(--stage-bg);
}

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

.caption {
  position: relative;
  padding: 4px 8px;
  border-radius: 6px;
  background: var(--stage-bg);
  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 账号,投稿内容会公开。