GODRICH

【コピペOK】ローディング 画面 デザイン CSSだけ 9選

ローディング 画面 デザインとは、サービスが開く最初の瞬間に「今準備しています」と伝える待ち画面のことです。デフォルトのスピナーでも動きますが、パステルのリングや満ちていく水のような画面にすると、同じ待ち時間が意図のあるものに見えます。コピペOK のローディング画面 9種を、動くデモとコード付きでまとめました。

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

9種は人気順ではなく、ローディング画面を選ぶときの順番で並べています。まず無難な最初の一枚であるキャンディリング(01)と見慣れたドット(02)から入り、個性が出るブロブ(03)とボール(04)へ。丁寧な軌道(05)と満ちる水(06)で待ち時間に意味を持たせ、数字カウント(07)とキューブ(08)で進捗を知らせ、ローディングが終わる瞬間を演出するカーテン(09)で締めます。

01キャンディリング回転

パステル3色を混ぜた conic-gradient のリングを mask でドーナツ形にくり抜き、1秒で1回転させます。外側のラッパーは独立した2秒周期で1.12倍まで息をするので、回っているだけのリングよりずっと生きて見えます。暗い最初の画面でキャンディのように目を引く、ダークテーマ向けの代表選手です。

conic-gradientmask$duration
.cr-ring {
  width: 148px;
  height: 148px;
  border-radius: 50%;
  background: conic-gradient(from 0deg, $color, $subject-lilac, $subject-sky, $color);
  mask: radial-gradient(farthest-side, #0000 calc(100% - 22px), #000 calc(100% - 21px));
}
.cr-wrap.is-demo {
  .cr-ring { animation: cr-spin $duration linear infinite; }
  animation: cr-breathe 2s $easing infinite;
}
@keyframes cr-spin { to { transform: rotate(360deg); } }
@keyframes cr-breathe {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.12); }
}

02粘着感のあるドット3つ

真ん中のドット1つだけが左右に動き、両端で止まっている隣のドットに触れるたびに伸びてくっつき、また離れます。最初は blurcontrast のフィルタ組み合わせで作りましたが、黄色い背景ではドットの縁に赤い縁取りが残るため、色ではなくアルファ(不透明度)だけを増幅する SVG の feColorMatrix 方式に変えました。動きはそのまま、色のにじみだけが消えました。

filter: blur + contrasttranslateX$duration
<svg width="0" height="0" style="position:absolute">
  <defs>
    <filter id="goo-filter">
      <feGaussianBlur in="SourceGraphic" stdDeviation="8" result="blur"/>
      <feColorMatrix in="blur" mode="matrix"
        values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 18 -7"/>
    </filter>
  </defs>
</svg>
<div class="goo">
  <span class="goo-dot"></span>
  <span class="goo-dot gd-b"></span>
  <span class="goo-dot"></span>
</div>
.goo {
  display: flex;
  align-items: center;
  padding: $sp-6 $sp-8;
  filter: url(#goo-filter);
}
.gd-b { animation: goo-slide 2s $ease-spring infinite; }
@keyframes goo-slide {
  0%, 50%, 100% { transform: translateX(0); }
  25% { transform: translateX(-68px); }
  75% { transform: translateX(68px); }
}

03形の変わるブロブ

クッションのような形が、8つの border-radius 値を順に切り替えながら90度ずつ回転して、絶えずうごめきます。半径値の4組が2秒のループでちょうど元に戻るので、継ぎ目は見えません。アニメーションを止めた状態でもブロブの形が残るよう、基本の半径を別に用意しました。

border-radiusrotate$duration
.mb-blob {
  width: 112px;
  height: 112px;
  border-radius: 58% 42% 43% 57% / 45% 58% 42% 55%;
  background: $color;
}
.mb-blob.is-demo { animation: mb-morph $duration linear infinite; }
@keyframes mb-morph {
  0%   { border-radius: 58% 42% 43% 57% / 45% 58% 42% 55%; transform: rotate(0deg) scale(1); }
  25%  { border-radius: 45% 55% 60% 40% / 52% 42% 58% 48%; transform: rotate(90deg) scale(1.06); }
  50%  { border-radius: 57% 43% 40% 60% / 55% 47% 53% 45%; transform: rotate(180deg) scale(.96); }
  75%  { border-radius: 42% 58% 57% 43% / 48% 55% 45% 52%; transform: rotate(270deg) scale(1.04); }
  100% { border-radius: 58% 42% 43% 57% / 45% 58% 42% 55%; transform: rotate(360deg) scale(1); }
}

04ペタンとつぶれるボール

ボールが落下し、床に触れた瞬間に高さ0.55倍につぶれ、1拍置いて弾力で跳ね返ります。transform-origin を床の中央に置くことで、つぶれる瞬間もボールの底が床に張り付き、影はボールが高いときは小さく薄く、着地すると大きくなります。落下区間は加速する cubic-bezier(.45, 0, .8, .6) で重力感を出しています。

translateYscale(1.28, .55)transform-origin
.sq-ball {
  position: absolute;
  left: 50%;
  bottom: 2px;
  width: 56px;
  height: 56px;
  margin-left: -28px;
  border-radius: 50%;
  background: $color;
  transform-origin: 50% 100%;
}
.sq-ball.is-demo { animation: sq-bounce 1s cubic-bezier(.45, 0, .8, .6) infinite; }
@keyframes sq-bounce {
  0%   { transform: translateY(-98px) scale(1, 1); }
  55%  { transform: translateY(0) scale(1, 1); }
  68%  { transform: translateY(0) scale(1.28, .55); }
  80%  { transform: translateY(0) scale(.92, 1.08); }
  100% { transform: translateY(-98px) scale(1, 1); }
}

05軌道を回るドット

大きさの違うドット3つが同じ中心を持ち、半径36・52・68px の軌道をそれぞれ回ります。小さいドットほど角速度が速く(1秒対2秒)、惑星が回るような奥行きが生まれ、3つは120度ずつずれた位相から出発するので一か所に固まりません。回しているのは大きな腕の箱ではなくドットそれぞれです — 理由は下の落とし穴で説明します。

rotatetransform-origin$duration
.od-s {                       // 小さいドット: 半径52
  top: 26px;
  left: calc(50% - 6px);
  width: 12px;
  height: 12px;
  transform-origin: 50% 58px; // ドットから系の中心までの距離
  animation: ob-spin-a 1s linear infinite;
}
@keyframes ob-spin-a {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}

06満ちていく水の円

丸い枠の中に水が満ちていき、水面は半径36%の丸い正方形を回転させて作ります。上昇には translate プロパティだけを使い、回転(transform)と衝突しないように分離しました。正方形の角が通るたびにさざ波が立ち、満ちては引く2秒のループなので長い待ち時間でも行き詰まった感じが出ません。

translate: 0 -60pxrotateoverflow: hidden
.lq-tube {
  position: relative;
  width: 150px;
  height: 150px;
  border: 4px solid $subject-ink;
  border-radius: 50%;
  overflow: hidden;
}
.lq-water {
  position: absolute;
  top: 100%;
  left: calc(50% - 55px);
  width: 110px;
  height: 110px;
  border-radius: 36%;
  background: $color;
  animation: lq-wave 2s linear infinite, lq-rise $duration $ease-spring infinite;
}
@keyframes lq-wave { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes lq-rise {
  0%   { translate: 0 0; }
  50%  { translate: 0 -60px; }
  100% { translate: 0 0; }
}

07数字カウントアップ

JavaScript なしで、@property に登録した整数変数 --n が0から100まで上がり、counter() で画面に表示されます。同じ変数を読む細い conic-gradient のリングがまったく同じ速度で満ちるので、数字とゲージはずれません。見えている数字自体は装飾として aria-hidden で隠し、代わりに画面上には見えない文言に role="status" を付けて、支援技術がその文言で進捗を読み上げるようにしています。

@propertycounter()linear
@property --n {
  syntax: "<integer>";
  inherits: true;
  initial-value: 0;
}
.cu-num {
  counter-reset: n var(--n);
}
.cu-num::after { content: counter(n) "%"; }
.cu-box.is-demo { animation: cu-count $duration linear infinite; }
@keyframes cu-count {
  from { --n: 0; }
  to   { --n: 100; }
}

08積み上がるキューブ

正方形4つが左上から右下へ(グリッドの並び順に)120ms間隔で跳ね上がって定位置に収まり、1拍置いて4つ一緒に沈みます。登場(内側キューブの scale)と着地(外側セルの translateY)を二重の要素に分けたので、transform が互いを上書きしません。モジュールを1つずつ読み込む画面のリズムにそのまま合います。

scalestagger$easing
.cs-cell { animation: cs-settle $duration $ease-pop infinite; }
.cs-cube  { animation: cs-pop $duration $ease-pop infinite backwards; }
.cs-cell:nth-child(2) .cs-cube { animation-delay: 120ms; }
.cs-cell:nth-child(3) .cs-cube { animation-delay: 240ms; }
.cs-cell:nth-child(4) .cs-cube { animation-delay: 360ms; }
@keyframes cs-pop {
  0%       { transform: scale(0); opacity: 0; }
  16%      { transform: scale(1.18); opacity: 1; }
  22%, 68% { transform: scale(1); opacity: 1; }
  90%, 100% { transform: scale(0); opacity: 0; }
}
@keyframes cs-settle {
  0%, 60%  { transform: translateY(0); }
  70%      { transform: translateY(8px); }
  80%, 100% { transform: translateY(0); }
}

09カーテンが開く

ローディングが終わる瞬間を演出する締めの1つです。左右のカーテン2枚が外側へ押し開かれ、後ろに隠したコンテンツの場所(タイトル行・本文行・サムネイル)を現し、しばらく開いたままになってからまた閉じて繰り返します。本番ではローディング完了のタイミングでカーテンを開き、クラスを外したままにすればページ遷移のカバーとしても使えます。

translateXoverflow: hidden$duration
.ct-left  { left: 0;  background: $color; }
.ct-right { right: 0; background: $subject-pink; }
.ct-scene.is-demo .ct-left  { animation: ct-open-left $duration $ease-spring infinite; }
.ct-scene.is-demo .ct-right { animation: ct-open-right $duration $ease-spring infinite; }
@keyframes ct-open-left {
  0%, 12%   { transform: translateX(0); }
  38%, 72%  { transform: translateX(-101%); }
  94%, 100% { transform: translateX(0); }
}
@keyframes ct-open-right {
  0%, 12%   { transform: translateX(0); }
  38%, 72%  { transform: translateX(101%); }
  94%, 100% { transform: translateX(0); }
}

どこで壊れるか — 回転する箱はスマホ画面をはみ出す

05番は最初、ドットを載せた大きな正方形の腕ごと回す、よくある作りにしていました。デスクトップでは問題なく見えたのに、320px のスマホ幅チェックで縦のはみ出しが見つかりました。ドット自体は小さいのに、168px の正方形が45度に傾いた瞬間、境界ボックスが対角線の長さ(約237px)まで膨らみ、スクロール高さの計算はその膨らんだ箱をそのまま数えるためです。実際に測ると body のスクロール高さは画面200px に対して218px と出て、overflow: hidden にしても clip にしてもこの計算は止まりません。解決策は回す対象を小さくすることで、ドット1つずつが自分の transform-origin を系の中心に置いてその場で公転すると、境界ボックスはドットが描く円(直径138px)の中にとどまります。回転要素をもう1つ持つ01番のリングは円形なので回しても境界ボックスが変わらないことと対照的です。アーカイブを展開するときのパスワードは 3eqz6dw4 で、入力すると9つのフォルダがそのまま開きます。02番も同じように最初の設計を捨てました — blur+contrast フィルタは黄色い背景の上でドットの縁を赤く染めたため、アルファだけ増幅する SVG 行列フィルタに変えたら跡形もなくきれいになりました。

アクセシビリティ

9つすべて prefers-reduced-motion: reduce でアニメーションが止まります。ただ消えるのではなく、それぞれ使える形を残します — 03番はブロブの形、07番は100%まで満ちたリングと数字、09番は開いたカーテン、04番は空中のボールです。ローディング表示は装飾なので、07番以外は aria-hidden="true" で支援技術から隠し、07番には逆に role="status" と「読み込み中」の文言を入れて進行状況が読み上げられるようにしました。永久ループのアニメーションなので、WCAG 2.2.2 の一時停止基準 と合わせるには、本番ではローディングが終わったら必ず画面から取り除く条件が必要です。この設定は OS の「動きを減らす」オプションと連動しており、詳細は MDN の文書 にまとまっています。

FAQ

ローディング画面は CSS だけでも作れますか?

はい、この9つはすべて JavaScript 0行で作っています。07番の数字カウントでさえ @property で変数を登録して CSS の文法の中で動きます。本番に組み込むときだけ、待ちの開始・終了で .is-loading クラスを付け外す JavaScript が1行必要です。定番のスピナーは CSS ローディングスピナー9種 に、スケルトン画面は スケルトン ローディング UI に別途まとめてあります。

色を自分のブランド色に変えたいです。

各デモの SCSS 先頭にある変数3つ($duration · $easing · $color)のうち $color だけを変えれば変わります。01番のキャンディリングはグラデーションに補助色が2つ($subject-lilac, $subject-sky)入っているので、その2つも一緒に調整してください。React 版は props で同じ値を渡せます。

実際のローディング時間がわからないのにパーセントを表示していいですか?

わからないなら偽のパーセントは見せない方が安全です — 数字が90で止まっていると、待ちは短く感じられるどころか長く感じられます。終わりのタイミングが不明なときは01・02・03番のような終了点のないインジケーターを使い、07番はアップロードのように実際に進捗がわかる場合だけに使ってください。終わる瞬間の演出だけが必要なら09番のカーテンが適しています。

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

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