First CH Designs
BLK-24Blocks

Cursor-Trail Strand Canvas (with parameter panel)

A drawing block that turns the cursor trail into a rope of evenly spaced nodes on Canvas 2D, then lets gravity, curl noise and distance constraints drop and pile it up. The First CH wordmark is rendered offscreen and thresholded into a 0/1 alpha mask, so strands bounce off the letterforms and only the contact points light up in amber (#d97706). Pressure, gravity and curl sliders plus clear / auto-draw toggles sit in a hairline-ruled panel rather than a row of cards. The live strand is amber and fades to ink over 900ms once it settles. The rAF loop stops on three conditions — prefers-reduced-motion, off-screen (IntersectionObserver) and hidden tab — and under reduced motion the same physics is run synchronously to paint a single settled frame, so the sliders still recompute the result instead of going dead. After a pause a Lissajous virtual pointer takes over so the effect reads on touch devices too. No dependencies, no image assets. Only the idea (strands from a cursor trail, plus a pressure/gravity/curl parameter panel) is borrowed from the reference pen; all code, copy, colour, composition and the wordmark are original.

Added:
2026-08-13
Dependencies:
None
tags
#block #canvas #interactive #physics #verlet #rope #pointer #controls #motion #no-image #no-dependency

Preview

軌跡ストランド・キャンバス

なぞると描け、押し込むと太くなります

描画パラメータ

線はワードマークに当たると弾かれ、触れた点だけがアンバーに灯ります。

インタラクションの相談
"use client";

import { useEffect, useRef, useState } from "react";
import { Montserrat } from "next/font/google";

const montserrat = Montserrat({ weight: ["700", "800"], subsets: ["latin"], display: "swap" });

/**
 * カーソル追従ストリング描画キャンバス(BLK-24)
 *
 * 汎用技法メモ(web-design-playbook へ還流する要点):
 * 1. 軌跡→ストランド化: ポインタ座標をそのまま点として撒かず、「直前のノードから SEG(6px) 進むごとに
 *    1ノード打つ」ループで等間隔のノード列を作る。ポインタが速く動いた1フレームでも while で
 *    間を埋めるので、フレームレートに依らず線の密度が一定になる(=速く動かすと点線になる問題が消える)。
 * 2. ロープ物理は「積分 → 距離拘束 → 衝突」の順で回す。拘束は隣接ノードの距離を SEG に戻す補正を
 *    2回ぶん流すだけ(Jakobsen 法)。バネ定数を上げるより安定し、発散しない。
 * 3. dt スケール: 速度・減衰をフレーム固定値にせず dt/16.667 で正規化する(120Hz で倍速にならない)。
 *    減衰は乗算なので Math.pow(DAMP, dtScale)。
 * 4. **文字の当たり判定はテキストをオフスクリーンに描いて alpha を読む**。ワードマークを
 *    fillText → getImageData → Uint8Array(w*h) の 0/1 マスクへ落とすと、任意の書体・任意の文字列に対して
 *    パスを持たずに衝突が取れる。法線は「左右・上下2px 先が塗りかどうか」の差分(勾配)から作る。
 *    フォントは next/font の `style.fontFamily` を ctx.font に渡し、document.fonts.ready で必ず組み直す
 *    (読み込み前に測るとフォールバック書体の字幅でマスクができてしまう)。
 * 5. リボン描画は1ストランド1 fill: 各ノードの接線から法線を出し、片側→反対側を逆順に辿って
 *    1本の閉パスにする。線分ごとに stroke すると数百回の状態変更になり 60fps を割る。
 *    太さのプロファイルは sin(π·i/n)^0.55 で両端を細らせると手描きの入り抜きになる。
 * 6. 「描いている線=アンバー / 置かれた線=インク」を色の時間変化で表す。ストランドが閉じた時刻から
 *    900ms かけて amber→ink を線形補間し、さらに一定時間後にアルファを落として消す。
 *    無限に溜まらないので、放置されるギャラリー内でも状態が安定する。
 * 7. reduced-motion では rAF を回さず、同じ物理を同期ループで 260 ステップ回した「計算結果」を1枚描く。
 *    アニメーションを止めてもパラメータ(圧力・重力・うねり)は結果の形に効くので、操作が死なない。
 *
 * 着想: Canvas上でカーソル軌跡から曲線ストランドを作る発想と、
 * pressure/gravity/curl を持つパラメータパネルという構成のみ)。ロゴ衝突の着想は First CH の
 * ワードマークへ差し替え、コード・文言・配色・構図はすべて新規に書き起こしている。
 */

type Bead = { x: number; y: number; vx: number; vy: number; w: number; hit: number };
type Strand = { beads: Bead[]; seed: number; settledAt: number };

const SEG = 6; // ノード間の静止距離(px)
const MAX_BEADS = 80; // 1ストランドの最大ノード数
const MAX_STRANDS = 7; // 同時に生かすストランド数
const DAMP = 0.93; // 60fps基準の減衰率
const WARM_MS = 900; // amber → ink へ移る時間
const FADE_HOLD = 3400; // 定着後この時間はそのまま見せる
const FADE_DUR = 1500; // そこから消えるまでの時間
const IDLE_MS = 1800; // 無操作がこれだけ続いたら自動描画へ
const PAD = 2;
const TAU = Math.PI * 2;
const WORDMARK = "FIRST CH";

const INK = [34, 30, 25];
const AMBER = [217, 119, 6];

const PARAMS = [
  { key: "pressure", label: "圧力", note: "線の太さ。押し込むと増える", min: 0, max: 100 },
  { key: "gravity", label: "重力", note: "落下と底への溜まり方", min: 0, max: 100 },
  { key: "curl", label: "うねり", note: "軌跡に乗るゆらぎの強さ", min: 0, max: 100 },
] as const;

export default function CursorStringCanvas() {
  const [pressure, setPressure] = useState(58);
  const [gravity, setGravity] = useState(22);
  const [curl, setCurl] = useState(46);
  const [auto, setAuto] = useState(true);

  const stageRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const paramsRef = useRef({ pressure: 58, gravity: 22, curl: 46, auto: true });
  const apiRef = useRef<{ clear: () => void; restatic: () => void } | null>(null);

  // 値はrAFループ側から参照するのでrefへミラーする(依存に入れて再購読しない)
  useEffect(() => {
    paramsRef.current = { pressure, gravity, curl, auto };
    // reduced-motion のときだけ、パラメータ変更を静止フレームの再計算として反映する
    apiRef.current?.restatic();
  }, [pressure, gravity, curl, auto]);

  useEffect(() => {
    const stage = stageRef.current;
    const canvas = canvasRef.current;
    if (!stage || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    let w = 1;
    let h = 1;
    let raf = 0;
    let last = 0;
    let clock = 0;
    let seedCount = 0;

    const strands: Strand[] = [];
    let live: Strand | null = null;

    const ptr = { x: 0, y: 0, inside: false, down: false, at: -Infinity };
    const cur = { x: 0, y: 0, vx: 0, vy: 0, on: false, s: 0, press: 0 };
    let wasAuto = false;

    const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
    let onScreen = true;
    let visible = document.visibilityState !== "hidden";

    // ---- ワードマークのマスク(テキスト → alpha → 0/1 ビットマップ) -------------------
    const maskCv = document.createElement("canvas");
    let mask: Uint8Array | null = null;
    const wm = { size: 0, x: 0, y: 0 };

    const setFont = (c: CanvasRenderingContext2D, size: number) => {
      const withSpacing = c as CanvasRenderingContext2D & { letterSpacing?: string };
      if ("letterSpacing" in withSpacing) withSpacing.letterSpacing = `${size * 0.05}px`;
      c.font = `800 ${size}px ${montserrat.style.fontFamily}, sans-serif`;
      c.textAlign = "center";
      c.textBaseline = "middle";
    };

    const buildMask = () => {
      const mc = maskCv.getContext("2d", { willReadFrequently: true });
      if (!mc) {
        mask = null;
        return;
      }
      maskCv.width = w;
      maskCv.height = h;
      mc.clearRect(0, 0, w, h);

      // 一度仮サイズで測ってから、幅が枠の74%になる実サイズへ組み直す
      let size = Math.min(h * 0.34, w * 0.2);
      setFont(mc, size);
      const measured = mc.measureText(WORDMARK).width || 1;
      size = Math.max(16, Math.min(h * 0.4, (size * (w * 0.74)) / measured));
      setFont(mc, size);
      wm.size = size;
      wm.x = w / 2;
      wm.y = Math.round(h * 0.5);

      mc.fillStyle = "#000";
      mc.fillText(WORDMARK, wm.x, wm.y);
      try {
        const data = mc.getImageData(0, 0, w, h).data;
        const bits = new Uint8Array(w * h);
        for (let i = 0, p = 3; i < bits.length; i++, p += 4) bits[i] = data[p] > 110 ? 1 : 0;
        mask = bits;
      } catch {
        // 画素が読めない環境では衝突なしで成立させる(描画自体は続く)
        mask = null;
      }
    };

    const solid = (x: number, y: number) => {
      if (!mask) return false;
      const ix = Math.floor(x);
      const iy = Math.floor(y);
      if (ix < 0 || iy < 0 || ix >= w || iy >= h) return false;
      return mask[iy * w + ix] === 1;
    };

    // ---- ストランド生成 ---------------------------------------------------------------
    const trim = () => {
      while (strands.length > MAX_STRANDS) {
        const dropped = strands.shift();
        if (dropped === live) live = null;
      }
    };

    const closeLive = () => {
      if (live) live.settledAt = clock;
      live = null;
    };

    const beadWidth = () => {
      const p = paramsRef.current.pressure / 100;
      return (1.5 + p * 5.2) * (1 + cur.press * 0.9);
    };

    const push = (x: number, y: number) => {
      if (!live) {
        live = { beads: [], seed: (seedCount++ * 0.6180339887) % 1, settledAt: 0 };
        strands.push(live);
        trim();
        if (!live) return;
      }
      live.beads.push({
        x,
        y,
        vx: cur.vx * 0.4,
        vy: cur.vy * 0.4,
        w: beadWidth(),
        hit: 0,
      });
      if (live.beads.length >= MAX_BEADS) closeLive();
    };

    /** 直前ノードから SEG ごとにノードを打つ(1フレームで大きく動いても密度が一定になる) */
    const emit = (x: number, y: number) => {
      if (!live || live.beads.length === 0) {
        push(x, y);
        return;
      }
      let guard = 0;
      while (live && guard++ < 10) {
        const head = live.beads[live.beads.length - 1];
        const dx = x - head.x;
        const dy = y - head.y;
        const d = Math.hypot(dx, dy);
        if (d < SEG) return;
        push(head.x + (dx / d) * SEG, head.y + (dy / d) * SEG);
      }
    };

    // ---- 物理(積分 → 距離拘束 → 衝突) ------------------------------------------------
    const collide = (b: Bead) => {
      if (mask && solid(b.x, b.y)) {
        // 法線は「2px 先が塗りかどうか」の差分から作る
        let nx = (solid(b.x - 2, b.y) ? 1 : 0) - (solid(b.x + 2, b.y) ? 1 : 0);
        let ny = (solid(b.x, b.y - 2) ? 1 : 0) - (solid(b.x, b.y + 2) ? 1 : 0);
        if (nx === 0 && ny === 0) {
          // 字面の内側まで潜った場合は入射方向を押し返す
          nx = -b.vx;
          ny = -b.vy;
        }
        let len = Math.hypot(nx, ny);
        if (len < 0.0001) {
          nx = 0;
          ny = -1;
          len = 1;
        }
        nx /= len;
        ny /= len;
        // 押し出しは細かく刻む(大きく飛ばすとロープが折れて見える)
        let guard = 0;
        while (solid(b.x, b.y) && guard++ < 22) {
          b.x += nx;
          b.y += ny;
        }
        const dot = b.vx * nx + b.vy * ny;
        if (dot < 0) {
          b.vx -= 1.15 * dot * nx;
          b.vy -= 1.15 * dot * ny;
        }
        b.vx *= 0.7;
        b.vy *= 0.7;
        b.hit = 1;
      }

      if (b.y > h - PAD) {
        b.y = h - PAD;
        b.vy *= -0.26;
        b.vx *= 0.88;
      } else if (b.y < PAD) {
        b.y = PAD;
        b.vy *= -0.26;
      }
      if (b.x < PAD) {
        b.x = PAD;
        b.vx *= -0.4;
      } else if (b.x > w - PAD) {
        b.x = w - PAD;
        b.vx *= -0.4;
      }
    };

    const stepPhysics = (dtScale: number) => {
      const g = (paramsRef.current.gravity / 100) * 0.34;
      const c = (paramsRef.current.curl / 100) * 0.17;
      const damp = Math.pow(DAMP, dtScale);
      const decay = Math.pow(0.9, dtScale);

      for (let s = strands.length - 1; s >= 0; s--) {
        const strand = strands[s];
        const beads = strand.beads;

        if (strand.settledAt && clock - strand.settledAt > FADE_HOLD + FADE_DUR) {
          strands.splice(s, 1);
          continue;
        }

        for (const b of beads) {
          // うねりは三角関数の合成で作る擬似ノイズ(乱数を使わないので再現できる)
          if (c > 0) {
            const a = b.x * 0.013 + clock * 0.0012 + strand.seed * TAU;
            const e = b.y * 0.011 - clock * 0.0009 + strand.seed * 4.11;
            b.vx += Math.sin(a * 1.3 + Math.cos(e)) * c * dtScale;
            b.vy += Math.cos(e * 1.2 + Math.sin(a)) * c * dtScale;
          }
          b.vy += g * dtScale;
          b.vx *= damp;
          b.vy *= damp;
          b.x += b.vx * dtScale;
          b.y += b.vy * dtScale;
          b.hit *= decay;
        }

        // 距離拘束(Jakobsen法): 隣接ノードの距離を SEG へ戻す補正を流す。
        // 拘束 → 衝突 → 拘束 → 衝突 と交互に回すと、押し出しで折れたロープがその場でならされる
        for (let k = 0; k < 3; k++) {
          for (let i = 1; i < beads.length; i++) {
            const a = beads[i - 1];
            const b = beads[i];
            const dx = b.x - a.x;
            const dy = b.y - a.y;
            const d = Math.hypot(dx, dy) || 0.0001;
            const corr = ((d - SEG) / d) * 0.5;
            const ox = dx * corr;
            const oy = dy * corr;
            a.x += ox;
            a.y += oy;
            b.x -= ox;
            b.y -= oy;
          }
          if (k > 0) for (const b of beads) collide(b);
        }
      }
    };

    const stepInput = (dtScale: number) => {
      const params = paramsRef.current;
      const useAuto = params.auto && clock - ptr.at > IDLE_MS;
      if (useAuto !== wasAuto) {
        closeLive();
        cur.on = false;
        wasAuto = useAuto;
      }

      let tx = 0;
      let ty = 0;
      let spray = false;
      if (useAuto) {
        // ワードマークを横切るリサージュ。無操作でも衝突が起きる軌道と速度にする
        // (遅すぎると SEG ごとの打点が間引かれて線が途切れて見える)
        const a = clock * 0.0013;
        tx = w * (0.5 + 0.34 * Math.sin(a));
        ty = h * (0.44 + 0.2 * Math.sin(a * 1.63 + 0.9));
        spray = true;
      } else if (ptr.inside && (ptr.down || clock - ptr.at < IDLE_MS)) {
        // 押していない・動いてもいないポインタの上で吹き続けない(その場に積み上がる)
        tx = ptr.x;
        ty = ptr.y;
        spray = true;
      }

      cur.press += ((ptr.down ? 1 : 0) - cur.press) * Math.min(1, 0.2 * dtScale);
      cur.s += ((spray ? 1 : 0) - cur.s) * Math.min(1, 0.09 * dtScale);

      if (!spray) {
        cur.on = false;
        closeLive();
        return;
      }
      if (!cur.on) {
        cur.x = tx;
        cur.y = ty;
        cur.vx = 0;
        cur.vy = 0;
        cur.on = true;
      } else {
        const px = cur.x;
        const py = cur.y;
        cur.x += (tx - cur.x) * Math.min(1, 0.35 * dtScale);
        cur.y += (ty - cur.y) * Math.min(1, 0.35 * dtScale);
        cur.vx = cur.x - px;
        cur.vy = cur.y - py;
      }
      emit(cur.x, cur.y);
    };

    // ---- 描画 --------------------------------------------------------------------------
    // リボンの両側の頂点(1ストランド1 fill で使い回す)
    const ax = new Float32Array(MAX_BEADS);
    const ay = new Float32Array(MAX_BEADS);
    const bx = new Float32Array(MAX_BEADS);
    const by = new Float32Array(MAX_BEADS);

    const drawWordmark = () => {
      if (!wm.size) return;
      setFont(ctx, wm.size);
      ctx.fillStyle = "rgba(34, 30, 25, 0.06)";
      ctx.fillText(WORDMARK, wm.x, wm.y);
      ctx.lineWidth = 1;
      ctx.strokeStyle = "rgba(34, 30, 25, 0.24)";
      ctx.strokeText(WORDMARK, wm.x, wm.y);
    };

    const drawStrand = (strand: Strand) => {
      const beads = strand.beads;
      const n = beads.length;
      if (n < 2) return;

      const warm = strand.settledAt
        ? Math.max(0, 1 - (clock - strand.settledAt) / WARM_MS)
        : 1;
      const gone = strand.settledAt
        ? Math.max(0, Math.min(1, (clock - strand.settledAt - FADE_HOLD) / FADE_DUR))
        : 0;
      const alpha = (0.26 + 0.5 * warm) * (1 - gone);
      if (alpha <= 0.01) return;

      // 各ノードの接線から法線を出し、太さプロファイルを掛けて両側の頂点を作る
      for (let i = 0; i < n; i++) {
        const prev = beads[Math.max(0, i - 1)];
        const next = beads[Math.min(n - 1, i + 1)];
        const tx = next.x - prev.x;
        const ty = next.y - prev.y;
        const len = Math.hypot(tx, ty) || 1;
        const prof = Math.pow(Math.sin((Math.PI * (i + 0.5)) / n), 0.55);
        const hw = (beads[i].w * prof) / 2 + 0.35;
        const ox = (-ty / len) * hw;
        const oy = (tx / len) * hw;
        ax[i] = beads[i].x + ox;
        ay[i] = beads[i].y + oy;
        bx[i] = beads[i].x - ox;
        by[i] = beads[i].y - oy;
      }

      const r = Math.round(INK[0] + (AMBER[0] - INK[0]) * warm);
      const g = Math.round(INK[1] + (AMBER[1] - INK[1]) * warm);
      const b = Math.round(INK[2] + (AMBER[2] - INK[2]) * warm);
      ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;

      // 片側→反対側を逆順に辿って1本の閉パスにする(1ストランド1 fill)。
      // 頂点を lineTo で結ぶとノードの揺れがそのまま角として出るので、
      // 「制御点=頂点 / 終点=次との中点」の二次ベジェで通すと1パスのまま滑らかになる。
      ctx.beginPath();
      ctx.moveTo(ax[0], ay[0]);
      for (let i = 1; i < n - 1; i++) {
        ctx.quadraticCurveTo(ax[i], ay[i], (ax[i] + ax[i + 1]) / 2, (ay[i] + ay[i + 1]) / 2);
      }
      ctx.lineTo(ax[n - 1], ay[n - 1]);
      ctx.lineTo(bx[n - 1], by[n - 1]);
      for (let i = n - 2; i > 0; i--) {
        ctx.quadraticCurveTo(bx[i], by[i], (bx[i] + bx[i - 1]) / 2, (by[i] + by[i - 1]) / 2);
      }
      ctx.lineTo(bx[0], by[0]);
      ctx.closePath();
      ctx.fill();
    };

    const draw = () => {
      ctx.clearRect(0, 0, w, h);
      drawWordmark();
      for (const strand of strands) drawStrand(strand);

      // ワードマークに触れた点だけアンバーで灯す
      for (const strand of strands) {
        for (const b of strand.beads) {
          if (b.hit <= 0.12) continue;
          ctx.fillStyle = `rgba(217, 119, 6, ${0.2 + b.hit * 0.55})`;
          ctx.beginPath();
          ctx.arc(b.x, b.y, b.w * 0.42 + 1.1, 0, TAU);
          ctx.fill();
        }
      }

      // 筆先の計測リング(標本モチーフ。太さの見える化も兼ねる)
      if (cur.on && cur.s > 0.04) {
        const ring = 9 + (paramsRef.current.pressure / 100) * 9 + cur.press * 9;
        ctx.lineWidth = 1;
        ctx.strokeStyle = `rgba(217, 119, 6, ${0.4 * cur.s})`;
        ctx.beginPath();
        ctx.arc(cur.x, cur.y, ring, 0, TAU);
        ctx.stroke();
        ctx.beginPath();
        for (let i = 0; i < 4; i++) {
          const a = (i / 4) * TAU;
          const cos = Math.cos(a);
          const sin = Math.sin(a);
          ctx.moveTo(cur.x + cos * (ring + 3), cur.y + sin * (ring + 3));
          ctx.lineTo(cur.x + cos * (ring + 8), cur.y + sin * (ring + 8));
        }
        ctx.stroke();
      }
    };

    // ---- ループと停止条件 ---------------------------------------------------------------
    const frame = (time: number) => {
      if (!last) last = time;
      const dt = Math.min(50, time - last);
      last = time;
      clock += dt;
      const dtScale = dt / 16.6667;
      stepInput(dtScale);
      stepPhysics(dtScale);
      draw();
      raf = requestAnimationFrame(frame);
    };

    const stop = () => {
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    };

    const start = () => {
      if (raf || mql.matches || !onScreen || !visible) return;
      last = 0;
      raf = requestAnimationFrame(frame);
    };

    /** reduced-motion 用: 同じ物理を同期で回し、落ち着いた「計算結果」を1枚だけ描く */
    const drawStatic = () => {
      strands.length = 0;
      live = null;
      cur.on = false;
      cur.vx = 0;
      cur.vy = 0;
      cur.press = 0;
      cur.s = 0;

      const sweeps = [
        { y0: 0.24, y1: 0.62, amp: 0.1 },
        { y0: 0.68, y1: 0.34, amp: 0.14 },
      ];
      for (const sweep of sweeps) {
        for (let i = 0; i <= 64; i++) {
          const t = i / 64;
          const x = w * (0.1 + 0.8 * t);
          const y = h * (sweep.y0 + (sweep.y1 - sweep.y0) * t + sweep.amp * Math.sin(t * 7.2));
          emit(x, y);
        }
        closeLive();
      }
      for (let i = 0; i < 260; i++) {
        clock += 16.6667;
        stepPhysics(1);
      }
      // 「置かれた線」と「描きたての線」の両方が見えるよう定着時刻をずらす
      if (strands[0]) strands[0].settledAt = clock - WARM_MS;
      if (strands[1]) strands[1].settledAt = clock - WARM_MS * 0.4;
      draw();
    };

    const ro = new ResizeObserver((entries) => {
      // contentRect はレイアウト寸法(CSS transform の影響を受けない)
      const box = entries[0].contentRect;
      w = Math.max(1, Math.round(box.width));
      h = Math.max(1, Math.round(box.height));
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      buildMask();
      if (mql.matches) drawStatic();
      else if (!raf) draw();
    });
    ro.observe(stage);

    const io = new IntersectionObserver(
      (entries) => {
        onScreen = entries[0].isIntersecting;
        if (onScreen) start();
        else stop();
      },
      { rootMargin: "120px" },
    );
    io.observe(stage);

    const onVisibility = () => {
      visible = document.visibilityState !== "hidden";
      if (visible) start();
      else stop();
    };

    const toLocal = (clientX: number, clientY: number) => {
      const rect = stage.getBoundingClientRect();
      if (!rect.width || !rect.height) return;
      // 変形後の矩形 → 論理座標へ比率で換算(親に scale が掛かっていても合う)
      ptr.x = ((clientX - rect.left) / rect.width) * w;
      ptr.y = ((clientY - rect.top) / rect.height) * h;
      ptr.inside = true;
      ptr.at = clock;
    };

    const onMove = (e: PointerEvent) => toLocal(e.clientX, e.clientY);
    const onDown = (e: PointerEvent) => {
      toLocal(e.clientX, e.clientY);
      ptr.down = true;
    };
    const onUp = () => {
      ptr.down = false;
    };
    const onLeave = () => {
      ptr.inside = false;
      ptr.down = false;
    };
    const onMotionChange = () => {
      if (mql.matches) {
        stop();
        drawStatic();
      } else {
        strands.length = 0;
        live = null;
        start();
      }
    };

    stage.addEventListener("pointermove", onMove, { passive: true });
    stage.addEventListener("pointerdown", onDown, { passive: true });
    stage.addEventListener("pointerup", onUp, { passive: true });
    stage.addEventListener("pointercancel", onLeave, { passive: true });
    stage.addEventListener("pointerleave", onLeave, { passive: true });
    document.addEventListener("visibilitychange", onVisibility);
    mql.addEventListener("change", onMotionChange);

    // 実フォントが載る前に測るとフォールバック書体の字幅でマスクができるので必ず組み直す
    let alive = true;
    document.fonts?.ready.then(() => {
      if (!alive) return;
      buildMask();
      if (mql.matches) drawStatic();
    });

    apiRef.current = {
      clear: () => {
        strands.length = 0;
        live = null;
        if (mql.matches) drawStatic();
        else if (!raf) draw();
      },
      restatic: () => {
        if (mql.matches) drawStatic();
      },
    };

    if (mql.matches) drawStatic();
    else start();

    return () => {
      alive = false;
      apiRef.current = null;
      stop();
      ro.disconnect();
      io.disconnect();
      stage.removeEventListener("pointermove", onMove);
      stage.removeEventListener("pointerdown", onDown);
      stage.removeEventListener("pointerup", onUp);
      stage.removeEventListener("pointercancel", onLeave);
      stage.removeEventListener("pointerleave", onLeave);
      document.removeEventListener("visibilitychange", onVisibility);
      mql.removeEventListener("change", onMotionChange);
    };
  }, []);

  const values = { pressure, gravity, curl };
  const setters = { pressure: setPressure, gravity: setGravity, curl: setCurl };

  return (
    <section
      aria-labelledby="canvas-string-title"
      className="w-full max-w-4xl overflow-hidden rounded-2xl border border-gray-200 bg-white"
    >
      {/* 見出し行 */}
      <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-5 py-4 sm:px-7 sm:py-5">
        <h2
          id="canvas-string-title"
          className="flex items-center gap-2 text-[12px] font-bold tracking-[0.14em] whitespace-nowrap text-gray-900"
        >
          <span aria-hidden="true" className="inline-block size-1 shrink-0 bg-amber-600" />
          軌跡ストランド・キャンバス
        </h2>
        {/* ポインタ種別の出し分けは純CSSで行う(JSでデバイス判定するとハイドレーション不一致になる) */}
        <p className="text-[11px] text-gray-500">
          <span className="motion-reduce:hidden">
            <span className="[@media(pointer:coarse)]:hidden">なぞると描け、押し込むと太くなります</span>
            <span className="hidden [@media(pointer:coarse)]:inline">指でなぞると描けます</span>
          </span>
          <span className="hidden motion-reduce:inline">静止画として計算結果を表示中</span>
        </p>
      </div>

      <div className="flex flex-col border-t border-gray-200 md:flex-row md:items-stretch">
        {/* 描画面 */}
        <div
          ref={stageRef}
          className="relative min-h-[260px] w-full touch-pan-y select-none sm:min-h-[300px] md:min-h-[360px] md:flex-1"
        >
          <canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 block h-full w-full" />
        </div>

        {/* パラメータパネル: カードを並べず hairline で区切る */}
        <div className="flex shrink-0 flex-col border-t border-gray-200 md:w-[224px] md:border-t-0 md:border-l">
          <p className="border-b border-gray-200 px-5 py-2.5 text-[11px] font-bold tracking-[0.12em] text-gray-500">
            描画パラメータ
          </p>

          <div className="flex flex-1 flex-col">
            {PARAMS.map((p) => (
              <label key={p.key} className="block border-b border-gray-200 px-5 py-3.5">
                <span className="flex items-baseline justify-between gap-2">
                  <span className="text-[12px] font-bold tracking-[0.08em] text-gray-900">
                    {p.label}
                  </span>
                  {/* range が値を読み上げるので、この表示は装飾として外す。
                      <output> はラベル対象要素なので label 直下に置くと input と紐づかなくなる */}
                  <span
                    aria-hidden="true"
                    className="font-mono text-[10px] font-bold text-amber-600 tabular-nums"
                  >
                    {String(values[p.key]).padStart(3, "0")}
                  </span>
                </span>
                <input
                  type="range"
                  min={p.min}
                  max={p.max}
                  value={values[p.key]}
                  onChange={(e) => setters[p.key](Number(e.target.value))}
                  className="mt-2.5 h-1 w-full cursor-pointer accent-amber-600 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
                />
                <span className="mt-2 block text-[10px] leading-relaxed text-gray-500">{p.note}</span>
              </label>
            ))}
          </div>

          <div className="flex items-center gap-2 px-5 py-3.5">
            <button
              type="button"
              onClick={() => apiRef.current?.clear()}
              className="flex-1 border border-gray-300 px-3 py-2 text-[11px] font-bold tracking-[0.08em] text-gray-700 transition-colors duration-200 hover:border-gray-900 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
            >
              消去
            </button>
            <button
              type="button"
              aria-pressed={auto}
              onClick={() => setAuto((v) => !v)}
              className={`flex-1 border px-3 py-2 text-[11px] font-bold tracking-[0.08em] transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none ${
                auto
                  ? "border-amber-600 bg-amber-600 text-white hover:bg-amber-700"
                  : "border-gray-300 text-gray-700 hover:border-gray-900 hover:text-gray-900"
              }`}
            >
              自動描画
            </button>
          </div>
        </div>
      </div>

      {/* 注記とテキストCTA */}
      <div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-3 border-t border-gray-200 px-5 py-4 sm:px-7">
        <p className="max-w-sm text-[11px] leading-[1.8] text-gray-500">
          線はワードマークに当たると弾かれ、触れた点だけがアンバーに灯ります。<span className="hidden motion-reduce:inline">モーション低減の設定を検知したため、アニメーションは止めて計算結果を1枚だけ描いています(数値を変えると再計算します)。</span>
        </p>
        <a
          href="#"
          className="group inline-flex items-center gap-2 text-[13px] font-semibold whitespace-nowrap text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
        >
          <span className="relative">
            インタラクションの相談
            <span className="absolute -bottom-1 left-0 h-px w-full origin-left scale-x-100 bg-gray-900 transition-transform duration-300 ease-out group-hover:scale-x-0 motion-reduce:transition-none" />
            <span className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 ease-out group-hover:origin-left group-hover:scale-x-100 motion-reduce:transition-none" />
          </span>
          <span
            aria-hidden="true"
            className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transition-none"
          >
            →
          </span>
        </a>
      </div>
    </section>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/cursor-string-canvas.json