GODRICH

ページ遷移 ローディング 9選 — コピペで動く

ページ遷移 ローディングとは、画面が次の画面へ切り替わるまでのわずかな待ち時間を目に見える形にする仕組みです。この九つは中央で回るスピナーではなく、プログレスバー・色の帯・点線のプレースホルダー・楽観的な遷移のように、画面全体が入れ替わる瞬間を扱うコードです。

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

九つは人気順ではなく、一度の遷移がたどる順に並べてあります。まずはリンクを押した直後です。上部の細い帯が少しずつ伸び(01)、色の帯が画面をワイプして通り過ぎ(02)、一覧の枠が一枚ずつ埋まります(03)。次に来るのは初回訪問と、押した直後の見せ方です。ブランドマークが脈打つスプラッシュ(04)と、見出しだけ先に変わる楽観的な遷移(05)です。それから、画面を読み進めている間です。一覧の末尾が次のページを取りに行き(06)、タブを移ると高さまで伸び縮みし(07)、画像は場所を確保した箱に遅れて浮かび上がります(08)。最後は遷移が失敗したときです。切れた接続を数えながら繋ぎ直すステータスバー(09)がこの並びを締めくくります。スピナー寄りの記事はCSS ローディング スピナー 9選スケルトン UIに、切り替えそのものはCSS ページ遷移アニメーションローディング 画面 デザインにまとめてあります。

01画面の上を横切るルートのプログレスバー

リンクを押すと画面最上部の細い帯が 12 → 38 → 66 → 88 → 100 の 5 段階で伸び、次の画面を待つ時間が目に見えます。帯を伸ばすのは transform: scaleX() なのでレイアウトの再計算が起きず、各段階で aria-valuenow も実際に書き換わります。一覧から詳細への移動が多いマガジンサイトに向き、2 秒ループでは 19 フレームが動きます。

role="progressbar"aria-valuenowscaleX
var rpSteps = [12, 38, 66, 88, 100];
function rpSet(v) {
  rpFill.style.transform = 'scaleX(' + v / 100 + ')';
  rpFill.setAttribute('aria-valuenow', String(v));
}
function rpGo(btn) {
  rp.classList.remove('is-demo');
  rpView.setAttribute('aria-busy', 'true');
  rpSet(0);
  var k = 0;
  rpTimer = setInterval(function () {
    rpSet(rpSteps[k]);
    k += 1;
    if (k < rpSteps.length) return;
    clearInterval(rpTimer);
    rpView.setAttribute('aria-busy', 'false');
  }, 240);
}

02色の帯が走り抜けて中身を入れ替える転換

一枚の色の帯が画面の左外から入って全体を覆い、右へ抜けながら新しい画面を見せます。本文の 2 画面は同じグリッド枠に重ねて置き、帯が覆っている区間の中だけで steps(1, end) のカットで入れ替わるので、半透明の残像が重なりません。画面そのものが作品になるポートフォリオサイトのメニュー遷移に向きます。

translateXsteps(1, end)aria-busy
@keyframes ws-sweep {
  0% { transform: translateX(-101%); }
  22%, 25% { transform: translateX(0); }
  33% { transform: translateX(101%); animation-timing-function: steps(1, end); }
  46% { transform: translateX(-101%); }
  68%, 71% { transform: translateX(0); }
  79%, 100% { transform: translateX(101%); }
}
@keyframes ws-b {
  0%, 22% { opacity: 0; }
  23%, 68% { opacity: 1; }
  69%, 100% { opacity: 0; }
}
wsBand.classList.remove('is-run');
void wsBand.offsetWidth;
wsBand.classList.add('is-run');
setTimeout(function () { wsPages.classList.toggle('is-b'); }, 360);
setTimeout(function () {
  wsBand.classList.remove('is-run');
  wsPages.setAttribute('aria-busy', 'false');
}, 740);

03空の枠が一枚ずつカードで埋まっていく一覧

光る灰色の棒の代わりに、点線の枠が先に場所を取り、カードが 0ms・160ms・320ms の animation-delay でずれて入っていきます。各枠は display: grid で点線とカードを同じ位置に重ねるので下の要素がずれず、揃うと aria-busy が false に戻ります。フィルタを変えるたびに一覧を取り直す検索結果画面に向きます。

aria-busyanimation-delayanimation-fill-mode: backwards
.of__slot {
  display: grid;
  min-height: 36px;
}
.of__ghost {
  grid-area: 1 / 1;
  border: 2px dashed rgba(255, 247, 230, .62);
  border-radius: $r-control;
}
.of__card {
  grid-area: 1 / 1;
  display: flex;
  align-items: center;
  transform: translateY(0);
  transition: transform $duration $easing;
}
.of__list.is-loading .of__card {
  transform: translateY(10px);
  opacity: 0;
}
.of__list.is-loading .of__slot.is-in .of__card {
  transform: translateY(0);
  opacity: 1;
}

04初回訪問で呼吸するブランドマークのスプラッシュ

丸と角の丸い四角だけで作ったブランドマークが transform: scale でゆっくり膨らんで縮み、下の三つの点が順番に跳ねます。案内文は aria-live="polite" 領域にあり、準備完了の知らせはボタンを押したときにだけ出ます。最初の画面をまるごと用意するウェブアプリの初回訪問に向きます。

transform: scaleanimation-delayaria-live
spEnter.addEventListener('click', function () {
  sp.classList.remove('is-demo');
  sp.classList.add('is-ready');
  spMsg.textContent = '준비가 끝났어요';
  spEnter.disabled = true;
});
.sp__dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: $color;
  opacity: .35;
}

05見出しだけ先に変え本文が後から追う楽観的な遷移

タブを押した瞬間、大きな見出しが新しい見出しへカットで切り替わって aria-selected も移りますが、本文はまだ届いていないので aria-busy を立てたまま場所だけ確保し、600ms 後に translateY で上がってきます。プレースホルダーは切り替えと同時に表示されるので、途中のフレームが空白の穴になりません。タブを行き来するダッシュボードに向きます。

steps(1, end)aria-busytranslateY
function ohGo(i) {
  oh.classList.remove('is-demo');
  oh.setAttribute('data-tab', String(i));
  oh.classList.add('is-loading');
  ohBody.setAttribute('aria-busy', 'true');
  for (var k = 0; k < ohTabs.length; k++) {
    ohTabs[k].setAttribute('aria-selected', k === i - 1 ? 'true' : 'false');
  }
  clearTimeout(ohTimer);
  ohTimer = setTimeout(function () {
    oh.classList.remove('is-loading');
    ohBody.setAttribute('aria-busy', 'false');
  }, 600);
}

06一覧の末尾で次のページを取りに行く底のローダー

スクロールコンテナの一番下にあるローダーカードが 90% 見えたところで、IntersectionObserver がそれを捕まえてカードを 3 枚 DOM に追加します。新しいカードは 90ms ずつずれて上がり、増えた枚数は aria-live 領域を通して読み上げられます。終わりのないフィードとして読まれるコミュニティ一覧に向き、ループでは 21 フレームが動きます。

IntersectionObserveraria-livetranslateY
function infWake() {
  if (infIo) return;
  inf.classList.remove('is-demo');
  infIo = new IntersectionObserver(function (es) {
    if (es[0] && es[0].isIntersecting) infAdd();
  }, { root: infBox, threshold: 0.9 });
  infIo.observe(infLoader);
}
['wheel', 'pointerdown', 'keydown', 'touchstart'].forEach(function (ev) {
  infBox.addEventListener(ev, infWake);
});

07タブを移ると高さまで伸び縮みするパネル

パネルを grid-template-rows0fr1fr の間で補間し、内容の高さが分からなくても滑らかに伸ばします。内容の入れ替えはカット、高さはイージングと役割を分けてあり、どのタブが選ばれているかは aria-selected が持ちます。説明の長さがばらばらな料金プランのタブに向きます。

grid-template-rows0fraria-selected
.th__panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows $duration $easing;
}
.th__panel.is-open { grid-template-rows: 1fr; }
.th__clip { min-height: 0; overflow: hidden; }
.th__body {
  padding: $sp-2 0 0;
  opacity: 0;
  transition: opacity $duration steps(1, end);
}
.th__tab[aria-selected="true"] { background: $subject-ink; color: $color; }

08比率の箱が場所を取り後から浮かび上がる画像

画像が届く前に aspect-ratio が同じ大きさの空の箱を確保して文章がずれないようにし、届いた画像はぼかしなしの opacity だけで浮かび上がります。loading="lazy"decoding="async" を実際につけた画像は data:image/svg+xml で始まるデータ URI なので、外部リクエストは発生しません。写真が多く、スクロール中に文章がずれやすい記事本文に向きます。

aspect-ratiodecoding="async"opacity
.lz__box {
  display: grid;
  aspect-ratio: 16 / 9;
  border-radius: $r-sm;
  overflow: hidden;
}
.lz__ph,
.lz__img {
  grid-area: 1 / 1;
  width: 100%;
  height: 100%;
}
.lz__img {
  display: block;
  object-fit: cover;
  opacity: 1;
  transition: opacity $duration $easing;
}

09切れた接続を数えながら繋ぎ直すステータスバー

接続が切れると帯がエラー色に変わり、3 → 2 → 1 秒のカウントダウンが 1 秒ずつカットで切り替わって、ゼロで再接続済みになります。実際の判定は navigator.onLineofflineonline イベントが担い、変わった文言は role="status" 領域から読み上げられます。地下鉄で接続が切れがちなモバイルウェブアプリに向きます。

role="status"steps(1, end)navigator.onLine
function ofDrop() {
  ofState('off');
  var n = 3;
  ofSec(n);
  clearInterval(ofTimer);
  ofTimer = setInterval(function () {
    n -= 1;
    if (n > 0) { ofSec(n); return; }
    clearInterval(ofTimer);
    ofSec(0);
    ofState('back');
  }, 1000);
}
if (!navigator.onLine) ofDrop();
window.addEventListener('offline', ofDrop);
window.addEventListener('online', ofUp);

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

最初に崩れるのは代表フレームです。レンダラーは前のフレームと最も違う一枚を代表画像に選ぶので、プログレスバーが完了して消える場面やステータスバーが閉じる場面では、消える面積が現れる面積を上回り、空の画面が代表として残ります。だから 01 はループでは 88% までしか塗らず、完了のフェードは実際のクリック経路にだけ置き、09 は帯を消さず文言と色だけを替えます。どちらも 2 秒の 90% 以内に、1 フレームのカットで最初の状態へ戻します。24 枚のサンプルの最後が 95.8% 地点なので、それより後ろは一枚も捕まらないからです。次に崩れるのは入れ替えです。02 の帯の入れ替えを opacity のクロスフェードにすると、4.17% 間隔のサンプルの合間に半透明の残像が焼き付くのでカットに切り替え、03 のずらしを animation: の一括指定と併用すると delay が 0s に戻って 3 枠が同時に動きます。個別プロパティに分けて書き、animation-fill-mode: backwards を添えたところ、12 フレームが確保できました。07 では畳む枠に縦パディングを置くと 0fr でもその分が残り、枠が閉じきらないのを実際に確認してから、パディングを内側のラッパーへ移しました。08 は再取得でフェードをやり直すとき、クラスを掛けてから requestAnimationFrame を二回通して外しました。一回だと同じフレームにまとめられて、やり直しが無視されます。06 はプレビューでスクロールコンテナを最下部まで下げておかないと、ローダーカードが最初のフレームに現れません。01 の均等な枠は flex 1 1 0 ではなく grid の 1fr 三つで組みました。scaleX で伸びる帯を flex 1 1 0 の上に重ねると実際の描画幅とずれて、表示が別の枠に掛かって見えるためです。09 の残り時間の帯も同じ理由で、grid で重ねた一枚として組んであります。枠の幅がぴったり均等になるのも、この組み方の利点です。02 のワイプは区間をわざと不均等に取りました。抜ける区間(8%)を入る区間(22%)より短く取ってはじめて、フレームあたりの変化が最も大きい場面が帯の抜け際に掛かり、中身の入れ替えは帯が完全に覆っている 22〜25% と 68〜71% の区間の中でワンフレームのカットとして終わります。この帯を clip-path で削る形も試しましたが、steps のタイミングの中では clip-path が補間されないため、ワイプそのものがカットになって成立しませんでした。結局、画面一枚分の大きさのまま translateX だけで押し出しています。05 ではタブを押したそのフレームである 10% で見出しをカットに差し替え、本文は 40% に届くように二つの時点を離しました。二つを同じフレームに寄せると楽観的という言葉そのものが消えますし、その間が空白の穴にならないように、プレースホルダーは見出しが替わるのと同じフレームで点けています。06 の戻り幅は 6px で、カード同士の間隔 8px より小さく取ってあります。戻るワンフレームでもカードがくっついて見えないようにするための値です。計測値も同じことを示します。同じ 2 秒ループなのに 07 は累積の変化面積が 36.456% で最も大きく、04 は 0.917% で最も小さい値です。高さを補間する 07 は画面全体を揺らし、脈を打つ 04 はマーク一枚しか触らないからです。動いたフレームも 07 が 11 枚、03 が 12 枚と少ない方で、カットが多いほどフレーム数は減り、その代わり 09 のようにアニメーション 13 本を重ねて間を埋めることになります。この九つのコードを開く圧縮ファイルのパスワードは wbq4cn6w で、zip には各項目の計測値と React 版まで入っています。

アクセシビリティ

九つとも prefers-reduced-motion: reduce では装飾のループだけを止め、状態は残します。04 の点は既定の opacity が .35 なので動きを消しても見えたままですし、03 のカードはカットなしで埋まった姿のまま残ります。待ち時間の意味は、見た目ではなく ARIA が守ります。01 の進捗は aria-valuenow に数値で書き込まれ、06 の追加枚数と 09 の文言の変化は aria-live 領域を通してスクリーンリーダーが読み上げます。05 と 07 は左右の矢印キーでタブが移り、フォーカスの移動は focus({ preventScroll: true }) で行います。

項目 文字 / 背景 前景色 背景色 コントラスト
01 本文 / パネル クリーム / インク #fff7e6 #2a262a 13.98:1
02 見出し / カード インク / クリーム #17141a #fff7e6 17.11:1
03 タイトル / オレンジ舞台 インク / オレンジ #17141a #ff4d1f 5.5:1
04 案内文 / 白い舞台 インク / 白 #17141a #ffffff 18.24:1
07 選択タブ / インク クリーム / インク #fff7e6 #17141a 17.11:1
09 オフライン文 / エラー帯 クリーム / エラー #fff7e6 #c2361a 5.13:1
マウス キーボード 状態の書き込み先
01 リンクを押す タブフォーカスで移動 塗り aria-valuenow、本文 aria-busy
02 次の画面を押す ボタンフォーカス後 Enter 本文 aria-busy
03 再読み込みを押す ボタンフォーカス 一覧 aria-busy
04 入るを押す ボタンフォーカス 案内文 aria-live
05 タブを押す 左右の矢印で移動 タブ aria-selected、本文 aria-busy
06 箱をスクロール 箱フォーカス後矢印 aria-live の枚数の文
07 タブを押す 矢印と tabIndex のロービング タブ aria-selected
08 再取得を押す ボタンフォーカス 画像 alt、箱サイズ固定
09 切るを押す ボタンフォーカス role="status"

判断の根拠はMDN の aria-busy の文書IntersectionObserver の文書です。開閉まわりの記事としてはタブメニューの切り替え画面切り替えエフェクトがあります。

FAQ

偽のプログレスバーはどこまで信じてよいですか

偽の進行段階は実際のダウンロード量を知らないので、88% に長く留まるとかえって嘘になります。だから 01 はループで最後の段階を 100 まで進めず、88% で止めています。本番ではレスポンスのストリームから受信済みの量を測れるときに、その値を aria-valuenow へ渡すのが誠実な実装で、なければ終わりの見えない待機より 09 のような再試行のほうがましです。

スケルトンと点線のプレースホルダーは何が違いますか

スケルトンは内容の形を灰色の棒で先に描くもので、03 の点線の枠は場所の存在だけを知らせるものです。形を描くと実際の内容と食い違った瞬間に不正確に見えますが、場所だけ確保しておけば何が来てもずれません。枠の数すら分からないときは、点線ではなく 09 のような状態を伝える文で知らせるのが正解です。

楽観的な遷移が失敗したらどうなりますか

戻せることが前提です。05 は見出しを先に変えますが、本文が届くまで aria-busy が立ったまま残るので、画面が完成したようには見えません。本番ではここに前の画面へ戻る案内を重ねます。失敗を隠すのではなく、待ち時間の主導権を押した人に返すのが楽観的な遷移の条件です。

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

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