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

スケルトン チャート 9種 — 数式で完成する瞬間まで

スケルトン チャート 9種のうち、1つは棒ごとの到着遅延を列番号から計算して順に実際の高さへ伸び、1つはヒートマップのセルが対角線距離で計算された遅延だけ色を埋めていき、1つは動きの低減設定なら計算なしで値がすぐ入れ替わります。

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

このセットは9種のチャートローディングスケルトンを一列に並べています。列番号から高さの遅延を求める棒グラフ、線の長さを測ってからほどける折れ線チャート、角度で区間に分かれるドーナツ、行ごとに波打って現れるスパークライン一覧、原点からの距離でフェードインするグリッド、減衰カスケードで収まるマルチシリーズ、対角線状のウェーブで埋まるヒートマップ、指数バックオフ付きの失敗/空/再試行サイクル、そしてそのすべての計算を飛ばすreduced-motion経路です。チャート種類ごとに別々のスケルトン グラフ用ライブラリを用意する代わりに、以下のどのパターンも同じclick/keydownの土台の上に、名前の付いた小さな計算関数を1つだけ載せています。

デモ

遅延が棒ごとに計算されるバーチャートのロード用スケルトン
新しいタブで開く

01 · 遅延が棒ごとに計算されるバーチャートのロード用スケルトン — 計測 累積面積 16.224% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

--phase--realtransform-origin

上のパターン01はbarDelay・paintNow・skeletonNowだけで、列番号を棒の登場タイミングに変えています。残り8種は以下に続き、それぞれ専用のコードブロックが付いています。

9つのチャートローディングスケルトンの解剖 — どこで計算の一層が回るか

# パターン 累積面積 平均強度 動いたフレーム
01 棒の高さスタッガー 16.22% 85.2 23中16
02 折れ線パス表出 1.89% 101.8 23中13
03 ドーナツリング変形 6.74% 48.2 23中18
04 スパークライン行ウェーブ 2.56% 83.8 23中16
05 距離順グリッドフェード 28.61% 29.1 23中9
06 マルチシリーズカスケード 3.49% 117.0 23中15
07 ヒートマップ対角ウェーブ 21.91% 49.1 23中14
08 失敗/空/再試行 7.96% 35.2 23中12
09 reduced-motion即時切替 9.88% 89.6 23中15

9つともrequestAnimationFrameを一度も呼んでいません。遅延はすべて--phaseや--delayのカスタムプロパティ、あるいは単純なanimationDelayとして一度だけ設定され、あとはCSSの@keyframesかtransitionが実際の描画を担います。setTimeoutは遅延が経過したタイミングや状態を自動的に進めるタイミングを決めるためだけに使われています。9つのうち4つ(02・04・06・07)は、実値が届いた瞬間にプレースホルダーのgetAnimations().cancel()も呼んでおり、ループが自然に終わるのを待たずにダッシュのアニメーションを打ち切ります。

パターン05は12個のグリッドセルすべてが一緒にクロスフェードするため9つの中で最も広い面積(累積28.61%)を覆いますが、その一連の動きはmaxOrder(5) × STEP_MS(55) = 275msという短い窓の中で終わるため、平均強度は最も低く(29.1)、動いたフレームも最少(23中9)です — 広いのに短く穏やかということです。パターン02は1本の細い線しか塗り替えないため面積が最小(1.89%)です。パターン06は9つの中で平均強度が最も高く(117.0)、各系列を実際のストローク長とダッシュオフセットへ一気に収める処理が、小さな点やセルではなくパス全体を塗り替えるためです。パターン03は動いたフレームが最多(23中18)で、実際の区間へ切り替わるまでの待ち時間ずっと点線のリングが回り続けるため、ほぼすべてのキャプチャフレームでどこかしらが動いています。

位置番号からそのまま遅延を求めるもの

パターン01・04・05は、長さや角度の計算を挟まず位置番号を遅延に変えます。01のbarDelay(i)は棒の列番号にSTAGGER_MSを掛けます。04のrowDelay(i)も行番号とROW_WAVE_MSで同じことをします。05のcellOrder(row, col)はセルの行と列を足して原点セルからの距離としてランク付けし、そのランクにSTEP_MSを掛けます。

描画前に長さや角度を測るもの

パターン02・03・06は、演出の前に図形自体の幾何情報を測ります。02のdrawPercent(len)は線のgetTotalLength()を読み取り、stroke-dashoffsetのキーフレームを決めるために値を丸めます。03のlayout()は各区間の割合を円周上の弧の長さに変え、そこまでの累積割合をその区間の開始角度として使います。06のseriesDelay(i)も各系列パスのgetTotalLength()を読みますが、そこに系列番号ぶんの減衰する累積和を重ね、後の系列ほど追加の間隔が縮まるようにしています。

距離・バックオフ・reduced-motionの逃げ道

パターン07・08・09は、それぞれ違う種類の歯止めを遅延に加えています。07のwaveDelayMs(row, col)はセルからグリッド右上の角までの実際のユークリッド距離を測り、WAVE_SPEEDで割ります。08のbackoffDelay(n)は再試行回数をBACKOFF_FACTORのべき乗にして、失敗が繰り返されるチャートほど長く待たせます。09のbarDelay(i, reduced)は01とまったく同じ式を再利用しつつ、reduced-motionのフラグを掛けることで、設定がオンのとき全部の棒の遅延が一斉に0へ収まります。

パターン別コード

以下の各コードブロックはそのパターン自身のindex.htmlからそのまま取っています — 変数名も順序も同じで、省略した部分はありません。

01遅延が棒ごとに計算されるバーチャートのロード用スケルトン

灰色のプレースホルダーの棒がまず置かれ、値が届くと各棒の登場遅延を列番号から計算し、その順番で実際の高さまで伸びていきます。ダッシュボードで統計の棒グラフが描かれる前に位置を確保する場所です。

遅延が棒ごとに計算されるバーチャートのロード用スケルトン
新しいタブで開く

01 · 遅延が棒ごとに計算されるバーチャートのロード用スケルトン — 計測 累積面積 16.224% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

--phase--realtransform-origin
  // 計算層: 棒の登場遅延を列番号(i)から求める — 列が右にあるほど遅れて実際の高さまで伸びる
  function barDelay(i) { return i * STAGGER_MS; }

  bars.forEach(function (bar, i) {
    bar.style.setProperty('--real', String(VALUES[i] / 100));
    bar.style.setProperty('--phase', barDelay(i) + 'ms');
  });

  function paintNow(i, value) {
    bars[i].style.transform = 'scaleY(' + (value / 100) + ')';
    bars[i].style.opacity = '1';
  }
  function skeletonNow(i) {
    bars[i].style.transform = 'scaleY(' + SKELETON_SCALE + ')';
    bars[i].style.opacity = '.32';
  }

02平らな線が実際の経路にほどける折れ線チャートのロード

平らな点線がまず流れ、実際の値が届くと線の全長を求めて描画にかかる時間を計算し、曲がりくねった実際の経路へとほどけていきます。月次推移の折れ線チャートがデータを受け取る前に待機する場所です。

平らな線が実際の経路にほどける折れ線チャートのロード
新しいタブで開く

02 · 平らな線が実際の経路にほどける折れ線チャートのロード — 計測 累積面積 1.89% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getTotalLengthstroke-dasharraygetAnimations
  // 計算層: 線の全長(getTotalLength)から描画にかかる区間(%)を求める — 長いほど描画に時間がかかる
  function drawPercent(len) { return Math.max(20, Math.min(48, Math.round(len / PX_PER_PCT))); }

  function prepare() {
    var len = Math.round(real.getTotalLength());
    var pct = drawPercent(len);
    real.style.strokeDasharray = String(len);
    real.style.strokeDashoffset = String(len);
    styleTag.textContent =
      '@keyframes clDraw {' +
      '0%{stroke-dashoffset:' + len + ';}' +
      '14%{stroke-dashoffset:' + len + ';}' +
      pct + '%{stroke-dashoffset:0;}' +
      '86%{stroke-dashoffset:0;}' +
      '100%{stroke-dashoffset:' + len + ';}}';
    real.style.animation = 'clDraw 2000ms ease-in-out infinite';
    return { len: len, pct: pct };
  }

03待機リングが実際の区間に変わるドーナツチャートのロード

点線のリングが回りながら待機し、値が届くと各区間の割合を円周上の角度として計算し、その順番でリングの色が区間ごとの色に分かれていきます。達成率のドーナツチャートが集計される前に少し回る場所です。

待機リングが実際の区間に変わるドーナツチャートのロード
新しいタブで開く

03 · 待機リングが実際の区間に変わるドーナツチャートのロード — 計測 累積面積 6.744% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

--delaystroke-dasharraytransform-origin
  // 計算層: 各区間の割合を円周上の長さに変え、開始角度(累積割合)で順序を決める
  function layout() {
    var cum = 0;
    VALUES.forEach(function (v, i) {
      var frac = v / 100;
      var segLen = CIRC * frac;
      segs[i].style.strokeDasharray = segLen.toFixed(2) + ' ' + (CIRC - segLen).toFixed(2);
      segs[i].style.strokeDashoffset = (-cum).toFixed(2);
      segs[i].style.setProperty('--delay', (cum / CIRC * 60) + '%');
      cum += segLen;
    });
  }

04行ごとに波のように広がるスパークラインのロード

リストの各行にまず平らな点線が置かれ、値が届くと行番号から波の遅延を計算し、上から下へ順に実際のスパークラインが現れていきます。銘柄・指標の一覧で行ごとに推移を先に見せる場所です。

行ごとに波のように広がるスパークラインのロード
新しいタブで開く

04 · 行ごとに波のように広がるスパークラインのロード — 計測 累積面積 2.563% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

--lengetTotalLengthanimationDelay
  // 計算層: 行番号(i)から波の遅延を求める — 上から下に行くほど実際の線が遅れて現れる
  function rowDelay(i) { return i * ROW_WAVE_MS; }

  reals.forEach(function (real, i) {
    var len = Math.round(real.getTotalLength());
    var row = real.closest('.cw__row');
    var flat = row.querySelector('.cw__flat');
    real.style.setProperty('--len', String(len));
    real.style.strokeDasharray = String(len);
    real.style.strokeDashoffset = String(len);
    real.style.animationDelay = rowDelay(i) + 'ms';
    flat.style.animationDelay = rowDelay(i) + 'ms';
    row.style.animationDelay = rowDelay(i) + 'ms';
  });

05グリッド線が距離順に現れるチャートのロード

各セルは原点セルからの(行+列)距離で計算された遅延だけフェードインし、左上から対角線状に広がったあとで実際の指標値が上に乗ります。値の到着が遅れても軸だけは先に配置されているべきレポート画面です。

グリッド線が距離順に現れるチャートのロード
新しいタブで開く

05 · グリッド線が距離順に現れるチャートのロード — 計測 累積面積 28.608% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

--delaytransform-originsetTimeout
  // 計算層: セルの(行+列)を原点からの距離としてランク付けする — 左上から対角線状に広がっていく
  function cellOrder(row, col) { return row + col; }

  cells.forEach(function (cell) {
    var row = Number(cell.getAttribute('data-row'));
    var col = Number(cell.getAttribute('data-col'));
    var delay = cellOrder(row, col) * STEP_MS;
    cell.style.setProperty('--delay', delay + 'ms');
  });

06系列ごとに減衰でずれて収まるマルチシリーズのロード

複数系列のスケルトンが同時に置かれたあと、系列番号に減衰の累積和を掛けた遅延で一つずつ実際の値に収まっていき、すべてが同時には切り替わりません。複数の指標を重ねて見せる比較チャートが値を受け取る場所です。

系列ごとに減衰でずれて収まるマルチシリーズのロード
新しいタブで開く

06 · 系列ごとに減衰でずれて収まるマルチシリーズのロード — 計測 累積面積 3.492% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

--lengetTotalLengthanimationDelay
  // 計算層: 系列の遅延を減衰(等比)の累積和として求める — 後の系列ほど増える間隔が縮まり安定する
  function seriesDelay(i) {
    var total = 0;
    for (var k = 1; k <= i; k += 1) { total += STEP_MS * Math.pow(DAMPING, k - 1); }
    return Math.round(total);
  }

  series.forEach(function (path, i) {
    var len = Math.round(path.getTotalLength());
    path.style.setProperty('--len', String(len));
    path.style.strokeDasharray = String(len);
    path.style.strokeDashoffset = String(len);
    path.style.animationDelay = seriesDelay(i) + 'ms';
  });

07セルが対角線状に埋まるヒートマップのロード

灰色のセルがまず格子状に並び、値が届くと各セルから右上の角までのユークリッド距離を波の速度で割った遅延だけ待ってから、実際の色の濃さに変わっていきます。カレンダー型ヒートマップや相関表が値を受け取る前にセルを確保する場所です。

セルが対角線状に埋まるヒートマップのロード
新しいタブで開く

07 · セルが対角線状に埋まるヒートマップのロード — 計測 累積面積 21.908% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

animationDelaygetAnimationstransform-origin
  // 計算層: 右上の角を波の原点とし、ユークリッド距離を波の速度で割って遅延を求める
  function waveDelayMs(row, col) {
    var dx = (COLS - 1 - col);
    var dy = row;
    var distance = Math.sqrt(dx * dx + dy * dy);
    return Math.round((distance / WAVE_SPEED) * 1000);
  }

08失敗すると空の状態に散っていくチャートのロード

パネルはローディング・失敗・空・実際の棒の4状態を巡り、手動での再試行のたびに再試行回数から計算した指数バックオフだけ長く待たされます。チャートの値が取得できなかったときに再試行を促す場所です。

失敗すると空の状態に散っていくチャートのロード
新しいタブで開く

08 · 失敗すると空の状態に散っていくチャートのロード — 計測 累積面積 7.962% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

matchMediaclassListsetTimeout
  function reduceMotion() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

  // 計算層: 再試行回数(attempt)から指数バックオフの待ち時間を求める — 失敗が繰り返されるほど次の試行までの待ちが長くなる
  function backoffDelay(n) { return Math.round(BASE_BACKOFF_MS * Math.pow(BACKOFF_FACTOR, n)); }

  function showState(name) {
    Object.keys(states).forEach(function (key) {
      states[key].classList.toggle('is-active', key === name);
    });
    panel.setAttribute('aria-busy', name === 'loading' ? 'true' : 'false');
  }

09動きの低減で計算せず即座に切り替わるチャートのロード

画面が動きの低減を優先している場合、同じ遅延の式に0へつぶすフラグを掛けて全部の棒の登場遅延を一斉に0にし、段階的に伸びる代わりにスケルトンが実際の値へそのまま切り替わります。前庭疾患や気が散りやすいユーザーのためのアクセシビリティ設定の場所です。

動きの低減で計算せず即座に切り替わるチャートのロード
新しいタブで開く

09 · 動きの低減で計算せず即座に切り替わるチャートのロード — 計測 累積面積 9.878% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

--phasematchMediatransform-origin
  // 計算層: 同じ式に減衰係数(reduced)を掛ける — 動きの低減時はすべての遅延が0にまとまる
  function barDelay(i, reduced) { return reduced ? 0 : i * STAGGER_MS; }

  normalBars.forEach(function (bar, i) {
    bar.style.setProperty('--real', String(VALUES[i] / 100));
    bar.style.setProperty('--phase', barDelay(i, false) + 'ms');
  });
  reducedBars.forEach(function (bar, i) {
    bar.style.setProperty('--real', String(VALUES[i] / 100));
    bar.style.setProperty('--phase', barDelay(i, true) + 'ms');
  });

値がUIを変える場所 — 遅延計算・対角線ウェーブ・減衰

01 — STAGGER_MS

これはbarDelay(i) = i * STAGGER_MSの中で列番号に掛けられる定数です — 棒の高さそのものではなく、隣り合う棒の登場タイミングがどれだけ離れているかだけを決めます。

06 — DAMPING

これはseriesDelayが系列番号ぶん積み上げる等比の項(STEP_MS · DAMPING^(k-1))の底です — ステップ幅そのものではなく、カスケードが進むにつれて追加の間隔がどれだけ早く縮んでいくかだけを決めます。

07 — WAVE_SPEED

これはwaveDelayMsがセルから右上の角までのユークリッド距離に対して使う除数です — 距離そのものではなく、波が1秒間に何マス分のグリッドを渡るかだけを決めます。

バリエーション 変わる値 体感の違い
STAGGER_MS 70 → 30 棒のスタッガー遅延が70msから30msに barDelay(i) = i * STAGGER_MSの形はそのままに定数だけ小さくなり、棒が左から右へ順に伸びる見た目から、ほぼ一斉に伸びる見た目へ近づきます
DAMPING 0.55 → 0.75 カスケードの減衰値が0.55から0.75に 等比の減衰がより緩やかになり、各系列が実際の値に収まるまでの時間差が広がります
WAVE_SPEED 6 → 3 対角線ウェーブの速度が6から3に 同じ距離でも遅延が2倍になり、ヒートマップが対角線に沿って埋まる速度がゆっくりに感じられます

どこで壊れるか — アクセシビリティ・reduced-motion・キーボードフォーカス

9つとも共通の土台を持っています。ローディング領域にはrole="img"とaria-busy(状態遷移型の08・09はaria-live="polite")が付き、再読み込みや再試行のボタンはすべてclickとkeydownの両方に答え、prefers-reduced-motionはCSSのメディアクエリだけに任せずmatchMediaでも読み取られています。

  • 01 — .cb__barはtransform/opacityの遷移を飛ばしてscaleY(var(--real))と完全な不透明度へ直接切り替わるため、すべての棒が段差なく一斉に実際の高さで表示されます。
  • 02 — 平らなプレースホルダーは即座に隠され(opacity: 0・アニメーション停止)、実際の線のstroke-dashoffsetは!important付きで0に固定されるため、線はほどける途中を見せずに描き切った状態で現れます。
  • 03 — 回転していたリングは即座に消え、すべての区間が段階を踏まずに同時にフルスケール・フル不透明度で現れます。
  • 04 — 各行は波の変形を飛ばし、平らなプレースホルダーは隠され、実際のスパークラインのダッシュオフセットも0に固定されるため、すべての行が一度に完成した線を見せます。
  • 05 — スケルトンのセルは隠され、実際の指標値が即座にフルスケールで現れるため、行+列の順序を目で追う余地がありません。
  • 06 — 平らなプレースホルダーは隠され、すべての系列パスのダッシュオフセットが一度に0へ固定されるため、カスケードは1回の同時切り替えにまとまります。
  • 07 — スケルトンのセルは隠され、すべてのヒートマップのセルが一度に実際の色になるため、対角線状のウェーブをたどることができません。
  • 08 — .cx__stateの遷移が止まるため、ローディング/失敗/空/成功の切り替えは即座になりますが、backoffDelayによる再試行間の指数バックオフはJavaScript側の処理なのでそのまま変わらず動き続けます。
  • 09 — このパターンの主題そのものがreduced-motionの経路です。barDelay(i, true)はすでにすべての棒に対して0を返すので、メディアクエリをオンにすること自体がこのパターン全体の見せどころです。

パスワードtx28eqn3でzipを開いて9個のファイルを並べて読むと、どのパターンがreduced-motion下でもCSSのtransitionを生かしたままにしているか、逆にどのパターンが値を直接代入しているかが最も早く分かります。

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

上の表がすでに確定した3つのバリエーション — STAGGER_MS・DAMPING・WAVE_SPEEDをカバーしています。それぞれ定数を1つだけ入れ替えていて、リスナーもaria-*属性もreduced-motionの分岐も元のパターンとまったく同じままです。バリエーションのindex.htmlは元のファイルの数値を1か所だけ書き換えたものなので、上に書いたアクセシビリティの挙動をバリエーションごとに確認し直す必要はありません。

スケルトン チャート の実装例はどこで見られる?

多くのローディング スケルトンの検索結果は、空のキャンバスにスピナーを重ねただけのものか、棒やセルごとの遅延をまったく持たない単一のスケルトンブロックのどちらかに行き着きます。上の9パターンはその中間に位置し、普通のボタン、普通のclick/keydownリスナー、そして名前の付いた小さな計算関数を1つずつ持つだけで、すべて上のzipからそのままコピーできます。ダッシュボードやレポート画面がこの形の一番の使い道ですが、実際の値が届くまで一瞬空のままになるあらゆるウィジェット — KPIタイル、ステータスパネル、小さな埋め込みレポートにも同じ「値が届いたら表示に切り替える」構造が使えます。9つのどれもチャート用ライブラリやCDNスクリプトには頼っていません。

FAQ

なぜパターン06は9つの中で平均強度が最も高いのですか?

各系列を実際の値に収める処理は、その系列のストローク長とダッシュオフセットをまとめて1回で塗り替えます。さらにseriesDelayが加える系列ごとの遅延差は減衰項がすぐ小さくなるためわずかしかなく、いくつものパス全体の塗り替えが近い時間に重なって起きます。

パターン07の対角線ウェーブは直線距離とグリッドの歩数のどちらで計算していますか?

直線距離です。waveDelayMs(row, col)は右上の角からの水平・垂直方向のずれをそれぞれ2乗して足し、その平方根(本物のユークリッド距離)をWAVE_SPEEDで割っています。行と列を単純に足し合わせているわけではありません。

このスケルトン スクリーン ui のパターン集は実際のダッシュボードにそのまま使っても安全ですか?

click/keydownのリスナー、aria-busy/aria-liveの同期、そして各計算関数はそのまま移せます。デモの固定されたVALUES配列を実際にチャートが取得しているデータへ差し替えるだけで、遅延の計算式は変更不要です。唯一このパターン集にわざと入れていないのは失敗結果のキャッシュなので、実運用に載せる前にその部分だけ自分で足してください。

Sources: MDN — prefers-reduced-motion · MDN — aria-busy · MDN — stroke-dasharray · MDN — Web Animations API · MDN — role="progressbar"

Related reading: 【コピペOK】CSS スケルトン ローディング 9選 · css グラフ をSVGで描く9種 — ライブラリなし · JavaScript グラフ描画 9選 — ライブラリなしのCanvasアニメーション — この2本はSVGやcanvasでチャートの形をゼロから描く話であるのに対し、上のパターンはチャートの実際の値が届く前のローディングの遷移を扱っています。

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

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