anima.js
ギャラリー
背景

DotGridBackground

Canvas

ポインターに反応するドットマトリクス背景 — 近くのドットがふくらみ、アクセント色に光り、ばねで押しのけられる。流れるウェーブとクリックの波紋付き。Canvas 2D で描画し、画面外や静止中は停止。

Interactive Canvas

ポインタに、反応する背景。

カーソルを動かすと、ドットがふくらみ、光り、そっと道をあける。クリックで波紋が広がります。

AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約34 KB)。
# DotGridBackground をこのプロジェクトに追加してください

このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。

## コンポーネントについて
- 名前: DotGridBackground
- 説明: ポインターに反応するドットマトリクス背景 — 近くのドットがふくらみ、アクセント色に光り、ばねで押しのけられる。流れるウェーブとクリックの波紋付き。Canvas 2D で描画し、画面外や静止中は停止。
- 構成ファイル: `components/dot-grid-background/index.tsx`, `components/dot-grid-background/DotGridBackground.module.css`

## 前提と注意
- React 19 / TypeScript / Next.js(App Router)のプロジェクトを想定しています。Next.js 以外の React プロジェクト(Vite など)でもそのまま動きます。
- スタイルは同梱の CSS Modules(ある場合)で自己完結しており、Tailwind CSS の有無や設定には依存しません。Tailwind の設定変更やグローバル CSS の追加は、この依頼文に明記された分だけにしてください。
- ブラウザ API やインタラクションを使うクライアントコンポーネントです。ファイル先頭の "use client" を維持してください。
- コンポーネント自体の背景は透過です。配置先ページの背景がそのまま透けて見える前提で作られているので、白背景や単色の `div` などで囲わず、コンポーネントだけをそのまま配置してください。
- ソースコードの内容は変更しないでください。例外は、`@/` パスエイリアスが無いプロジェクトで import パスを相対パスに直す場合だけです。

## 手順
### 1. 依存パッケージを追加する
追加の npm パッケージは不要です(React のみ)。

### 2. ファイルを作成する
`@/components` が指すディレクトリ(多くは `src/components/` か `components/`。shadcn の `components.json` があれば `aliases.components` の指す場所)に、以下の 2 ファイルを**一字一句そのまま**作成してください。

#### `components/dot-grid-background/index.tsx`
```tsx
"use client";

import {
  useEffect,
  useRef,
  type CSSProperties,
  type ReactNode,
} from "react";
import styles from "./DotGridBackground.module.css";

/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;

export type DotGridBackgroundProps = {
  /** Dot diameter at rest, in px. */
  dotSize?: number;
  /** Distance between dot centers, in px. */
  gap?: number;
  /** Resting dot color. Drawn at low alpha, so a light gray reads as dim. */
  color?: string;
  /** Color dots brighten toward near the pointer, in ripples and wave crests. */
  accentColor?: string;
  /** Pointer influence radius, in px. */
  radius?: number;
  /** How far dots under the pointer are pushed away, in px. 0 = no push. */
  push?: number;
  /** Slow traveling band of brightness, so the grid is alive without a pointer. */
  wave?: boolean;
  /** Wave speed multiplier. */
  waveSpeed?: number;
  /** A ring that propagates outward from every click / tap. */
  ripple?: boolean;
  /** Fade the grid out toward the edges so it melts into the page. */
  fade?: boolean;
  className?: string;
  /** Hero content rendered above the grid. */
  children?: ReactNode;
};

const DEFAULT_DOT_SIZE = 2;
const DEFAULT_GAP = 22;
const DEFAULT_COLOR = "#a1a1b5";
const DEFAULT_ACCENT = "#a78bfa";
const DEFAULT_RADIUS = 160;
const DEFAULT_PUSH = 12;
const DEFAULT_WAVE_SPEED = 1;

/** Higher DPRs cost fill-rate without a visible gain on 2px dots. */
const MAX_DPR = 2;
const TAU = Math.PI * 2;

/** Resting alpha of a dot before any pointer / wave / glow lifts it. */
const BASE_ALPHA = 0.3;

/*
 * Dots are drawn in buckets of identical fill (color level × alpha level):
 * one path + one fill() per bucket instead of a fill per dot, which is what
 * keeps a full-screen grid of several thousand dots cheap. 12 × 20 levels
 * is below what's distinguishable on 2px dots.
 */
const COLOR_LEVELS = 12;
const ALPHA_LEVELS = 20;
const BUCKETS = COLOR_LEVELS * ALPHA_LEVELS;
/** Bucket index for dots that end up invisible — never drawn. */
const SKIP = BUCKETS;

/* Ripple ring: expansion speed (px/ms), ring thickness (px), lifetime (ms). */
const RIPPLE_SPEED = 0.55;
const RIPPLE_WIDTH = 46;
const RIPPLE_LIFE = 1700;
const MAX_RIPPLES = 6;

/* Spring that returns pushed dots to rest (per 60fps frame). */
const STIFFNESS = 0.12;
const DAMPING = 0.78;

type Config = {
  dotSize: number;
  gap: number;
  color: string;
  accentColor: string;
  radius: number;
  push: number;
  wave: boolean;
  waveSpeed: number;
  ripple: boolean;
  fade: boolean;
};

type Ripple = { x: number; y: number; age: number };

type Engine = {
  setConfig: (config: Config) => void;
  destroy: () => void;
};

const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);

/**
 * Resolves any CSS color to [r, g, b] by letting the 2D context normalize
 * it — avoids shipping a color parser. Unparseable input keeps `fallback`.
 */
function parseColor(
  ctx: CanvasRenderingContext2D,
  input: string,
  fallback: string,
): [number, number, number] {
  ctx.fillStyle = fallback;
  ctx.fillStyle = input;
  const v = String(ctx.fillStyle);
  if (v.startsWith("#")) {
    return [
      parseInt(v.slice(1, 3), 16),
      parseInt(v.slice(3, 5), 16),
      parseInt(v.slice(5, 7), 16),
    ];
  }
  const m = v.match(/[\d.]+/g);
  return m ? [+m[0], +m[1], +m[2]] : [255, 255, 255];
}

/**
 * The imperative canvas loop. Lives outside React: per-frame state stays in
 * typed arrays and closures, so pointer movement never re-renders anything.
 */
function createEngine(
  root: HTMLDivElement,
  canvas: HTMLCanvasElement,
  initial: Config,
): Engine | null {
  const maybeCtx = canvas.getContext("2d");
  if (!maybeCtx) return null;
  const ctx: CanvasRenderingContext2D = maybeCtx;

  let cfg = initial;
  let width = 0;
  let height = 0;
  let dpr = 1;

  // Grid state — rest position, spring offset/velocity, static edge fade.
  let count = 0;
  let x0 = new Float32Array(0);
  let y0 = new Float32Array(0);
  let ox = new Float32Array(0);
  let oy = new Float32Array(0);
  let vx = new Float32Array(0);
  let vy = new Float32Array(0);
  let edge = new Float32Array(0);
  let glow = new Float32Array(0);
  let size = new Float32Array(0);
  let bucket = new Uint16Array(0);
  let order = new Uint32Array(0);
  const starts = new Uint32Array(BUCKETS + 2);

  let palette: string[] = [];

  // Pointer in the root's own (unscaled) CSS px.
  let px = 0;
  let py = 0;
  let pointerActive = false;
  let strength = 0;
  let ripples: Ripple[] = [];
  // Largest spring error / velocity of the last step — 0 once settled.
  let motion = 0;

  // Wave clock in ms, advanced only while frames run so it never jumps
  // after the grid was offscreen. Seeded mid-cycle for a lively first paint.
  let clock = 4000;
  let raf = 0;
  let last = 0;
  let visible = true;
  const mq =
    typeof matchMedia !== "undefined"
      ? matchMedia("(prefers-reduced-motion: reduce)")
      : null;
  let reduced = !!mq?.matches;

  function buildPalette() {
    const [br, bg, bb] = parseColor(ctx, cfg.color, DEFAULT_COLOR);
    const [ar, ag, ab] = parseColor(ctx, cfg.accentColor, DEFAULT_ACCENT);
    palette = new Array(BUCKETS);
    for (let c = 0; c < COLOR_LEVELS; c++) {
      const t = c / (COLOR_LEVELS - 1);
      const r = Math.round(br + (ar - br) * t);
      const g = Math.round(bg + (ag - bg) * t);
      const b = Math.round(bb + (ab - bb) * t);
      for (let a = 0; a < ALPHA_LEVELS; a++) {
        palette[c * ALPHA_LEVELS + a] =
          `rgba(${r},${g},${b},${((a + 1) / ALPHA_LEVELS).toFixed(3)})`;
      }
    }
  }

  function buildGrid() {
    const gap = Math.max(4, cfg.gap);
    // One extra row/column so the grid bleeds past every edge instead of
    // leaving a margin on the sides that don't divide evenly.
    const cols = Math.floor(width / gap) + 2;
    const rows = Math.floor(height / gap) + 2;
    const startX = (width - (cols - 1) * gap) / 2;
    const startY = (height - (rows - 1) * gap) / 2;
    count = cols * rows;
    x0 = new Float32Array(count);
    y0 = new Float32Array(count);
    ox = new Float32Array(count);
    oy = new Float32Array(count);
    vx = new Float32Array(count);
    vy = new Float32Array(count);
    edge = new Float32Array(count);
    glow = new Float32Array(count);
    size = new Float32Array(count);
    bucket = new Uint16Array(count);
    order = new Uint32Array(count);

    const cx = width / 2;
    const cy = height / 2;
    let i = 0;
    for (let r = 0; r < rows; r++) {
      for (let c = 0; c < cols; c++) {
        const x = startX + c * gap;
        const y = startY + r * gap;
        x0[i] = x;
        y0[i] = y;
        // Elliptical distance from the center, 1 at the edge midpoints.
        const ex = (x - cx) / (cx || 1);
        const ey = (y - cy) / (cy || 1);
        const e = Math.sqrt(ex * ex + ey * ey);
        // Edge fade: full until ~half way out, gone just past the corners'
        // inscribed ellipse — the headline area stays crisp.
        const s = clamp01((e - 0.45) / 0.65);
        edge[i] = cfg.fade ? 1 - s * s * (3 - 2 * s) : 1;
        // Static center glow, used only for the reduced-motion still: it
        // replaces the wave's brightness variation with a gentle vignette.
        const g = clamp01(1 - e / 0.9);
        glow[i] = g * g;
        i++;
      }
    }
  }

  function resize() {
    width = root.clientWidth;
    height = root.clientHeight;
    dpr = Math.min(MAX_DPR, window.devicePixelRatio || 1);
    canvas.width = Math.max(1, Math.round(width * dpr));
    canvas.height = Math.max(1, Math.round(height * dpr));
    buildGrid();
    // Resizing clears the bitmap; repaint synchronously so there's no
    // blank frame while the loop spins back up.
    step(0);
    paint();
    kick();
  }

  /** Advances physics by `f` 60fps-frames and writes size + bucket per dot. */
  function step(f: number) {
    const interactive = !reduced;
    strength += ((interactive && pointerActive ? 1 : 0) - strength) *
      Math.min(1, 0.14 * f);
    if (strength < 0.001) strength = 0;

    const R = Math.max(1, cfg.radius);
    const R2 = R * R;
    const push = cfg.push;
    const P = strength;
    const useWave = cfg.wave && interactive;
    const useGlow = reduced;
    const baseR = Math.max(0.25, cfg.dotSize / 2);

    // Ripple ring radius / amplitude for this frame.
    const rings: { x: number; y: number; r: number; amp: number }[] = [];
    if (interactive) {
      for (const rp of ripples) {
        const life = rp.age / RIPPLE_LIFE;
        rings.push({
          x: rp.x,
          y: rp.y,
          r: rp.age * RIPPLE_SPEED,
          amp: Math.pow(1 - life, 1.6),
        });
      }
    }

    // Diagonal wave: a sharp traveling crest plus a slow cross-swell so the
    // pattern never reads as a single repeating stripe.
    const k1 = TAU / 560;
    const k2 = TAU / 900;
    const ca = Math.cos(0.55);
    const sa = Math.sin(0.55);
    const t1 = clock * 0.0011;
    const t2 = clock * 0.0006;

    const springK = Math.min(1, STIFFNESS * f);
    const damp = Math.pow(DAMPING, f);
    let maxMotion = 0;

    for (let i = 0; i < count; i++) {
      const x = x0[i];
      const y = y0[i];
      let hot = 0;
      let tx = 0;
      let ty = 0;

      if (P > 0) {
        const dx = x - px;
        const dy = y - py;
        const d2 = dx * dx + dy * dy;
        if (d2 < R2) {
          const d = Math.sqrt(d2);
          let s = 1 - d / R;
          s = s * s * (3 - 2 * s) * P;
          hot = s;
          if (d > 0.001) {
            tx += (dx / d) * s * push;
            ty += (dy / d) * s * push;
          }
        }
      }

      for (let j = 0; j < rings.length; j++) {
        const rg = rings[j];
        const dx = x - rg.x;
        const dy = y - rg.y;
        const d = Math.sqrt(dx * dx + dy * dy);
        const k = (d - rg.r) / RIPPLE_WIDTH;
        if (k > -3 && k < 3) {
          const c = Math.exp(-k * k) * rg.amp;
          if (c * 0.9 > hot) hot = c * 0.9;
          if (d > 0.001) {
            tx += (dx / d) * c * push * 0.5;
            ty += (dy / d) * c * push * 0.5;
          }
        }
      }

      let wv = 0;
      if (useWave) {
        const s1 = 0.5 + 0.5 * Math.sin((x * ca + y * sa) * k1 - t1);
        const s2 = 0.5 + 0.5 * Math.sin((y - x * 0.5) * k2 + t2);
        wv = s1 * s1 * s1 * s1 * 0.6 + s2 * 0.15;
      }
      const gl = useGlow ? glow[i] : 0;

      // Damped spring toward the target offset. With no force the target
      // is 0, so pushed dots settle back to the grid.
      if (interactive) {
        vx[i] = (vx[i] + (tx - ox[i]) * springK) * damp;
        vy[i] = (vy[i] + (ty - oy[i]) * springK) * damp;
        ox[i] += vx[i] * f;
        oy[i] += vy[i] * f;
        const m = Math.max(
          Math.abs(vx[i]),
          Math.abs(vy[i]),
          Math.abs(tx - ox[i]),
          Math.abs(ty - oy[i]),
        );
        if (m > maxMotion) maxMotion = m;
      } else {
        ox[i] = oy[i] = vx[i] = vy[i] = 0;
      }

      const mix = clamp01(hot * 1.1 + wv * 0.45 + gl * 0.4);
      const alpha =
        edge[i] * clamp01(BASE_ALPHA + hot * 0.75 + wv * 0.45 + gl * 0.3);
      size[i] = baseR * (1 + hot * 1.5 + wv * 0.3 + gl * 0.2);
      const al = Math.round(alpha * ALPHA_LEVELS) - 1;
      bucket[i] =
        al < 0
          ? SKIP
          : Math.round(mix * (COLOR_LEVELS - 1)) * ALPHA_LEVELS + al;
    }
    motion = maxMotion;
  }

  function paint() {
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, width, height);

    // Counting sort of dot indices by bucket — O(n), no allocation.
    starts.fill(0);
    for (let i = 0; i < count; i++) starts[bucket[i] + 1]++;
    for (let b = 1; b < starts.length; b++) starts[b] += starts[b - 1];
    for (let i = 0; i < count; i++) order[starts[bucket[i]]++] = i;
    // starts[b] now holds the end of bucket b; bucket b begins at end(b-1).
    let begin = 0;
    for (let b = 0; b < BUCKETS; b++) {
      const end = starts[b];
      if (end > begin) {
        ctx.fillStyle = palette[b];
        ctx.beginPath();
        for (let k = begin; k < end; k++) {
          const i = order[k];
          const x = x0[i] + ox[i];
          const y = y0[i] + oy[i];
          const r = size[i];
          ctx.moveTo(x + r, y);
          ctx.arc(x, y, r, 0, TAU);
        }
        ctx.fill();
      }
      begin = end;
    }
  }

  /** True while the next frame would look different from this one. */
  function isAnimating(): boolean {
    if (reduced) return false;
    // waveSpeed 0 holds the wave still, so it alone needs no frames.
    if ((cfg.wave && cfg.waveSpeed > 0) || ripples.length > 0) return true;
    if (Math.abs(strength - (pointerActive ? 1 : 0)) > 0.002) return true;
    return motion > 0.02;
  }

  function frame(now: number) {
    raf = 0;
    // Clamp so a long gap (tab switch, offscreen) doesn't explode springs.
    const dt = last ? Math.min(50, now - last) : 16.67;
    last = now;
    const f = dt / 16.67;
    if (!reduced) {
      clock += dt * Math.max(0, cfg.waveSpeed);
      for (const rp of ripples) rp.age += dt;
      ripples = ripples.filter((rp) => rp.age < RIPPLE_LIFE);
    }
    step(f);
    paint();
    if (visible && isAnimating()) raf = requestAnimationFrame(frame);
    else last = 0;
  }

  function kick() {
    if (!raf && visible && width > 0 && height > 0) {
      raf = requestAnimationFrame(frame);
    }
  }

  // The root may sit inside a transform: scale() (e.g. a thumbnail), so
  // client px are mapped back into the root's own layout px.
  function toLocal(e: PointerEvent) {
    const rect = root.getBoundingClientRect();
    const sx = rect.width ? width / rect.width : 1;
    const sy = rect.height ? height / rect.height : 1;
    px = (e.clientX - rect.left) * sx;
    py = (e.clientY - rect.top) * sy;
  }

  function onPointerMove(e: PointerEvent) {
    if (reduced) return;
    toLocal(e);
    pointerActive = true;
    kick();
  }

  function onPointerDown(e: PointerEvent) {
    if (reduced) return;
    toLocal(e);
    pointerActive = true;
    if (cfg.ripple) {
      ripples.push({ x: px, y: py, age: 0 });
      if (ripples.length > MAX_RIPPLES) ripples.shift();
    }
    kick();
  }

  function onPointerEnd(e: PointerEvent) {
    // A finger lifting is the end of hover; a mouse button release is not.
    if (e.type === "pointerleave" || e.pointerType !== "mouse") {
      pointerActive = false;
      kick();
    }
  }

  root.addEventListener("pointermove", onPointerMove, { passive: true });
  root.addEventListener("pointerdown", onPointerDown, { passive: true });
  root.addEventListener("pointerup", onPointerEnd, { passive: true });
  root.addEventListener("pointercancel", onPointerEnd, { passive: true });
  root.addEventListener("pointerleave", onPointerEnd, { passive: true });

  const ro = new ResizeObserver(() => resize());
  ro.observe(root);

  // Stop the loop entirely while the hero is scrolled away.
  let io: IntersectionObserver | null = null;
  if (typeof IntersectionObserver !== "undefined") {
    io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      if (visible) kick();
      else if (raf) {
        cancelAnimationFrame(raf);
        raf = 0;
        last = 0;
      }
    });
    io.observe(root);
  }

  const onMotionPref = () => {
    reduced = !!mq?.matches;
    ripples = [];
    step(0);
    paint();
    kick();
  };
  mq?.addEventListener("change", onMotionPref);

  buildPalette();
  resize();

  return {
    setConfig(next) {
      const gridChanged = next.gap !== cfg.gap || next.fade !== cfg.fade;
      const colorsChanged =
        next.color !== cfg.color || next.accentColor !== cfg.accentColor;
      cfg = next;
      if (colorsChanged) buildPalette();
      if (gridChanged) buildGrid();
      if (!cfg.ripple) ripples = [];
      step(0);
      paint();
      kick();
    },
    destroy() {
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
      ro.disconnect();
      io?.disconnect();
      mq?.removeEventListener("change", onMotionPref);
      root.removeEventListener("pointermove", onPointerMove);
      root.removeEventListener("pointerdown", onPointerDown);
      root.removeEventListener("pointerup", onPointerEnd);
      root.removeEventListener("pointercancel", onPointerEnd);
      root.removeEventListener("pointerleave", onPointerEnd);
    },
  };
}

/**
 * Interactive dot-matrix backdrop on a 2D canvas. Dots near the pointer
 * grow, brighten toward the accent color and are pushed aside on a spring;
 * an optional slow wave keeps the grid alive without a pointer (first
 * paint, touch), and clicks send out a ripple ring. The loop only runs
 * while the grid is on screen and something is actually moving. Fills its
 * parent; children render on top.
 */
export function DotGridBackground({
  dotSize = DEFAULT_DOT_SIZE,
  gap = DEFAULT_GAP,
  color = DEFAULT_COLOR,
  accentColor = DEFAULT_ACCENT,
  radius = DEFAULT_RADIUS,
  push = DEFAULT_PUSH,
  wave = true,
  waveSpeed = DEFAULT_WAVE_SPEED,
  ripple = true,
  fade = true,
  className,
  children,
}: DotGridBackgroundProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const engineRef = useRef<Engine | null>(null);

  const config: Config = {
    dotSize,
    gap,
    color,
    accentColor,
    radius,
    push,
    wave,
    waveSpeed,
    ripple,
    fade,
  };
  // Read by the mount effect so the first paint already uses the props,
  // without making the engine's lifetime depend on them.
  const configRef = useRef(config);

  useEffect(() => {
    configRef.current = config;
  });

  // The engine is created once per mount; prop changes go through
  // setConfig below instead of tearing the canvas loop down.
  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas || typeof ResizeObserver === "undefined") return;
    const engine = createEngine(root, canvas, configRef.current);
    engineRef.current = engine;
    return () => {
      engine?.destroy();
      engineRef.current = null;
    };
  }, []);

  useEffect(() => {
    engineRef.current?.setConfig({
      dotSize,
      gap,
      color,
      accentColor,
      radius,
      push,
      wave,
      waveSpeed,
      ripple,
      fade,
    });
  }, [
    dotSize,
    gap,
    color,
    accentColor,
    radius,
    push,
    wave,
    waveSpeed,
    ripple,
    fade,
  ]);

  // Only the accent feeds CSS (the soft top glow); the rest lives in the
  // canvas. Inline only when non-default, so the default render is exactly
  // the stylesheet.
  const vars: CSSVars = {};
  if (accentColor !== DEFAULT_ACCENT) vars["--dg-accent"] = accentColor;

  return (
    <div
      ref={rootRef}
      className={className ? `${styles.root} ${className}` : styles.root}
      style={vars}
    >
      <div className={styles.glow} aria-hidden="true" />
      <canvas ref={canvasRef} className={styles.canvas} aria-hidden="true" />
      <div className={styles.content}>{children}</div>
    </div>
  );
}

export default DotGridBackground;
```

#### `components/dot-grid-background/DotGridBackground.module.css`
```css
/* ==========================================================================
   Dot grid background — interactive dot matrix for dark hero sections

   Layers (back → front): base color → soft accent glow → dot canvas →
   content. The dots, their pointer response, wave and ripples are all
   drawn on the canvas; CSS only provides the frame and the static glow.
   ========================================================================== */

.root {
  /* Spec value. Set inline only when accentColor differs from it. */
  --dg-accent: #a78bfa;
  /* Not a prop: override via className to match a non-#0a0a0a page. */
  --dg-base: #08080b;

  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
  isolation: isolate;
  background: var(--dg-base);
  color: #fff;
  /* No touch-action override: the grid only listens, so a swipe on a touch
     screen still scrolls the page. No CSS animation either — all motion is
     on the canvas, which handles prefers-reduced-motion itself. */
}

/* --- Accent glow ---------------------------------------------------------
   A faint wash of the accent from the top, behind the headline. Static, so
   it costs nothing per frame, and it keeps the stage from reading as flat
   black between the dots. */
.glow {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background:
    radial-gradient(
      ellipse 60% 48% at 50% 0%,
      color-mix(in srgb, var(--dg-accent) 22%, transparent),
      transparent 72%
    ),
    radial-gradient(
      ellipse 80% 60% at 50% 110%,
      color-mix(in srgb, var(--dg-accent) 8%, transparent),
      transparent 70%
    );
}

/* --- Dot canvas ---------------------------------------------------------
   Its bitmap is sized by the component (ResizeObserver × capped DPR); the
   CSS box always matches the root, so it never drives layout. */
.canvas {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: block;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

/* --- Content slot ------------------------------------------------------- */
.content {
  position: relative;
  z-index: 2;
  height: 100%;
}
```

代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/dot-grid-background.json
```

### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { DotGridBackground } from "@/components/dot-grid-background";

// TODO: ヒーローの中身(見出し・ボタンなど)を children として渡してください: <DotGridBackground …>…</DotGridBackground>。親要素に高さ(例: min-height: 100svh)を与えると、その全面を埋めます
<DotGridBackground
  dotSize={2}
  gap={22}
  color="#a1a1b5"
  accentColor="#a78bfa"
  fade={true}
  radius={160}
  push={12}
  ripple={true}
  wave={true}
  waveSpeed={1}
/>
```

### 4. 組み込み手順
1. ヒーローの中身(見出し・ボタンなど)を `children` として渡す: `<DotGridBackground>…</DotGridBackground>`。コンポーネント自体が `"use client"` なので、Server Component からそのまま使える。
2. ルートは `width: 100%; height: 100%` で**親を埋める**。親に高さを与える(例: ラッパーに `min-height: 100svh`、または `className` で `min-height` を指定)。高さの無い親の中では中身の高さだけになり、キャンバスもその大きさになる。
3. 下地色はページ背景に合わせる。既定は `#08080b`。変えるときは `className` で CSS 変数 `--dg-base` を上書きする(例: `.hero { --dg-base: #0a0a0a; }`)。上部の淡いグローは `accentColor` から作られる。
4. `color` はドットの静止色で、不透明度 .3 前後で描かれる。暗い色を渡すとほぼ見えなくなるので、明るめのグレー〜白系を使う。`accentColor` はポインター付近・波紋・ウェーブの山の色。
5. ポインターはルート要素で受け取る(中のボタンやリンクの上でも反応する)。`touch-action` は変えていないので、スマホではスワイプでページがスクロールし、タップで波紋が出る。
6. `npm run build` が通ることを確認し、実機で「ポインター付近のドットが光って押しのけられ、離すと戻る」「スクロールで画面外に出すと CPU 使用率が下がる」「OS の視差効果を減らす設定で静止表示になる」を確認する。

#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| ドットがぼやける / Retina で太く見える | キャンバスのビットマップを CSS サイズのまま作った | `canvas.width = cssWidth × min(devicePixelRatio, 2)` にし、`ctx.setTransform(dpr, 0, 0, dpr, 0, 0)` で描く。CSS 上のサイズは 100% のまま |
| 縮小表示(`transform: scale`)の中でポインター位置と光がずれる | `clientX - rect.left` をそのまま使った | `rect.width / clientWidth` で拡大率を割り戻してからキャンバス座標にする |
| ポインターを動かすとカクつく / React の再レンダーが走る | 座標やドットの状態を state で持った | 座標は変数に記録するだけ。ドットの状態は型付き配列に持ち、`requestAnimationFrame` の中で描く |
| 何も動いていないのに CPU を使い続ける | 毎フレーム無条件に `requestAnimationFrame` を回した | ウェーブ・波紋・ばねの揺れ・ポインターのフェードのどれかが動いている間だけ次のフレームを要求する。IntersectionObserver で画面外なら止める |
| 大きな画面で重い | ドット 1 個ごとに `fillStyle` を変えて `fill()` した | 色 × 不透明度を段階に量子化してバケット分けし、バケットごとに 1 パス・1 回の `fill()` で描く |
| リサイズの瞬間に一瞬真っ黒になる | `canvas.width` を変えるとビットマップが消える | リサイズ直後に同期的に 1 回描画する |
| `gap` を極端に小さくすると重い | ドット数は面積 / gap² で増える | `gap` は 12px 以上を目安にする |

### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる

## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules。追加の npm 依存なし(Canvas 2D)
- ファイル: `components/dot-grid-background/DotGridBackground.tsx`(`"use client"`)+ `DotGridBackground.module.css`
- Props: `dotSize`(直径 px、既定 2)、`gap`(px、既定 22)、`color`(既定 #a1a1b5)、`accentColor`(既定 #a78bfa)、`radius`(影響範囲 px、既定 160)、`push`(押しのけ最大 px、既定 12)、`wave`(既定 true)、`waveSpeed`(倍率、既定 1)、`ripple`(既定 true)、`fade`(既定 true)、`className`、`children`
- CSS 変数: `--dg-accent`(既定 #a78bfa、既定と異なる場合だけ inline)、`--dg-base`(下地、#08080b、props ではなく className で上書き)

### 見た目
層構成は「下地 → アクセントのグロー → ドットのキャンバス → 中身」。

- **ルート(.root)**: `position: relative; width: 100%; height: 100%; overflow: hidden; isolation: isolate`、背景 `var(--dg-base)`、文字色 白
- **グロー(.glow)**: 上中央から `radial-gradient(ellipse 60% 48% at 50% 0%, color-mix(in srgb, var(--dg-accent) 22%, transparent), transparent 72%)` + 下側にごく弱い同色の楕円。静止
- **キャンバス(canvas)**: `position: absolute; inset: 0; width/height: 100%; pointer-events: none`、`aria-hidden`。ビットマップは ResizeObserver で CSS サイズ × `min(devicePixelRatio, 2)`
- **ドット**: `gap` 間隔の正方格子を中央揃えで、四辺から 1 列はみ出すように敷く。静止時は直径 `dotSize`、`color` を不透明度 .3 で
- **周辺フェード(fade)**: 中心からの楕円距離 e(辺の中点で 1)に対し、e = .45 から 1.1 にかけて smoothstep で不透明度を 0 へ。見出しの周りは鮮明なまま
- **中身(.content)**: `position: relative; z-index: 2; height: 100%`

### モーション
- **ポインター**: ルートの `pointermove`(passive)で座標を記録。影響度 s = smoothstep(1 - 距離 / radius) × 強さ。強さはポインターが入ると 1、離れると 0 へ毎フレーム 14% ずつ近づく(ふわっと点灯・消灯)
  - ドットは 直径 × (1 + 1.5s) に拡大、色は accentColor へ s × 1.1 だけ近づき、不透明度は .3 + .75s
  - 押しのけ: ポインターから外向きに s × push の目標位置を与え、ばね(剛性 .12、減衰 .78 / 60fps フレーム、dt で補正)で追従。目標が 0 に戻ればばねで元の格子位置へ戻る
- **ウェーブ(wave)**: 斜め(約 31°)に進む波長 560px の正弦波を 4 乗して鋭い明るさの帯にし(× .6)、波長 900px の逆向きのうねり(× .15)を足す。帯の部分はドットが少し大きく、明るく、アクセント寄りになる。時計は描画中だけ進むので、画面外から戻っても跳ばない
- **波紋(ripple)**: `pointerdown` の位置から 0.55px/ms で広がる幅 46px のリング(ガウス形)、寿命 1.7 秒で `(1 - 経過率)^1.6` で減衰。リング上のドットは光り、外向きに少し押される。同時に最大 6 個
- **描画**: 色 12 段階 × 不透明度 20 段階のバケットに量子化し、計数ソートでドットを並べてバケットごとに 1 パス + 1 回の `fill()`
- **ループ制御**: rAF は「ウェーブ(waveSpeed > 0)・波紋・ポインター強さの変化・ばねの揺れ」のどれかがある間だけ継続し、静まったら止める。ポインター操作で再開。IntersectionObserver で画面外なら停止。リサイズ時は同期的に 1 回描く
- 縮小表示の中でもずれないよう、ポインター座標は `(clientX - rect.left) × clientWidth / rect.width` でルートの CSS px に変換
- タッチ: タップで波紋とその場の点灯、指を離すと消灯。`touch-action` は変えずスクロールを妨げない
- `prefers-reduced-motion: reduce`: ウェーブ・波紋・ポインター追従をすべて止め、中心ほど少し大きく明るくアクセント寄りの**静止したビネット**を 1 回だけ描く(設定の変更も監視)

### アクセシビリティ
- キャンバスとグローは `aria-hidden="true"`、`pointer-events: none`。装飾のみで、中身のボタンやリンクの操作・フォーカスを妨げない
- 中身の文字のコントラストを確保する(本文は白 .6 以上を目安に)。点滅や急な明滅は無い

### 受け入れ条件
- ホバーしていない初回描画でも、ウェーブの帯が流れていて空っぽに見えない
- ポインター付近のドットがふくらんで光り、押しのけられ、離すとばねで格子に戻る
- クリック / タップで波紋のリングが広がって消える
- 何も動いていない(ウェーブ off、ポインター静止)ときと画面外では rAF が止まる
- devicePixelRatio 2 の画面でドットがくっきりしている。`transform: scale(.4)` の中でもポインター位置とずれない
- OS の「視差効果を減らす」で静止したビネット表示になる
インストール
npx shadcn@latest add https://anima-js.vercel.app/r/dot-grid-background.json
生成コード
import { DotGridBackground } from "@/components/dot-grid-background";

// TODO: ヒーローの中身(見出し・ボタンなど)を children として渡してください: <DotGridBackground …>…</DotGridBackground>。親要素に高さ(例: min-height: 100svh)を与えると、その全面を埋めます
<DotGridBackground
  dotSize={2}
  gap={22}
  color="#a1a1b5"
  accentColor="#a78bfa"
  fade={true}
  radius={160}
  push={12}
  ripple={true}
  wave={true}
  waveSpeed={1}
/>