AI先生のロボットキャラクター
第9章 - セクション7

猫顔風のSVGローディングアニメーション

猫のシルエットパス上をドットが周回するSVGローディングアニメーション。prefers-reduced-motionに対応。

SVGのパスを利用したローディングアニメーションは、汎用的な円形スピナーとは異なり、オリジナルの形状で個性を表現できます。このパーツでは、猫のシルエットパス上をドットが周回する、キュートなローディングアニメーションを実装します。

getPointAtLength()メソッドとrequestAnimationFrameを組み合わせ、パス上をスムーズに移動するアニメーションを実現します。また、prefers-reduced-motionに対応し、アクセシビリティにも配慮した設計となっています。

作成した猫顔風のSVGローディングアニメーション

猫のシルエットパス上をドットが周回するSVGローディングアニメーション。prefers-reduced-motionに対応。

HTML
<div class="spinner" aria-label="loading" role="img">
    <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="100%" viewbox="0 0 315 294" xml:space="preserve">

        <defs>
            <path id="catPath" pathlength="100" d="
M48.818039,20.819481 
	C54.641541,23.650852 60.813469,25.544733 65.292236,29.403715 
	C77.629494,40.033707 89.620316,51.131927 101.005409,62.771461 
	C106.277580,68.161469 111.373672,69.673439 118.288834,67.756615 
	C134.634979,63.225597 151.361298,61.538990 168.209442,63.110565 
	C178.593155,64.079147 188.917114,66.123344 199.126694,68.336166 
	C204.065369,69.406570 207.488815,68.087280 210.754807,64.774933 
	C218.588013,56.830585 226.154434,48.582027 234.442444,41.139927 
	C241.461761,34.836990 248.975967,28.915340 256.934540,23.871708 
	C266.646240,17.717056 273.706940,20.222788 278.974915,30.514168 
	C286.973145,46.139305 288.669769,63.006226 287.371765,80.077843 
	C286.354065,93.462860 284.027344,106.764320 281.909119,120.043388 
	C280.737518,127.388237 280.692230,134.299988 283.145691,141.567368 
	C297.493805,184.068542 286.270691,220.394730 254.363388,250.365692 
	C226.311569,276.715118 191.875809,287.380798 153.758560,286.627411 
	C114.449486,285.850464 79.966713,272.900482 52.915039,243.761566 
	C24.860287,213.542191 17.585888,178.369827 31.361925,139.319672 
	C33.731373,132.603134 33.362537,126.185844 31.796259,119.731323 
	C26.721684,98.819397 24.626087,77.596947 27.343390,56.319523 
	C28.556398,46.821232 32.569111,37.553185 36.209583,28.543653 
	C38.152164,23.736118 42.294483,20.163218 48.818039,20.819481 
z"></path>

        </defs>

        <g id="cat">
            <g id="dotsBase" aria-hidden="true"></g>
            <circle id="dotActive" class="dotActive" r="4.5" cx="0" cy="0"></circle>
        </g>
    </svg>
</div>
CSS
:root{
      color-scheme: dark;
      --bg: #0b0b0b;
      --outline: rgba(255,255,255,0.42);
      --accent: rgba(255,255,255,0.95);
      --size: clamp(14rem, 40vmin, 22rem);
      --speed: 3s;

      /* pathLength=100 を基準にしたドット設計(dot+gap の合計で個数が決まる) */
      --dot-count: 40;
      --dot-size: 10;
      --active-size:13;
      --dot-phase: 0;

      /* グラデ風(先頭が濃く、後ろが薄い) */
      --trail: 38;
      --alpha-min: 0.01;
      --alpha-max: 0.95;

      /* サイズもグラデ風(先頭が大きく、後ろが小さい) */
      --scale-min: 0.35;
      --scale-max: 1;
    }

    html,body{height:100%;}
    body{
      margin:0;
      background: radial-gradient(1200px 800px at 50% 20%, rgba(255,255,255,0.06), transparent 55%), var(--bg);
      display:grid;
      place-items:center;
      overflow:hidden;
      font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
    }

    .spinner{
      width: var(--size);
      height: var(--size);
    }

    svg{display:block;width:100%;height:100%;}

    .dot{
      vector-effect: non-scaling-stroke;
      fill: var(--accent);
      opacity: var(--alpha-min);
    }

    .dotActive{
      vector-effect: non-scaling-stroke;
      fill: var(--accent);
      color: var(--accent);
      filter: drop-shadow(0 0 10px currentColor);
    }

    @media (prefers-reduced-motion: reduce){
      .dotActive{ filter: none; }
    }
JavaScript
(() => {
      const root = document.documentElement;
      const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;

      const svg = document.querySelector('svg');
      const path = document.getElementById('catPath');
      const dotsBase = document.getElementById('dotsBase');
      const dotActive = document.getElementById('dotActive');
      if (!svg || !path || !dotsBase || !dotActive) return;

      const readNumberVar = (name, fallback) => {
        const raw = getComputedStyle(root).getPropertyValue(name).trim();
        const n = Number.parseFloat(raw);
        return Number.isFinite(n) ? n : fallback;
      };

      const dotCount = Math.max(3, Math.round(readNumberVar('--dot-count', 20)));
      const activeSize = Math.max(1, readNumberVar('--active-size', 9));
      const phase = readNumberVar('--dot-phase', 0);
      const durationMs = Math.max(200, readNumberVar('--speed', 4) * 1000);
      const trail = Math.max(2, Math.round(readNumberVar('--trail', Math.min(12, Math.floor(dotCount / 2)))));
      const alphaMin = Math.min(1, Math.max(0, readNumberVar('--alpha-min', 0.12)));
      const alphaMax = Math.min(1, Math.max(0, readNumberVar('--alpha-max', 0.95)));
      const scaleMin = Math.min(1, Math.max(0.05, readNumberVar('--scale-min', 0.35)));
      const scaleMax = Math.min(1.5, Math.max(scaleMin, readNumberVar('--scale-max', 1)));

      const total = path.getTotalLength();
      const phaseLen = (phase / 100) * total;
      const points = Array.from({ length: dotCount }, (_, i) => {
        const t = (i / dotCount) * total + phaseLen;
        return path.getPointAtLength((t % total + total) % total);
      });

      while (dotsBase.firstChild) dotsBase.removeChild(dotsBase.firstChild);
      const dots = [];
      for (const pt of points) {
        const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
        c.setAttribute('class', 'dot');
        c.setAttribute('cx', String(pt.x));
        c.setAttribute('cy', String(pt.y));
        c.setAttribute('r', String((activeSize / 2) * scaleMin));
        dotsBase.appendChild(c);
        dots.push(c);
      }

      dotActive.setAttribute('r', String(activeSize / 2));
      const setActiveIndex = (idx) => {
        const activeIndex = (idx % dotCount + dotCount) % dotCount;
        const pt = points[activeIndex];
        dotActive.setAttribute('cx', String(pt.x));
        dotActive.setAttribute('cy', String(pt.y));

        const denom = Math.max(1, trail - 1);
        for (let i = 0; i < dots.length; i++) {
          if (i === activeIndex) {
            // dotActive と二重に濃くならないようにベース側は消す
            dots[i].style.opacity = '0';
            dots[i].setAttribute('r', String((activeSize / 2) * scaleMin));
            continue;
          }
          const d = (activeIndex - i + dotCount) % dotCount; // 1が直後(尾の1つ目)
          if (d >= 1 && d <= trail) {
            const t = 1 - (d - 1) / denom;
            const a = alphaMin + (alphaMax - alphaMin) * t;
            dots[i].style.opacity = String(a);

            const s = scaleMin + (scaleMax - scaleMin) * t;
            dots[i].setAttribute('r', String((activeSize / 2) * s));
          } else {
            dots[i].style.opacity = String(alphaMin);
            dots[i].setAttribute('r', String((activeSize / 2) * scaleMin));
          }
        }
      };

      setActiveIndex(0);
      if (reduceMotion) return;

      const stepMs = durationMs / dotCount;
      let lastStep = -1;
      const start = performance.now();

      const tick = (now) => {
        const elapsed = now - start;
        const step = Math.floor(elapsed / stepMs);
        if (step !== lastStep) {
          lastStep = step;
          setActiveIndex(step);
        }
        requestAnimationFrame(tick);
      };

      requestAnimationFrame(tick);
    })();

このパーツの特徴

  • カスタムパス対応 猫のシルエットパス上をドットが周回するユニークな演出
  • グラデーション効果 ドットの透明度とサイズが尾に向かって減少する彗星風の表現
  • アクセシビリティ対応 prefers-reduced-motionでアニメーションを停止し、配慮が必要なユーザーにも対応
  • CSSカスタムプロパティ ドットの数、速度、尾の長さなどを簡単にカスタマイズ可能
  • パフォーマンス最適化 requestAnimationFrameを使用し、ブラウザの描画タイミングに合わせた効率的なアニメーション

コードのポイント

SVGパス上の座標取得

getPointAtLength()メソッドを使って、パス上の任意の位置の座標を取得します。これにより、複雑な形状のパス上でも等間隔にドットを配置できます。

const total = path.getTotalLength();
const phaseLen = (phase / 100) * total;
const points = Array.from({ length: dotCount }, (_, i) => {
    const t = (i / dotCount) * total + phaseLen;
    return path.getPointAtLength((t % total + total) % total);
});

ドットのグラデーション表現

先頭からの距離に応じて、透明度とサイズを段階的に変化させることで、尾を引くような視覚効果を実現します。

const d = (activeIndex - i + dotCount) % dotCount;
if (d >= 1 && d <= trail) {
    const t = 1 - (d - 1) / denom;
    const a = alphaMin + (alphaMax - alphaMin) * t;
    dots[i].style.opacity = String(a);

    const s = scaleMin + (scaleMax - scaleMin) * t;
    dots[i].setAttribute('r', String((activeSize / 2) * s));
}

prefers-reduced-motion対応

モーションを減らす設定のユーザーには、アニメーションを停止し、初期状態だけを表示します。

const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;

setActiveIndex(0);
if (reduceMotion) return;

// 以下、アニメーション処理...

CSSカスタムプロパティでカスタマイズ

CSS変数でドットの数、速度、尾の長さなどを定義し、JavaScriptから読み取って使用します。

:root {
    --dot-count: 40;
    --speed: 3s;
    --trail: 38;
    --alpha-min: 0.01;
    --alpha-max: 0.95;
    --scale-min: 0.35;
    --scale-max: 1;
}

requestAnimationFrameで滑らかなアニメーション

ブラウザの描画タイミングに合わせてアニメーションを実行し、パフォーマンスを最適化します。

const tick = (now) => {
    const elapsed = now - start;
    const step = Math.floor(elapsed / stepMs);
    if (step !== lastStep) {
        lastStep = step;
        setActiveIndex(step);
    }
    requestAnimationFrame(tick);
};

requestAnimationFrame(tick);

まとめ

猫顔SVGローディングのポイント
  • SVGパス活用 getPointAtLength()で複雑な形状でも自由にアニメーション可能
  • 視覚的な尾効果 透明度とサイズのグラデーションで彗星のような表現
  • アクセシビリティ prefers-reduced-motionでアニメーション停止に対応
  • 柔軟なカスタマイズ CSS変数で簡単に調整できる設計
  • パフォーマンス最適化 requestAnimationFrameで滑らかかつ効率的なアニメーション

SVGパスを活用することで、円形スピナーでは表現できないオリジナルの形状でローディングアニメーションを作成できます。ブランドのロゴやキャラクターなど、任意のパスで同様の演出を実現できるため、応用範囲の広いテクニックです。

学習チェック

このレッスンを理解できたら「完了」をクリックしてください。
後で見直したい場合は「未完了に戻す」で進捗をリセットできます。

レッスン完了!🎉

お疲れさまでした!