GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

テキスト アニメーション css 9選 — 文字をキャンバスのピクセルから作り直す

テキスト アニメーションをcssのキーフレームだけで組むと、文字はいつも塊のまま動きます。この9種は違います。文字をピクセルにサンプリングして組み直す、つまりfillTextで描いた文字をgetImageDataで読み返し、粒子・点・線・円の材料にして組み上げる作りです。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

デモ

並びは派手さの順ではなく、文字が素材になっていく順です。まず散らばった粒子が文字へ凝集する基本形が生まれ(01)、サンプルを点・線・円という別の材料に書き直し(02・03・04)、帯・波・モーフィングで文字そのものが変形し(05・06・07)、最後は密度の読み替えとポインタ反応で見る人と絡みます(08・09)。テキスト アニメーション 作り方 を調べると文字を塊のまま動かす手法がほとんどで、文字の内側を素材に扱う作例は、この9種が並ぶ領域です。

実測では07のモーフィングが累積面積41.55パーセントで9種の中でもっとも広く動き、06のノイズ歪みは30.59パーセントと幅ではもっとも控えめな代わりに、平均強度151.0で大きく揺れています。9種すべてが23/23フレームで動き続けています。入力欄の単語を打ち替えればその場でマスクが組み直され、全目標点が計算し直されます。あらかじめ書き出した動画ではなく、表示している最中にサンプリングが走っているのがこの仕組みです。

軸もはっきり違います。ノイズ関数で地形を計算するノイズ地形とも、レイを飛ばして奥行きを描くレイマーチングとも違い、この9種はすでに描いた文字を読み直して材料にします。テキスト アニメーション かっこいい と印象づける演出の多くは、実はこの「文字の内側のピクセル」を扱うところから生まれています。

なぜこの順番か

01粒子再構成がサンプリングの原型です。オフスクリーンキャンバスにfillTextで文字を描き、getImageDataでアルファマスクを取り、各粒子に目標ピクセルを割り当ててバネで集まらせます。LPヒーローのワードマーク登場に使います。この原型の上に02の点、03の線、04の円が材料を差し替え、05〜07はマスクを裂く単位を行・座標・単語へ変え、08〜09は読み方そのものを密度と力へ変えます。

項目別コード

01粒子再構成 — 散らばった塵が文字になる

粒子再構成 — 散らばった塵が文字になる
新しいタブで開く

01 · 粒子再構成 — 散らばった塵が文字になる — 計測 累積面積 30.128% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fillTextgetImageDataarc

マスクの取り出し方と粒子のたどり着き方

オフスクリーンキャンバスにfillTextで文字を描き、getImageDataでアルファマスクを読み取り、各粒子に目標ピクセルを割り当ててバネで集まらせる、LPヒーローのワードマーク登場に使う、他の8種が参照する原型になる1枚です。

// ── 1) 文字 → アルファマスク ────────────────────────────────
function buildMask(word) {
  var off = document.createElement('canvas');
  off.width = SW; off.height = SH;
  var oc = off.getContext('2d', { willReadFrequently: true });
  oc.textAlign = 'center'; oc.textBaseline = 'middle';
  var fs = 200;
  oc.font = '900 ' + fs + 'px Arial';
  var w = oc.measureText(word).width || 1;
  fs = Math.min(fs * ((SW - 8) / w), SH - 6);       // 枠内に収まる最大サイズ
  oc.font = '900 ' + Math.round(fs) + 'px Arial';
  oc.fillStyle = '#fff';
  oc.fillText(word, SW / 2, SH / 2);
  return oc.getImageData(0, 0, SW, SH).data;
}
// ── 2) マスク → 目標点リスト ───────────────────────────────
function samplePoints(data) {
  var pts = [];
  for (var y = 0; y < SH; y += STEP) {
    for (var x = 0; x < SW; x += STEP) {
      if (data[(y * SW + x) * 4 + 3] > 128) pts.push({ x: x, y: y });
    }
  }
  return pts;
}

散乱と凝集を行き来するバネ

var br = BASE + (1 - BASE) * k;                  // 散乱 ↔ 凝集 の間を行き来する
var cx = SW / 2, cy = SH / 2;
for (var i = 0; i < parts.length; i++) {
  var p = parts[i];
  var tx = cx + (p.tx - cx) * br, ty = cy + (p.ty - cy) * br;
  p.vx = (p.vx + (tx - p.x) * 0.014) * 0.86;
  p.vy = (p.vy + (ty - p.y) * 0.014) * 0.86;
  p.x += p.vx; p.y += p.vy;
}

measureTextで幅を測って枠内に収まる最大サイズへ合わせ、白で打てばアルファチャンネルに文字の形がそのまま残ります。2pxの間隔で歩いてアルファが128を超えるセルの座標だけを目標点にする2段階が、9種すべての出発点です。毎フレーム目標を画面の中心を基準に押したり引いたりするので、粒子は2秒の周期で塵から文字へ、文字から呼吸する表情へ往復します(実測: 累積面積30.13パーセント、平均強度167.7、23/23フレームで動作)。

02グリッドドット ハーフトーン — サンプリング間隔が描く文字

グリッドドット ハーフトーン — サンプリング間隔が描く文字
新しいタブで開く

02 · グリッドドット ハーフトーン — サンプリング間隔が描く文字 — 計測 累積面積 36.418% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getImageDataarcrequestAnimationFrame

格子点ごとにインク濃度を点の大きさへ書き写す

アルファマスクを一定間隔の格子点だけで読み、値に比例した大きさの点を描き、間隔を時間で狭めたり広げたりして文字が密になったり粗になったりする、印刷・出版コンセプトのブランドヒーローに合う1枚です。

// サンプル間隔は時間で4.2↔7.8px — 周期2秒。版が密になったり粗になったりする
function stepAt(t) { return 6 + 1.8 * Math.sin(t * Math.PI); }
function paint(t) {
  ctx.clearRect(0, 0, W, H);
  var sx = W / SW, sy = H / SH, s = stepAt(t);
  var ox = Math.sin(t * 1.2) * 4.0, oy = Math.cos(t * 0.9) * 3.2;
  var rows = Math.ceil((SH + 8) / s) + 2, colsN = Math.ceil((SW + 8) / s) + 2;
  for (var gy = -2; gy < rows; gy++) {
    var y = gy * s + oy;
    for (var gx = -2; gx < colsN; gx++) {
      var x = gx * s + ((gy & 1) ? s * 0.5 : 0) + ox;  // 1行おきに半セルずらす
      var idx = ((gy + 8) * 131 + (gx + 8)) % phas.length; // 格子点ごとの固有位相
      var jx = x + Math.sin(t * 5.1 + phas[idx]) * 0.9;
      var jy = y + Math.cos(t * 4.7 + phas[idx] * 1.3) * 0.9;
      var xi = Math.round(jx), yi = Math.round(jy);
      if (xi < 0 || yi < 0 || xi >= SW || yi >= SH) continue;
      var a = mask[(yi * SW + xi) * 4 + 3] / 255;  // このセルのインク濃度
      var r = (0.17 + Math.pow(a, 1.2) * 0.86) * s * 0.52;
      ctx.globalAlpha = 0.30 + a * 0.70;
      ctx.fillStyle = cols[a > 0.66 ? 0 : (a > 0.4 ? 1 : 2)];
      ctx.beginPath(); ctx.arc(jx * sx, jy * sy, r * sx, 0, 6.2832); ctx.fill();
    }
  }
  ctx.globalAlpha = 1;
}

ハーフトーンとして読むとインクが残る

間隔がなめらかに滑ると点が格子の上を浮かんで流れるので、点ごとにシード位相の小さな揺れを足して毎フレームわずかにずらして描きます。文字は濃度を読む格子点で点が膨らむ場所から浮かび上がり、空きのセルも薄い下地の点を受けて版全体が印刷物のように呼吸します(実測: 累積面積36.42パーセント、23/23フレームで動作)。

03線分メッシュ — 隣接サンプルを結ぶ糸

線分メッシュ — 隣接サンプルを結ぶ糸
新しいタブで開く

03 · 線分メッシュ — 隣接サンプルを結ぶ糸 — 計測 累積面積 30.981% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

moveTolineTogetImageData

近い隣接ペアだけを糸で結ぶ

格子間隔で拾ったサンプル点から近い隣接ペアだけを選び、moveToとlineToで糸を引き、線ごとのアルファを値に応じて揺らして文字のシルエットを浮かび上がらせる、テック・AIスタートアップのヒーロー背景に使う1枚です。

function paint(t) {
  ctx.clearRect(0, 0, W, H);
  var sx = W / SW, sy = H / SH, wx = SW * (0.5 + 0.46 * Math.sin(t * 3.9)), wy = SH * (0.5 + 0.44 * Math.cos(t * 3.1));
  ctx.lineCap = 'round';
  for (var i = 0; i < pts.length; i++) {
    var p = pts[i];
    var nbs = [grid[gkey(p.gx + 1, p.gy)], grid[gkey(p.gx, p.gy + 1)],
               grid[gkey(p.gx + 1, p.gy + 1)], grid[gkey(p.gx + 1, p.gy - 1)]];
    for (var k = 0; k < nbs.length; k++) {
      var j = nbs[k];
      if (j === undefined || j <= i) continue;
      var q = pts[j], dx = q.x - p.x, dy = q.y - p.y;
      if (dx * dx + dy * dy > LINK * LINK) continue;
      var mx = (p.x + q.x) * 0.5, my = (p.y + q.y) * 0.5;
      var d = Math.sqrt((mx - wx) * (mx - wx) + (my - wy) * (my - wy));
      var al = p.a * q.a * (0.26 + 0.74 * Math.max(0, Math.cos(d * 0.11 - t * 2.6)));  // 峠が通るときだけ明るい
      if (al < 0.05) continue;
      ctx.strokeStyle = al > 0.62 ? colA : (al > 0.32 ? colB : colC);
      ctx.globalAlpha = Math.min(1, 0.2 + al);
      ctx.lineWidth = (1.8 + al * 3.4) * sx;
      ctx.beginPath();
      ctx.moveTo(p.x * sx, p.y * sy); ctx.lineTo(q.x * sx, q.y * sy); ctx.stroke();
    }
  }
}

波の峠がシルエットを起こす

各点から右・下・斜めの隣接だけを見るので同じ線が二度引かれず、格子座標で引けるため距離の比較すら要りません。波の源が画面を大きく周回し、峠が通る場所の糸だけが明るさと太さを増すので、文字が一斉に点灯するのではなく、掃かれるように浮かびます(実測: 累積面積30.98パーセント、23/23フレームで動作)。

04円パッキング — マスク内に最大限の円

円パッキング — マスク内に最大限の円
新しいタブで開く

04 · 円パッキング — マスク内に最大限の円 — 計測 累積面積 32.033% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

arcgetImageDatafillText

直径を縮めながら重なったら捨てる

マスク内に直径を縮めながら円を貪欲に置き、重なるものは捨てます。シード固定のため同じ文字には常に同じ配置になり、イベント・発表スライドのタイトルに合う、完成した後も円が脈打つ1枚です。

function packCircles(data, seed) {
  var rnd = mulberry32(seed), out = [], grid = [];   // セル → 円インデックスのリスト
  function hits(x, y, r) { var cx = Math.floor(x / CELL), cy = Math.floor(y / CELL);
    for (var gy = cy - 1; gy <= cy + 1; gy++) {
      for (var gx = cx - 1; gx <= cx + 1; gx++) {
        if (gx < 0 || gy < 0 || gx >= GW || !grid[gy * GW + gx]) continue;
        var cell = grid[gy * GW + gx];
        for (var k = 0; k < cell.length; k++) {
          var c = out[cell[k]], dx = c.x - x, dy = c.y - y, rr = c.r + r + 0.25;
          if (dx * dx + dy * dy < rr * rr) return true;
        }
      }
    }
    return false;
  }
  for (var d = 0; d < DIAMS.length; d++) { var r = DIAMS[d] / 2;
    for (var i = 0; i < TRIES[d]; i++) {
      var x = rnd() * SW, y = rnd() * SH;
      if (data[((y | 0) * SW + (x | 0)) * 4 + 3] <= 128 || hits(x, y, r)) continue;  // マスク外・重なり
      var gi = (y / CELL | 0) * GW + (x / CELL | 0);
      (grid[gi] || (grid[gi] = [])).push(out.length); out.push({ x: x, y: y, r: r, ph: rnd() * 6.2832, ci: pickColor(rnd()) });
    }
  }
  return out;
}

同じシードは同じ配置を保証する

直径9.2pxから1.4pxまで8段階に縮めて大きい円から詰めるので、太いストロークは大きい円が、細い末端は小さい円が占めます。衝突の照合を12pxのセル格子に分けて隣接9セルだけを見るので数千回の試行も軽く、試行の順序がすべてmulberry32(シード)で決まるため、シード7を入れ直せば同じ配置が戻ってきます(実測: 累積面積32.03パーセント、平均強度167.9、23/23フレームで動作)。

05グリッチ シャード — 横帯がずれる文字

グリッチ シャード — 横帯がずれる文字
新しいタブで開く

05 · グリッチ シャード — 横帯がずれる文字 — 計測 累積面積 37.567% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

drawImagegetImageDatarequestAnimationFrame

行ごとに剥がして別のオフセットへ貼る

サンプリングした各行を独立な断片として切り出し、drawImageで異なるオフセットに貼り直します。帯ごとに別のタイミングで跳ね、文字が裂けては再びくっつく、ゲーム・ハッキング演出のエラー画面に使う1枚です。

function offsetAt(row, t) {
  var nz = rowNoise[row];
  var o = Math.sin(t * 2.1 * nz.sp + nz.n1) * 4.6 + Math.sin(t * 3.7 * nz.sp + nz.n2) * 2.2;
  var ph = (t % KICK) / KICK;                        // キック — 短い窓(0.09秒)で行の半分が大きく跳ねる
  if (ph < 0.09 && (row * 2654435761 + seed) % 2 === 0) o += (nz.n1 > 3.14 ? 1 : -1) * 16 * Math.sin((ph / 0.09) * Math.PI);
  return o;
}
function paint(t) {
  ctx.clearRect(0, 0, W, H);
  if (!ink) return;
  var sx = W / SW, sy = H / SH;
  ctx.imageSmoothingEnabled = false;
  for (var row = 0; row < bands; row++) {
    var o = offsetAt(row, t), y = row * BH, aa = Math.abs(o);
    ctx.drawImage(ink, 0, y, SW, BH, o * sx, y * sy, SW * sx, BH * sy + 0.5);
    if (aa > 4.5) {                                  // 大きなずれにだけゴースト ±2.2px
      ctx.globalAlpha = Math.min((aa - 4.5) / 8, 1) * 0.6;
      ctx.drawImage(ink, 0, y, SW, BH, (o - 2.2) * sx, y * sy, SW * sx, BH * sy + 0.5);
      ctx.drawImage(ink, 0, y, SW, BH, (o + 2.2) * sx, y * sy, SW * sx, BH * sy + 0.5);
      ctx.globalAlpha = 1;
    }
  }
  ctx.imageSmoothingEnabled = true;
}

ずれが大きいときだけゴーストを重ねる

マスクをオフスクリーンのインク版に描き直して行単位で使い回すので、drawImageでオフセットだけ替えればその行の画素は再計算されず、3pxの行36枚を毎フレーム貼り替えても計算が積みません。ずれが4.5pxを超えた瞬間だけ左右に2.2pxずつ開いたゴーストを重ねて、裂ける瞬間を強調します(実測: 累積面積37.57パーセント、23/23フレームで動作)。

06ノイズ歪み — 波に洗われる文字

ノイズ歪み — 波に洗われる文字
新しいタブで開く

06 · ノイズ歪み — 波に洗われる文字 — 計測 累積面積 30.59% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getImageDataarcrequestAnimationFrame

目標点そのものを波打たせる

サンプル座標を読むたびに2つの正弦波の位相を混ぜ、目標点自体を波打たせて押し出すと、追う粒子によって文字全体が陽炎のように揺らぐ、音楽・フェスLPの背景に合う、残像が揺らぎを大きくする1枚です。

function paint(t) {
  ctx.clearRect(0, 0, W, H);
  var sx = W / SW, sy = H / SH;
  for (var i = 0; i < parts.length; i++) {
    var p = parts[i];
    // 遅い大屈曲 — 行・列ごとに位相のずれた大きなサイン。文字全体が波に洗われるように揺れる
    var swx = Math.sin(t * 0.95 + p.oy * 0.028) * 19;
    var swy = Math.cos(t * 0.75 + p.ox * 0.031) * 13;
    // さざ波 — 目標点そのものを波打たせる。振幅もゆっくり膨らんでは沈む(波の目)
    var amp = AMP * (1 + 0.85 * Math.sin(t * 2.4 + p.ox * 0.02 + p.oy * 0.015));
    var tx = p.ox + swx + Math.sin(p.oy * F1 + t * 2.0) * Math.sin(p.ox * F2 - t * 1.4) * amp;
    var ty = p.oy + swy + Math.cos(p.ox * F2 * 1.15 + t * 1.7) * Math.sin(p.oy * F1 * 0.9 + t * 1.1) * amp * 0.72;
    p.x += (tx - p.x) * LERP;                       // lerp追従 — 残像が揺らぎを大きくする
    p.y += (ty - p.y) * LERP;
    ctx.fillStyle = cols[p.ci];
    ctx.beginPath();
    ctx.arc(p.x * sx, p.y * sy, p.sz * sx * 0.85, 0, 6.2832);
    ctx.fill();
  }
}

サイン2枚の積が波の目を作る

サイン波1本で押すと文字は単に左右に揺れますが、sin(y·F1+t) × sin(x·F2−t·0.7)のように縦と横の進行を掛けると、峠と谷が交わる場所で押される向きが変わり、波の模様になります。振幅自体も遅いサインで膨らんでは沈むので波が一定にならず、粒子が目標をLERP 0.14でゆっくり追うため、目標がすでに次の波へ移った場所に残像が残ります(実測: 累積面積30.59パーセント、平均強度151.0 — 9種の中で動きの幅はもっとも狭いのに、揺れの激しさでは上の席です)。

07モーフィング — 単語が別の単語に変わる

モーフィング — 単語が別の単語に変わる
新しいタブで開く

07 · モーフィング — 単語が別の単語に変わる — 計測 累積面積 41.546% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fillTextgetImageDataarc

目標セットを2組作って入れ替える

2つの単語をそれぞれサンプリングして目標座標を2組作り、粒子数を多い側に合わせて順に組み合わせ、目標を入れ替えることで単語が流れるように変わる、サービス紹介のローリングコピーに使う9種の中でもっとも広く動く1枚です。

// ── 3) 2つの単語をサンプリングし、粒子数は多い側に合わせる ─────────
function resample(nextWord, nextSeed) {
  var w = String(nextWord == null ? wordB : nextWord).trim().toUpperCase();
  if (w) wordB = w;                          // 空文字列なら現在値を保持
  var s = Math.floor(Number(nextSeed));
  if (s >= 1) seed = s;
  var setA = samplePoints(buildMask(WORD_A));
  var setB = samplePoints(buildMask(wordB));
  var n = Math.max(setA.length, setB.length);  // 粒子数 = 多い側
  var rnd = mulberry32(seed);
  parts = [];
  for (var i = 0; i < n; i++) {
    var a = setA[i % setA.length], b = setB[i % setB.length];
    var r1 = rnd(), r2 = rnd(), r3 = rnd(), r4 = rnd();
    parts.push({
      x: a.x, y: a.y,                        // 開始 = 最初の単語の形
      ax: a.x, ay: a.y,                      // 目標A — HELLO 側の座標
      bx: b.x, by: b.y,                      // 目標B — 入力単語側の座標
      sz: 2.9 + r1 * 2.2, ph: r2 * 6.2832, ci: pickColor(r3),
      bl: (r4 - 0.5) * 26                    // 弓なまり(px) — 経路を弧に広げる
    });
  }
}

座標が流れるので幽霊フレームが残らない

// モーフィングミックス — サインで0..1..0往復。2秒のgifの中で2周する
var mix = 0.5 - 0.5 * Math.cos(t / CYCLE * 6.2832);
var ease = mix * mix * (3 - 2 * mix);               // smoothstep
for (var i = 0; i < parts.length; i++) {
  var p = parts[i];
  var dx = p.bx - p.ax, dy = p.by - p.ay;
  var tx = p.ax + dx * ease, ty = p.ay + dy * ease;
  var bow = Math.sin(ease * Math.PI);               // 出発・到着は0、中間で最大
  tx += -dy * 0.35 * bow + p.bl * bow;              // 直交方向への弧の広がり
  ty +=  dx * 0.35 * bow + p.bl * 0.6 * bow;
  tx += Math.sin(t * 0.95 + p.ay * 0.028) * 12;     // 遅い大屈曲(面積の主な源)
  ty += Math.cos(t * 0.75 + p.ax * 0.031) * 9;
  var ob = 0.55 + 0.45 * Math.sin(t * 1.9 + p.ph * 3.1);   // 呼吸軌道の大きさ
  tx += Math.cos(t * 1.35 + p.ph) * ORBIT * ob;
  ty += Math.sin(t * 1.15 + p.ph * 1.7) * ORBIT * ob * 0.8;
  p.x += (tx - p.x) * LERP;                         // lerp追従
  p.y += (ty - p.y) * LERP;
}

2つの単語のアルファを重ねて交叉フェードさせると、中間の瞬間に半分ずつ混ざった幽霊のフレームが通りますが、座標の補間では各粒子が物理的に1点にしかいないので幽霊は生まれません。少ない側の目標は%の余りで再利用して2組を同じ粒子へ組み、直交方向への弧の広がりが経路を膨らませて掃く面積を大きくします(実測: 累積面積41.55パーセントで9種中最大、23/23フレームで動作)。

08ASCII密度 — 明るさが文字を選ぶ盤

ASCII密度 — 明るさが文字を選ぶ盤
新しいタブで開く

08 · ASCII密度 — 明るさが文字を選ぶ盤 — 計測 累積面積 32.283% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fillTextgetImageDatarequestAnimationFrame

セルのアルファを平均して密度として読む

各グリッドセルのアルファ値を密度として読み、あらかじめ決めた文字のはしご" .:-=+*#%@"から1字を選んでfillTextで打ち、はしごの基準を時間で動かして埋まる順序が変わる、レトロ端末・開発者向けLPに使う1枚です。

// ── 2) マスク → セル密度グリッド ────────────────────────────
function buildCells(word) {
  var data = buildMask(word);
  var cw = Math.floor(SW / CELL), ch = Math.floor(SH / CELL);
  var rnd = mulberry32(seed);
  var out = [];
  for (var cy = 0; cy < ch; cy++) {
    for (var cx = 0; cx < cw; cx++) {
      var sum = 0, n = 0;
      for (var py = cy * CELL; py < (cy + 1) * CELL; py++) {
        for (var px = cx * CELL; px < (cx + 1) * CELL; px++) {
          sum += data[(py * SW + px) * 4 + 3]; n++;
        }
      }
      var d = sum / (n * 255);                   // 0..1 の密度
      var r1 = rnd(), r2 = rnd();
      out.push({
        cx: cx, cy: cy, d: d,
        ci: d > 0.02 ? (r1 < 0.6 ? 0 : r1 < 0.78 ? 1 : r1 < 0.9 ? 2 : 3) : 0,
        ph: r2 * 6.2832                          // 走査位相 — シードが決める
      });
    }
  }
  return out;
}

はしごを押すと埋まる順序が変わる

// はしごスライド — 密度→文字の対応が時間でずれる(登場シーケンス)
var slide = (t / CYCLE) * LADDER.length;
for (var i = 0; i < cells.length; i++) {
  var c = cells[i];
  var bg = c.d <= 0.02;                    // マスク外のセル
  if (bg && slBg <= 0) continue;
  var win = Math.max(0, Math.cos(Math.min(t * 2.6, Math.PI)));  // 登場の窓
  var sl = slide * 0.28 * win + Math.sin(t * 5.3 + c.ph) * (LADDER.length + 1.5);
  var lv = (bg ? 0 : c.d) * (LADDER.length - 1) + (bg ? slBg : sl);
  var idx = Math.max(0, Math.min(LADDER.length - 1, Math.round(lv)));
  var ch = LADDER.charAt(idx);
  if (ch === ' ') continue;
  ctx.fillText(ch, (c.cx + 0.5) * CELL * sx, (c.cy + 0.5) * CELL * sy);
}

二値のしきい値(内・外)ではなくセル内の画素アルファを平均して0..1の密度で読むので、太いストロークの中心は濃い#や@を、半分だけかかる縁のセルは薄い.や:を受け取って、文字に明暗がつきます。はしごの基準点を時間で押すと同じ密度が別の文字へ置き換わり、版全体が満ちていく表情が動き続けます(実測: 累積面積32.28パーセント、23/23フレームで動作)。

09再集合 — ポインタがかき分けた文字が再び集まる

再集合 — ポインタがかき分けた文字が再び集まる
新しいタブで開く

09 · 再集合 — ポインタがかき分けた文字が再び集まる — 計測 累積面積 35.76% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getImageDataarcrequestAnimationFrame

復元バネと押す力の綱引き

各粒子は目標ピクセルへ戻る復元力を持ち、ポインタ周辺の半径内では押しのける力が勝ちます。手が通った場所の文字は押しのけられ、また元の場所へ再集合する、インタラクティブヒーロー・展示インスタレーションに使う1枚です。

function step(dt) {
  // 仮想ポインタ — 実ポインタがないときサインで画面を横切って流れる(自動モード)
  var px, py;
  if (ptr.on) {
    px = ptr.x; py = ptr.y;
  } else {
    var w = tNow * 0.9;
    px = SW / 2 + Math.sin(w) * (SW * 0.36);
    py = SH / 2 + Math.sin(w * 2.3 + 1.2) * (SH * 0.30);
  }
  for (var i = 0; i < parts.length; i++) {
    var p = parts[i];
    var dx = p.x - px, dy = p.y - py;
    var d2 = dx * dx + dy * dy;
    var R = ptr.on ? RADIUS : AUTO_R;                // 自動モードはより広く掃く
    if (d2 < R * R && d2 > 0.01) {
      var d = Math.sqrt(d2), f = (1 - d / R) * PUSH;  // 近いほど強く押す
      p.vx += (dx / d) * f; p.vy += (dy / d) * f;
    }
    p.vx += (p.ox - p.x) * SPRING;                   // 目標への復元
    p.vy += (p.oy - p.y) * SPRING;
    p.vx *= DAMP; p.vy *= DAMP;
    p.x += p.vx; p.y += p.vy;
  }
}

放出機ではなく目標点の復元が核心

同じcanvasの粒子場を思い浮かべると、花火の放出機と寿命の軸をまず考えがちですが、この1枚にはどちらもありません。粒子は生まれも死にもせず、最初にサンプリングされた目標ピクセルを一生の家として覚え、ポインタが過ぎれば一時的に押しのけられ、力が抜ければSPRING 0.012の復元力で我が家へ戻ります。ポインタがないときは仮想ポインタがサインで画面を横切り、文字を自らかき分けます(実測: 累積面積35.76パーセント、平均強度176.2、23/23フレームで動作)。

文字はどうやってピクセルになるのか? — getImageData サンプリング

9種すべてが同じ手順を1回ずつ踏みます。何を作るにも第一歩は、見えないキャンバスに文字を描いて読み返すことです。252×108のオフスクリーンキャンバスにfillTextで単語を白く打つと、インクが乗った画素のアルファ値は255に近く、空きは0なので、4番目のバイトだけを見れば文字の形がそのまま数値の地図になります。getImageDataでこの配列を取り出した先が分かれ道です。アルファ128を超えるセルの座標を目標点リストにすれば粒子になり(01・06・07・09)、格子点で値を読めば点の大きさになり(02)、隣接ペアを結べば線になり(03)、内側に円を詰めればタイポグラフィ自体になり(04)、行単位に裂けば断片になり(05)、セル平均を取れば密度になります(08)。外部ライブラリも書体ファイルのダウンロードも不要で、ブラウザが元々持つシステム書体とCanvas 2Dだけで文字が素材になります。描いた文字を読み返してその座標を粒子・点・線・円の目標に書き直す点が、ノイズ関数で地形を計算するやり方とも、レイを飛ばして3Dの空間を描くやり方とも違う、この9種を際立たせる特徴です。

どこで壊れるか — 落とし穴

格子の上を滑る点

最初に組んだ02のハーフトーンは、点の間隔をサインで動かすと点が格子の上を滑るように流れて、文字が印刷版ではなく網のように見えました。格子点の座標がフレームごとに実数で連続的に変わるため、点はその場で膨らむのではなく隣のセルへ流れ込んでいたのです。各点にシード位相の小さな揺れを足して毎フレームわずかにずらして描くと、点は場所を守ったまま生き生きと動くようになりました。

影を失ったスタンプ

逆方向の失敗もあります。08のASCII密度でセルのアルファを二値のしきい値(0か1か)で読むと、文字は切り抜いたスタンプのように平らになり、セル内の全画素を足して平均する読み方に変えて初めて、縁に薄いはしごが並びました。

フォールバック書体の跳び

fillTextは書体が読み込まれる前に描くと代替書体で引き伸ばされることがあり、本物の書体が届いた瞬間にマスクが再サンプリングされて目標点が一気に数百増え、粒子が弾けたことがあります。今はシステム書体のArialだけを使い、単語が変わるたびに目標点を最初から計算し直して、この跳びを消しています。9種のvanillaソースとReact移植版はこの下のアーカイブに1つにまとまっており、開ける鍵は ex82wzp9 です。

3つのバリエーションとアクセシビリティ

名前 変える値 変わる印象
サンプル間隔 02の格子点 6px → 3px 粗かった点の文字が締まって印刷物らしくなる
単語 07のモーフィング HELLO → WELCOME 同じ粒子が別の文字へ流れていく
色 $glyph-sky → $glyph-peach 冷たいシアンの粒子が温かいピーチへ変わる

サンプル間隔

ハーフトーンの間隔を6pxから3pxへ半分にすると、1行あたりの格子点が倍に増えて点がオフセット印刷の肌理へ締まります。同じ単語を倍の解像度で読み直すだけの変更です。

単語

モーフィングは入力欄の単語をそのまま再サンプリングするので、HELLOをWELCOMEに替えると同じ粒子の集まりが新しい目標へ流れていき、目に見える組み直しは起きません。

色

各サンプルはscss冒頭のパステルトークンから palette を読むので、$glyph-skyを$glyph-peachに替えれば冷たいシアンの粒子が温かいピーチへ染まります。サンプリングの計算は1行も変わりません。

動きを減らす設定で何が残るか

prefers-reduced-motion: reduceの利用者にはrAFのループを回さず、シードが選んだ静止画を1枚だけ描きます。01は全粒子が目標に着いた完成した文字、02はインクが乗りきったハーフトーン版、08ははしごが着地した端末面です。この静止画でも単語とシードの入力は生きており、値を変えれば別の1枚にすぐ描き直るので、動きが止まっても文字という結果は手元に残ります。

FAQ

入力欄の単語はリアルタイムで再サンプリングされますか?

されます。9種すべてが単語とシードの入力欄にinputイベントを付けており、文字が変わればbuildMaskからやり直して目標点を全部計算し直します。実測では01は既定単語GLYPHの目標点1631個が長い単語で856個に、07のモーフィングはWAVEの2063個が1110個にその場で組み替わり、同じシードを2回入れれば同じ配置、7と91を入れれば別の配置になるまで確認しました。円パッキングとモーフィングでは特にシードが配置を大きく変えるので、2つの値を交互に試す面白さがあります。

テキスト アニメーション css のキーフレームで足りる場面はどこまでですか?

文字が塊のまま動いてよい場面、つまり移動・フェード・拡大縮小まではcssのキーフレームとトランジションの方が軽く正確です。canvasが要るのは文字を材料に変える場面です。粒子がピクセルから散ったり、ハーフトーンの点が締まったり、単語の間を流れたりする演出には、cssが決して見ない文字の内側の地図が必要なので、キャンバスのサンプリングが近道になります。

ソースを自分のページに置いてそのまま使えますか?

使えます。「テキスト アニメーション 無料」で探すと有料のテンプレートが並びますが、この9種は無料でコピーでき、vanilla版とReact移植版がそれぞれ丸ごとアーカイブに入っています。ファイルを置けばこのページと同じ画面が出て、色は各サンプルのscss先頭に置いたパステルトークンで入れ替え、単語とシードはソース上部の既定値を書き換えれば同じように変わります。

関連記事

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。