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

アップロード プログレスバー ローダー9種 — 計算するUI

アップロード プログレスバーは、バイトと時間の実測値からリング・速度・チェックの動きを計算して描く部品です。ファイルをドロップすると、バイトと時間から速度を計算してリングが回り、完了するとチェックに変わります。9つの状態をライブラリなしで計算するローダー — コードはパスワード付きzipでそのままお渡しします。

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

デモ

項目 名前 累積面積% 強度平均 動いたフレーム
01 ドロップゾーンハイライト 13.246 55.6 17/24
02 リングパーセント 1.533 121.8 20/24
03 速度ラベル 3.254 163.6 20/24
04 複数ファイルスタガー 11.172 76.7 17/24
05 サムネイルポップイン 4.903 145.3 16/24
06 完了モーフ 1.049 74.6 19/24
07 エラーシェイクとリトライ 2.978 161.2 21/24
08 チャンク一時停止・再開 3.703 87.6 11/24
09 貼り付けキャプチャ 18.947 93.4 15/24

24フレーム実測をひと目で

強度平均は九つとも70を超え、02や06のように面積が1%台でも20フレーム前後は実際に動いています。08だけ11フレームと少ないのは、待機時間そのものが機能だからです。

面積が低いのが正常な理由

このローダー群は背景を塗り替える場面ではなく、リングの弧・数字・小さな枠線だけを動かす部品です。累積面積が1〜3%台に集まるのは合成写真の演出ではなく、コンポーネントとしての実測どおりの姿です。

ドロップゾーンから貼り付けまで、アップロードの旅をたどった九段階

「ドラッグ アンド ドロップ アップロード」で探してたどり着く人がまず触れるのは、境界線だけが反応する01です。02のリングと03の速度ラベルは同じバイト・時間の数字を別の見せ方に変換し、04は列を成すファイルへ規模を広げます。05は読み込み完了という別の信号に、06は100%という到達点に、07は失敗という例外に焦点を移し、08は分割送信という別の転送方式を、09はドラッグを使わない入力経路を見せて締めくくります。ホバーやクリックに反応するボタンのマイクロインタラクション集や、出力を待つ時間だけを担当するCSSだけで作るローディングスピナー集と違い、この9種は実際のバイト数と時間差という本物の入力を計算に使います。

項目別コード

9つとも同じ構造です。イベント(ドロップ・progress・変更・貼り付け)を受け取り、実測値から目標値を計算し、画面はその目標へ慣性で近づきます。

01子要素でもちらつかないドロップゾーンハイライト

dragenter・dragleaveが子ノードをまたぐたびに重複発生する分をカウンター1つで相殺し、ファイルを乗せている間は枠が一度だけ広がり一度だけ戻ります。累積面積13.246%・強度平均55.6・動いたフレーム17/24です。

子要素でもちらつかないドロップゾーンハイライト
新しいタブで開く

01 · 子要素でもちらつかないドロップゾーンハイライト — 計測 累積面積 13.246% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

dragenterdragleavedataTransfer

depthカウンターで重複発火を相殺する

dragenterで+1・dragleaveで−1し、depthが0より大きいときだけis-overを付けるので、内側の要素をまたいでも枠線は途切れません。

// ドロップゾーンのハイライト — 子要素をまたぐ dragenter・dragleave は
// 一度の進入でも何度も発火する。カウンター depth を 0 より大きいときだけ
// ハイライトすれば、子要素の境界をまたいでも枠線がちらつかない。
var depth = 0;

function paint() {
  zone.classList.toggle('is-over', depth > 0);
  badge.style.setProperty('--n', depth);
}
function enter(e) { e.preventDefault(); depth += 1; paint(); }
function leave(e) { e.preventDefault(); depth = Math.max(0, depth - 1); paint(); }
function drop(e) {
  e.preventDefault();
  var fileCount = (e.dataTransfer && e.dataTransfer.files) ? e.dataTransfer.files.length : 0;
  depth = 0; paint();
  zone.classList.add('is-dropped');
  setTimeout(function () { zone.classList.remove('is-dropped'); }, 640);
}
zone.addEventListener('dragenter', enter);
zone.addEventListener('dragleave', leave);
zone.addEventListener('drop', drop);

02円周を計算して回る円形パーセントリング

半径から円周(2πr)を求めてstroke-dasharrayに入れ、進捗ぶんstroke-dashoffsetを巻き戻すと、数値ラベルと同じ値をリングがそのまま描きます。累積面積1.533%・強度平均121.8・動いたフレーム20/24です。

円周を計算して回る円形パーセントリング
新しいタブで開く

02 · 円周を計算して回る円形パーセントリング — 計測 累積面積 1.533% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

stroke-dashoffsetgetBoundingClientRectrequestAnimationFrame

実測した半径から巻き戻し量を出す

getBoundingClientRectで実際の描画サイズを読み、目標値へshownをlerpで近づけるので、進捗が飛んでもリングは滑らかに追いつきます。

// 円形パーセントリング — 半径を実測(getBoundingClientRect)して円周(2πr)を
// 求め、stroke-dasharrayにそのまま入れる。進行率はstroke-dashoffsetを
// 円周から巻き戻す比率で計算するので、数字ラベルとリングは常に同じ値を指す。
var box = prog.getBoundingClientRect();
var measuredR = box.width > 0 ? box.width / 2 : prog.r.baseVal.value;
var circ = 2 * Math.PI * measuredR;
prog.style.setProperty('--circ', String(circ));

var target = 0, shown = 0;
function paint() {
  shown += (target - shown) * 0.18;
  if (Math.abs(target - shown) < 0.05) shown = target;
  prog.style.setProperty('--p', shown.toFixed(2));
  pct.style.setProperty('--n', Math.round(shown));
  if (shown < 100) requestAnimationFrame(paint);
}
function tick() {
  target = Math.min(100, target + 3 + rand() * 9);
  if (target >= 100) clearInterval(timer);
}

03経過バイト差から計算する速度ラベル

progressイベントが来るたびに前回とのバイト差を時間差で割ってKB/sを再計算し、直近数回の平均で値のブレを抑えます。累積面積3.254%・強度平均163.6・動いたフレーム20/24です。

経過バイト差から計算する速度ラベル
新しいタブで開く

03 · 経過バイト差から計算する速度ラベル — 計測 累積面積 3.254% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

performance.nowProgressEventrequestAnimationFrame

5標本の移動平均でブレを抑える

samples配列に最新値を積んで5件を超えたら古い方から捨て、その平均をtargetにしてから表示値をlerpで追わせています。

// 速度ラベル — progressイベントが来るたびに前回との時間差dtと
// バイト差dbからKB/sを再計算し、直近5標本の移動平均を取って
// 瞬間値のブレを抑える。
bus.addEventListener('progress', function (e) {
  var now = performance.now();
  var dt = (now - lastT) / 1000;
  var db = e.loaded - lastLoaded;
  var kbps = dt > 0 ? (db / 1024) / dt : 0;
  samples.push(kbps);
  if (samples.length > 5) samples.shift();
  var sum = 0;
  for (var i = 0; i < samples.length; i++) sum += samples[i];
  var target = sum / samples.length;
  lastT = now; lastLoaded = e.loaded;
  fill.style.transform = 'scaleX(' + (e.loaded / e.total) + ')';
  tick(target);
});
function tick(target) {
  shownKbps += (target - shownKbps) * 0.3;
  speed.style.setProperty('--n', Math.round(shownKbps));
}

04複数ファイルの順送りスタガー

ファイルごとに個別のXHRを並行して開きつつ、リスト行は登録順のインデックス分だけ遅延を掛けて現れるため、5つ一度に投げ込んでも一行ずつ整列して積み上がります。「ファイル アップロード ドラッグ アンド ドロップ 複数」という状況をそのまま計算に落とした項目で、累積面積11.172%・強度平均76.7・動いたフレーム17/24です。

複数ファイルの順送りスタガー
新しいタブで開く

04 · 複数ファイルの順送りスタガー — 計測 累積面積 11.172% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Array.prototype.mapanimation-delayrequestAnimationFrame

登録順のインデックスで遅延を段組みする

row.style.animationDelayにi番目×80msを入れてから同じrequestAnimationFrameループで全行を進めるので、行ごとの開始時刻だけがずれます。

// 複数ファイルの順送りスタガー — 行ごとに登録順のインデックス(--i)分だけ
// animation-delayを掛けて並べるので、まとめて投げ込んでも一行ずつ現れる。
var STAGGER_MS = 80;
var rows = Array.prototype.map.call(list.querySelectorAll('.ms__row'), function (row, i) {
  row.style.animationDelay = (i * STAGGER_MS) + 'ms';
  return { row: row, fill: row.querySelector('.ms__fill'), value: 0 };
});

function step() {
  var done = true;
  rows.forEach(function (r) {
    var target = Number(r.fill.getAttribute('data-target'));
    if (r.value < target) {
      r.value = Math.min(target, r.value + 2 + rand() * 6);
      r.fill.style.transform = 'scaleX(' + (r.value / 100) + ')';
      done = false;
    }
  });
  if (!done) requestAnimationFrame(step);
}

05読み込んだ瞬間に弾むサムネイルポップイン

FileReaderがdataURLの読み込みを終えた瞬間を合図にサムネイルが0からオーバーシュートで拡大し、その下の進捗バーはそこから初めて満ちていきます。累積面積4.903%・強度平均145.3・動いたフレーム16/24です。

読み込んだ瞬間に弾むサムネイルポップイン
新しいタブで開く

05 · 読み込んだ瞬間に弾むサムネイルポップイン — 計測 累積面積 4.903% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FileReadercreateObjectURLtransform

onloadを合図に進捗バーを起動する

readAsDataURLのonloadが発火するまで進捗バーは0のままで、発火後にfillToが900msかけてscaleXを動かします。

// サムネイルポップイン — FileReaderがdataURLの読み込みを終えるonloadを
// 合図にサムネイルが0からオーバーシュートで拡大し、その下の進捗バーは
// そこから初めて満ちていく。
function fillTo(pct, dur) {
  var t0 = performance.now();
  function step(now) {
    var k = Math.min(1, (now - t0) / dur);
    fill.style.transform = 'scaleX(' + (pct * k / 100) + ')';
    if (k < 1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

input.addEventListener('change', function (e) {
  var file = e.target.files && e.target.files[0];
  if (!file) return;
  var reader = new FileReader();
  reader.onload = function () {
    thumb.style.backgroundImage = 'url(' + reader.result + ')';
    thumb.classList.add('is-pop');
    fillTo(100, 900);
  };
  reader.readAsDataURL(file);
});

06完了モーフ — リングがチェックに変わる瞬間

100%に達したフレームでリングのpathをチェック形状に即座に差し替え、短いドローインを掛けることで、円が消えずにチェックへ続けて描かれたように見せます。累積面積1.049%・強度平均74.6・動いたフレーム19/24です。

完了モーフ — リングがチェックに変わる瞬間
新しいタブで開く

06 · 完了モーフ — リングがチェックに変わる瞬間 — 計測 累積面積 1.049% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

stroke-dasharraypathtransition

getTotalLengthで描画長を測ってから巻き戻す

チェックpathのgetTotalLengthを起動時に一度測っておき、is-doneが付いた瞬間にstroke-dashoffsetを0までtransitionで戻します。

// 完了モーフ — 100%に達したフレームでリングを隠しチェックpathを
// 即座に見せてから、stroke-dashoffsetをtransitionで0まで巻き戻して
// draw-inさせる。d属性そのものは補間されないので差し替えは常に瞬間的。
var checkLen = check.getTotalLength();
check.style.setProperty('--checklen', String(checkLen));

var target = 0, shown = 0;
function paint() {
  shown += (target - shown) * 0.2;
  if (Math.abs(target - shown) < 0.1) shown = target;
  ring.style.setProperty('--p', shown.toFixed(2));
  if (shown >= 100) {
    root.classList.add('is-done');
    return;
  }
  requestAnimationFrame(paint);
}
function tick() {
  target = Math.min(100, target + 6 + rand() * 10);
}

07途切れた地点を覚えるエラーシェイクとリトライ

XHRがerror・abortを投げると、その時点のloaded値を残したままバーが左右に揺れ、リトライは最初からではなく残りバイトだけを再要求します。累積面積2.978%・強度平均161.2・動いたフレーム21/24と、9つで最も多く動きます。

途切れた地点を覚えるエラーシェイクとリトライ
新しいタブで開く

07 · 途切れた地点を覚えるエラーシェイクとリトライ — 計測 累積面積 2.978% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ProgressEventAbortControllerkeyframes

loadedを0に戻さずAbortControllerで断つ

retryのクリックは新しいAbortControllerを作って前の試行だけを断ち、run関数には現在のloadedをstartAtとして渡すので続きから再開します。

// エラーシェイクとリトライ — errorイベントが来た時点のloaded値を
// 残したままバーが揺れ、リトライはAbortControllerで前の試行を
// 断ってから最初にではなく残りバイトだけを再送する。
var FAIL_AT = 58;
function run(startAt) {
  loaded = startAt;
  controller = new AbortController();
  timer = setInterval(function () {
    if (controller.signal.aborted) return;
    loaded = Math.min(100, loaded + 4 + rand() * 8);
    if (!failedOnce && loaded >= FAIL_AT) {
      failedOnce = true;
      clearInterval(timer);
      bus.dispatchEvent(new ProgressEvent('error', { loaded: loaded, total: 100 }));
      return;
    }
    bus.dispatchEvent(new ProgressEvent('progress', { loaded: loaded, total: 100 }));
    if (loaded >= 100) clearInterval(timer);
  }, 220);
}
retry.addEventListener('click', function () {
  controller.abort();
  root.classList.remove('is-error');
  run(loaded);
});

08送信済み分だけ繋がるチャンク一時停止・再開

ファイルをBlob.sliceで固定サイズに分け順番に送り、一時停止すると最後に確認したチャンク番号を保存し、再開はその番号から計算し直して繋ぎます。累積面積3.703%・強度平均87.6・動いたフレーム11/24で、9つのうち最も待機の比重が高い項目です。

送信済み分だけ繋がるチャンク一時停止・再開
新しいタブで開く

08 · 送信済み分だけ繋がるチャンク一時停止・再開 — 計測 累積面積 3.703% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Blob.slicefetchAbortController

lastConfirmedから数え直して再開する

一時停止はcontroller.abort()で進行中の1件だけを断ち、再開はfor文をlastConfirmedから開始するので送信済みの断片はやり直しません。

// チャンク一時停止・再開 — Blob.sliceで固定サイズに切り、順番に送る。
// 一時停止はAbortControllerで進行中の要求だけを断って最後に確認した
// 番号を保存し、再開はその番号から数え直して繋ぐ(最初から送り直さない)。
var CHUNK_BYTES = 256 * 1024;
var TOTAL_CHUNKS = 6;

async function sendFrom(startIdx) {
  running = true;
  controller = new AbortController();
  for (var i = startIdx; i < TOTAL_CHUNKS; i++) {
    if (paused) { running = false; return; }
    var chunk = blob.slice(i * CHUNK_BYTES, (i + 1) * CHUNK_BYTES);
    try {
      await fetch(location.href, { method: 'HEAD', signal: controller.signal });
    } catch (err) {
      // このデモには実際のアップロードサーバーがない — 失敗しても次の断片へ進む。
    }
    if (paused) { running = false; return; }
    lastConfirmed = i + 1;
    paint(lastConfirmed);
    await delay(160 + rand() * 140);
  }
  running = false;
}

09貼り付けキャプチャ — Ctrl+Vで始まるアップロード

画面のどこかにフォーカスした状態でキャプチャ画像を貼り付けると、clipboardData.itemsからファイルを取り出しドロップゾーンと同じ進捗パイプラインに流し込み、ドラッグなしで同じリングが回ります。累積面積18.947%・強度平均93.4・動いたフレーム15/24で、9つのうち面積は最大です。

貼り付けキャプチャ — Ctrl+Vで始まるアップロード
新しいタブで開く

09 · 貼り付けキャプチャ — Ctrl+Vで始まるアップロード — 計測 累積面積 18.947% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pasteclipboardDataDataTransfer

kindがfileの項目だけを取り出す

items配列をループしてkind==='file'かつtypeがimage/で始まる項目だけgetAsFile()し、進捗を持たない即時キャプチャなので620msのスピンだけを挟みます。

// 貼り付けキャプチャ — ドラッグなしでCtrl+Vだけでクリップボードの
// 画像を受け取る。clipboardData.itemsからimage/*項目を探してファイルに
// 取り出し、インディターミネートリングを少し回してからサムネイルへ差し替える。
function process(file) {
  root.classList.remove('is-idle');
  root.classList.add('is-spin');
  objectUrl = URL.createObjectURL(file);
  setTimeout(function () {
    img.src = objectUrl;
    root.classList.remove('is-spin');
    root.classList.add('is-done');
  }, 620);
}

root.addEventListener('paste', function (e) {
  if (!e.clipboardData) return;
  var items = e.clipboardData.items;
  for (var i = 0; i < items.length; i++) {
    if (items[i].kind === 'file' && items[i].type.indexOf('image/') === 0) {
      var file = items[i].getAsFile();
      if (file) { process(file); break; }
    }
  }
});

02のリング太さ・04の間隔・08のチャンクサイズを変えるとアクセシビリティ対応も見えてくる

変形 何を変えるか 効果
02 stroke-width 6pxから14pxへ太くする リングの存在感が増し、進捗の視認性が上がる
04 STAGGER_MS 80msから220msへ伸ばす 行の出現間隔が広がり、ファイル数を数えやすくなる
08 CHUNK_BYTES 256KBから1MBへ拡大する 分割数が減り、一時停止の粒度が粗くなる

02のstroke-widthを変える

--sw変数だけを差し替えれば、円周計算やlerpのロジックには一切触れずに太さだけが変わります。

04のSTAGGER_MSを変える

STAGGER_MS定数を220にすると、5行の出現が体感でもはっきり数えられるほど間延びします。

08のCHUNK_BYTESを変える

CHUNK_BYTESを1MBにするとTOTAL_CHUNKSは同じ6のまま1回あたりの送信量だけが増え、一時停止の区切りが粗くなります。

prefers-reduced-motionと02の固定値

OSの「視差効果を減らす」設定が有効なとき、02のリングはlerpによる巻き戻しアニメーションを止めて62%の位置に固定表示されます。「react ファイル アップロード ドラッグ アンド ドロップ」の実装でも、この計算部分はDOM操作の層を差し替えるだけで同じロジックのまま移植できます。

depthカウンターなしでdragenter・dragleaveを扱うと壊れる場所

レイアウト前にrectを測ると半径が0になる

要素がまだ描画されていない段階でgetBoundingClientRectを呼ぶとwidthが0になり、円周も0のまま固定されてしまいます。02・06のコードがbox.width > 0を確認してからprog.r.baseVal.valueへフォールバックしているのはこのためです。

リトライでloadedを0に戻す

07のリトライボタンでloadedを0に初期化すると、ユーザーには進んだ分まで消えて見えます。正しい実装はcontroller.abort()で前の試行だけを断ち、run(loaded)で現在値から再開します。

一時停止フラグをループの外だけで見る

08でpausedをfor文の先頭でしか確認しないと、awaitで止まっている最中の一時停止指示を取りこぼします。チャンクごとの送信前後どちらでもpausedを見直すことで、止めた瞬間にすぐ止まります。

瞬間値をそのまま表示してブレさせる

progressイベント1回分のKB/sだけをそのまま出すと、ネットワークの揺らぎで数字が跳ねます。zipの中身はq73rkhs7で解凍でき、samples配列で平均を取る03のコードをそのまま見比べられます。

なぜ数値とリングが常に一致するのか — 円周と時間差の計算

「ファイル アップロード 進捗 表示」を担う部品が数字とグラフを別々に描くと、両者はいつかズレます。この9種が一致し続けるのは、両方を同じ1つの値から導くからです。

半径の実測から円周を求める

02・06のリングはgetBoundingClientRectで自分の実寸を読み、そこから円周を計算してstroke-dasharrayに入れます。stroke-dashoffsetはその円周に進捗率を掛けた分だけ巻き戻すので、CSSがどれだけ拡大縮小されても比率は崩れません。

バイト差と時間差から速度を導く

03の速度ラベルはprogressイベントごとにloadedの差分を時間差で割ってKB/sを出し、01の枠線はdragenter・dragleaveの回数差だけを見ます。どちらも「前回との差」という同じ形の計算で、絶対値ではなく変化量を扱うから途中の状態を正しく表せます。

どこで使う画面か — アップロードフォームを作り直すチーム

添付ファイルの入力欄を作り直すチームは、まず01のドロップゾーンハイライトから当てはめます。履歴書や契約書を1つだけ受け取る欄でもアイコンや説明文が子要素として入っていることが多く、depthカウンターのおかげで境界をまたいでも枠線が一度だけ点いて一度だけ消えます。

商品画像や添付資料をまとめて登録する管理画面には04の複数ファイルスタガーが向いています。行が順番に積み上がるリズムで選んだ枚数が一目でわかり、5枚目まで待つ体感時間はSTAGGER_MSの値ひとつで調整できます。

動画やバックアップのような大きなファイルの送信には08のチャンク一時停止・再開が要ります。再開がlastConfirmedから始まるため、モバイル回線が途切れても最初から送り直しにはなりません。

アップロード進捗の9枚を実装に組み込む前によくある質問

ドラッグ&ドロップに対応していないブラウザではどうなりますか

すべての項目にinput要素またはクリック操作の代替経路があり、ドロップイベントが使えない環境でもファイル選択ダイアログから同じ進捗パイプラインへ入れます。

進捗バーの数値が実際の転送速度と違って見えるのはなぜですか

03のラベルは直近5標本の移動平均を表示しているため、瞬間の回線速度そのものではなく数秒間ならした値になります。これはブレを抑えるための意図した挙動です。

複数ファイルを同時に扱うにはどう改造すればよいですか

04のrows配列に行を増やすだけで対応できます。「ファイル アップロード ドラッグ アンド ドロップ 複数」を一度に処理したい場合も、STAGGER_MSの遅延計算とdata-targetの読み取りはそのまま使えます。

もっと読む

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

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