TipsBlocks Web Technique Library

TB-0001

Pixel Block Trail

CanvasとJavaScriptで実装した、マウスポインターの移動に反応するテキストアニメーションです。文字を構成するピクセルブロックがポインターの方向と速度に合わせて動き、離れると元の文字位置へ戻ります。

Animation Cursor Effect Pointer Move Canvas JavaScript
Interaction
文字の上でポインターを動…
Runtime
Canvas 2D

MOTION PREVIEW

動きを短時間で確認する。

ポインターが文字へ近づいた時にブロックがどのように崩れ、移動速度によって反応範囲がどう変化し、最後に文字へ戻っていくかを短いプレビューで確認できます。

PREVIEW MEDIA Preview MP4 / Poster を登録すると、ここへ自動表示されます。

LIVE DEMO

実際に触って確かめる。

文字の上でポインターを動かしてください。ゆっくり動かした時と、素早く横切った時の反応の違いも確認できます。

HOW TO INTERACT

Pointer Move

文字の上でポインターを動かしてください。ゆっくり動かした時と、素早く横切った時の反応の違いも確認できます。

ABOUT THIS BLOCK

Pixel Block Trailとは

Pixel Block Trailは、文字を小さなブロックの集合として描画し、マウスポインターの移動に反応させるインタラクティブなテキストアニメーションです。Canvas 2DとVanilla JavaScriptだけで構成しており、ポインターが文字の近くを通過すると、周辺のブロックが移動方向へ押し出され、色や角度を変えながら散っていきます。

ブロックは単純に飛び散るだけではなく、それぞれが元の文字位置を基準点として持っています。ポインターから離れた後はスプリング処理によって少しずつ元の位置へ戻るため、文字の形が崩れてから再形成される一連の動きを自然に見せられます。移動速度が速いほど影響範囲が広がるため、ゆっくり動かした場合と素早く動かした場合で反応が変わるのも特徴です。

ヒーローエリアのタイトル演出、クリエイティブ系サイトのキービジュアル、ポートフォリオの名前表示、イベントサイトの見出しなど、ユーザーの操作そのものをデザインに取り込む場面へ応用できます。文章を読むための本文テキストよりも、短いタイトルやロゴ的な文字表現に向いているBlockです。

Technology
Canvas / JavaScript
Interaction
Pointer Move
External Library
None
Reduced Motion
Supported

HOW IT WORKS

どのような仕組みで動くのか。

文字マスクからブロックを生成し、ポインターの方向・速度・距離を使って動きを与え、スプリング処理で元の位置へ戻すまでの流れを整理します。

  1. 01

    文字をCanvasへ描画

    まず非表示のCanvasへ太字の文字を描画し、文字が存在する領域のピクセル情報を取得します。表示文字列そのものではなく、描画結果をブロック配置の元データとして利用します。

  2. 02

    文字領域からブロックを生成

    文字のピクセルを一定間隔でサンプリングし、それぞれの位置をブロックの基準座標として保存します。画面サイズに応じてサンプリング間隔も変えるため、表示領域に合わせて密度を調整できます。

  3. 03

    ポインターの方向と速度を取得

    pointermoveイベントから現在位置と直前位置の差を求め、移動方向と速度を計算します。速度は影響半径にも反映されるため、素早く動かすほど広い範囲のブロックが反応します。

  4. 04

    近くのブロックへ力を加える

    ポインターから一定距離以内にあるブロックへ、進行方向の力と少量の横方向の散らばりを加えます。距離が近いほど影響を強くすることで、操作位置を中心に自然な崩れ方を作ります。

  5. 05

    スプリング処理で元へ戻す

    各ブロックには最初の文字位置が保存されており、毎フレームその基準点へ戻る力を加えます。速度を減衰させながら位置を更新することで、散ったブロックが滑らかに文字へ再集合します。

CODE

HTML・CSS・JavaScriptを確認する。

実行用Demoとは別に管理している整理済みSourceです。HTML・CSS・JavaScriptを切り替えながら、Canvasの準備、ブロック生成、ポインター追従、復帰処理を確認できます。

<canvas id="stage"></canvas>

<div class="brand">TIPS BLOCKS / MOTION IDEA 001</div>
<div class="meta">PIXEL BLOCK TRAIL<br>CANVAS 2D / ORIGINAL STUDY</div>
<div class="hint"><span class="dot"></span>MOVE THE POINTER THROUGH THE LETTERS</div>
:root{
    --paper:#f4f0e7;
    --ink:#111318;
    --coral:#ef684f;
    --blue:#4c8df6;
    --yellow:#f1c84c;
  }

  *{box-sizing:border-box}
  html,body{margin:0;width:100%;height:100%;overflow:hidden;background:var(--paper);color:var(--ink)}
  body{
    font-family: Arial, Helvetica, sans-serif;
    cursor: crosshair;
  }

  #stage{
    position:fixed;
    inset:0;
    width:100%;
    height:100%;
    display:block;
  }

  .brand{
    position:fixed;
    left:28px;
    top:24px;
    z-index:2;
    font-size:13px;
    font-weight:700;
    letter-spacing:.16em;
    text-transform:uppercase;
    pointer-events:none;
  }

  .meta{
    position:fixed;
    right:28px;
    top:24px;
    z-index:2;
    text-align:right;
    font-size:12px;
    line-height:1.55;
    letter-spacing:.06em;
    pointer-events:none;
    opacity:.7;
  }

  .hint{
    position:fixed;
    left:50%;
    bottom:26px;
    transform:translateX(-50%);
    z-index:2;
    font-size:12px;
    letter-spacing:.08em;
    white-space:nowrap;
    pointer-events:none;
    opacity:.62;
  }

  .dot{
    display:inline-block;
    width:7px;
    height:7px;
    margin:0 7px 1px 0;
    background:var(--coral);
  }

  @media (max-width:720px){
    .brand{left:18px;top:18px}
    .meta{right:18px;top:18px}
    .hint{bottom:18px;font-size:11px}
  }

  @media (prefers-reduced-motion: reduce){
    .hint::after{content:" — reduced motion"}
  }
(() => {
  "use strict";

  const canvas = document.getElementById("stage");
  const ctx = canvas.getContext("2d", { alpha: false });
  const mask = document.createElement("canvas");
  const mctx = mask.getContext("2d", { willReadFrequently: true });

  const palette = ["#ef684f", "#4c8df6", "#f1c84c"];
  const paper = "#f4f0e7";
  const ink = "#111318";

  let W = 0, H = 0, dpr = 1;
  let blocks = [];
  let sampleStep = 9;
  let fontSize = 160;
  let reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;

  const pointer = {
    x: -9999, y: -9999,
    px: -9999, py: -9999,
    vx: 0, vy: 0,
    speed: 0,
    active: false
  };

  function fit() {
    dpr = Math.min(devicePixelRatio || 1, 2);
    W = innerWidth;
    H = innerHeight;

    canvas.width = Math.round(W * dpr);
    canvas.height = Math.round(H * dpr);
    canvas.style.width = W + "px";
    canvas.style.height = H + "px";
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

    mask.width = Math.round(W);
    mask.height = Math.round(H);

    const longest = 11;
    fontSize = Math.min(W / longest * 1.67, H * 0.28);
    fontSize = Math.max(64, Math.min(fontSize, 220));
    sampleStep = Math.max(7, Math.round(fontSize / 19));

    rebuild();
  }

  function rebuild() {
    mctx.clearRect(0, 0, W, H);
    mctx.fillStyle = "#000";
    mctx.textAlign = "center";
    mctx.textBaseline = "middle";
    mctx.font = `900 ${fontSize}px Arial, Helvetica, sans-serif`;
    mctx.fillText("TIPS", W / 2, H / 2 - fontSize * 0.44);
    mctx.fillText("BLOCKS", W / 2, H / 2 + fontSize * 0.46);

    const pixels = mctx.getImageData(0, 0, W, H).data;
    blocks = [];

    const startX = Math.max(0, Math.floor(W * 0.10));
    const endX   = Math.min(W, Math.ceil(W * 0.90));
    const startY = Math.max(0, Math.floor(H * 0.20));
    const endY   = Math.min(H, Math.ceil(H * 0.80));

    for (let y = startY; y < endY; y += sampleStep) {
      for (let x = startX; x < endX; x += sampleStep) {
        const a = pixels[(Math.floor(y) * W + Math.floor(x)) * 4 + 3];
        if (a > 80) {
          blocks.push({
            bx: x,
            by: y,
            x,
            y,
            vx: 0,
            vy: 0,
            heat: 0,
            spin: 0,
            vr: 0,
            seed: Math.random()
          });
        }
      }
    }
  }

  function pointerMove(e) {
    const x = e.clientX;
    const y = e.clientY;

    if (!pointer.active) {
      pointer.px = x;
      pointer.py = y;
      pointer.active = true;
    }

    pointer.vx = x - pointer.px;
    pointer.vy = y - pointer.py;
    pointer.speed = Math.min(42, Math.hypot(pointer.vx, pointer.vy));
    pointer.x = x;
    pointer.y = y;
    pointer.px = x;
    pointer.py = y;
  }

  addEventListener("pointermove", pointerMove, { passive: true });
  addEventListener("pointerleave", () => { pointer.active = false; });
  addEventListener("blur", () => { pointer.active = false; });
  addEventListener("resize", fit);

  function update() {
    const radius = 92 + pointer.speed * 2.25;
    const r2 = radius * radius;

    for (const b of blocks) {
      if (pointer.active && !reducedMotion) {
        const dx = b.x - pointer.x;
        const dy = b.y - pointer.y;
        const dist2 = dx * dx + dy * dy;

        if (dist2 < r2) {
          const dist = Math.sqrt(dist2) + 0.001;
          const falloff = 1 - dist / radius;
          const speedFactor = 0.35 + pointer.speed / 24;

          // Main idea: blocks are carried along the pointer direction,
          // plus a small perpendicular scatter to break the silhouette.
          const nx = pointer.vx / (pointer.speed + 0.001);
          const ny = pointer.vy / (pointer.speed + 0.001);
          const side = (b.seed - 0.5) * 2;

          b.vx += (nx * 2.8 + (-ny) * side * 1.25) * falloff * speedFactor;
          b.vy += (ny * 2.8 + ( nx) * side * 1.25) * falloff * speedFactor;
          b.vr += side * 0.012 * falloff * pointer.speed;
          b.heat = Math.min(1, b.heat + 0.16 + falloff * 0.28);
        }
      }

      // Spring back to the original glyph position.
      b.vx += (b.bx - b.x) * 0.018;
      b.vy += (b.by - b.y) * 0.018;

      b.vx *= 0.87;
      b.vy *= 0.87;
      b.vr *= 0.86;

      b.x += b.vx;
      b.y += b.vy;
      b.spin += b.vr;
      b.heat *= 0.94;
    }

    pointer.vx *= 0.82;
    pointer.vy *= 0.82;
    pointer.speed *= 0.82;
  }

  function draw() {
    ctx.fillStyle = paper;
    ctx.fillRect(0, 0, W, H);

    const s = Math.max(4.8, sampleStep * 0.72);

    for (const b of blocks) {
      const moving = Math.min(1, Math.hypot(b.x - b.bx, b.y - b.by) / 42);
      const t = Math.max(b.heat, moving);

      let fill = ink;
      if (t > 0.06) {
        const index = Math.min(
          palette.length - 1,
          Math.floor((b.seed * 0.45 + t * 0.78) * palette.length) % palette.length
        );
        fill = palette[index];
      }

      const stretch = 1 + Math.min(1.8, Math.hypot(b.vx, b.vy) * 0.055);
      const h = s * (0.95 - t * 0.20);
      const w = s * (1 + t * 0.45) * stretch;

      ctx.save();
      ctx.translate(b.x, b.y);
      ctx.rotate(b.spin);
      ctx.fillStyle = fill;
      ctx.fillRect(-w / 2, -h / 2, w, h);
      ctx.restore();
    }

    // Pointer marker: custom square, deliberately different from the reference.
    if (pointer.active && !reducedMotion) {
      ctx.save();
      ctx.translate(pointer.x, pointer.y);
      ctx.rotate(Math.PI / 4);
      ctx.strokeStyle = ink;
      ctx.lineWidth = 1.5;
      ctx.strokeRect(-7, -7, 14, 14);
      ctx.restore();
    }
  }

  function frame() {
    update();
    draw();
    requestAnimationFrame(frame);
  }

  fit();
  frame();
})();

USE CASE

Webデザインへどう活用できるか。

短いタイトルやキービジュアルに動きを持たせ、ユーザーのポインター操作そのものをページの演出へ取り込む用途に向いています。

Creative Agency Hero

Creative Agency Hero

制作会社やクリエイティブエージェンシーのヒーローで、社名や短いメッセージをユーザー操作に反応させる使い方です。静止した大見出しよりも強い第一印象を作れます。

Event Website

Event Website

イベント名や開催テーマを大きく表示し、来訪者がポインターを動かした瞬間に文字が反応する演出として利用できます。短いタイトルとの相性が良い用途です。

Portfolio

Portfolio

デザイナーや開発者のポートフォリオで、名前や作品タイトルの表示へ組み込む用途です。作品そのものに触れる感覚をページの入口から作れます。

TECHNICAL NOTES

実装前に確認しておきたいこと。

Canvas描画、端末性能、Pointer Events、Reduced Motionなど、実際のWebサイトへ組み込む際に確認しておきたい技術面をまとめます。

Browser

Canvas 2D、Pointer Events、requestAnimationFrameを使用します。公開前に対象ブラウザの現行バージョンで実機確認してください。

Performance

devicePixelRatioは最大2に制限し、文字サイズに応じてブロックのサンプリング間隔を調整しています。実際の負荷は画面サイズ、端末性能、同時に動かすBlock数によって変わります。

Mobile

Canvas自体は画面サイズに追従しますが、このBlockはポインター移動を中心に設計しています。タッチ端末での操作感はPC版とは異なるため、公開用途に応じてタッチ向け挙動を個別に検証してください。

Reduced Motion

prefers-reduced-motion: reduceを検出すると、ポインターによるブロック移動とカスタムポインター表示を抑制する実装が入っています。