本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ローディング

ローディング中に丸の中を波打つ液体が満ちていく

デモ

仕様

動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

丸の中で 2 枚の波が逆向きに流れながら、読み込みの進み具合に合わせて水位が上がっていきます。中央の数字は液体に沈んだ部分だけ白く抜け、沈んでいない部分は濃い色のまま残ります。

前の波は左へ、後ろの波は右へ流れ、どちらも 1 周で波の形がぴったり重なる長さだけ進むので、継ぎ目なく流れ続けます。進み具合が届くたびに 0.5 秒ほどで水位と数字を追いつかせると、飛び飛びの値でもなめらかに見えます。

波を流す動きと、進み具合を追いかける動きを同じ土台で描いていて、どちらか一方だけを更新すると数字と波の位置がずれるので、必ずまとめて描き直します。動きを減らす設定では波を流す動きだけを止め、進み具合の追いつきはほぼ一瞬にして、待たせる時間を長くしません。

波の高さや流れる速さを変えれば、静かな印象にも賑やかな印象にも調整できます。アップロードや同期、インストールなど、残りの量が数でわかる待ち時間のカードに向いています。

コード

HTML
<div class="liquid-loader">
  <div class="liquid-loader__dial" role="progressbar" aria-label="読み込みの進み具合"
       aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
    <svg class="liquid-loader__svg" viewBox="0 0 240 240" aria-hidden="true">
      <defs>
        <linearGradient id="liquid-loader-grad" gradientUnits="userSpaceOnUse" x1="0" y1="-7" x2="0" y2="212">
          <stop offset="0" class="liquid-loader__from" />
          <stop offset="1" class="liquid-loader__to" />
        </linearGradient>
        <clipPath id="liquid-loader-circle"><circle cx="120" cy="120" r="106" /></clipPath>
        <clipPath id="liquid-loader-below">
          <path class="liquid-loader__wave-sync" d="M -240 0 Q -210 -14 -180 0 T -120 0 Q -90 -14 -60 0 T 0 0 Q 30 -14 60 0 T 120 0 Q 150 -14 180 0 T 240 0 Q 270 -14 300 0 T 360 0 Q 390 -14 420 0 T 480 0 L 480 280 L -240 280 Z" />
        </clipPath>
        <mask id="liquid-loader-above" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="240">
          <rect width="240" height="240" fill="#fff" />
          <path class="liquid-loader__wave-sync" fill="#000" d="M -240 0 Q -210 -14 -180 0 T -120 0 Q -90 -14 -60 0 T 0 0 Q 30 -14 60 0 T 120 0 Q 150 -14 180 0 T 240 0 Q 270 -14 300 0 T 360 0 Q 390 -14 420 0 T 480 0 L 480 280 L -240 280 Z" />
        </mask>
      </defs>
      <circle class="liquid-loader__ring" cx="120" cy="120" r="114" />
      <circle class="liquid-loader__glass" cx="120" cy="120" r="106" />
      <g clip-path="url(#liquid-loader-circle)">
        <path class="liquid-loader__wave liquid-loader__wave--back" d="M -240 0 Q -210 -18 -180 0 T -120 0 Q -90 -18 -60 0 T 0 0 Q 30 -18 60 0 T 120 0 Q 150 -18 180 0 T 240 0 Q 270 -18 300 0 T 360 0 Q 390 -18 420 0 T 480 0 L 480 280 L -240 280 Z" />
        <path class="liquid-loader__wave liquid-loader__wave--front liquid-loader__wave-sync" d="M -240 0 Q -210 -14 -180 0 T -120 0 Q -90 -14 -60 0 T 0 0 Q 30 -14 60 0 T 120 0 Q 150 -14 180 0 T 240 0 Q 270 -14 300 0 T 360 0 Q 390 -14 420 0 T 480 0 L 480 280 L -240 280 Z" />
      </g>
      <path class="liquid-loader__shine" d="M 46 86 A 88 88 0 0 1 90 42" />
      <g mask="url(#liquid-loader-above)" class="liquid-loader__digits liquid-loader__digits--above">
        <text x="120" y="138" text-anchor="middle"><tspan class="liquid-loader__digit">0</tspan><tspan class="liquid-loader__unit" dx="2">%</tspan></text>
      </g>
      <g clip-path="url(#liquid-loader-below)" class="liquid-loader__digits liquid-loader__digits--below">
        <text x="120" y="138" text-anchor="middle"><tspan class="liquid-loader__digit">0</tspan><tspan class="liquid-loader__unit" dx="2">%</tspan></text>
      </g>
    </svg>
  </div>
</div>

CSS
.liquid-loader {
  --ground: #F2F1ED; /* 地の色(Paper) */
  --ink: #262626;    /* 濃い数字の色 */
  --ring: rgba(38, 38, 38, 0.12); /* 丸の外の輪 */
  --glass: #F2F1ED;  /* 空の丸の中 */
  --back: #98B1C4;   /* 後ろの波(淡いスレート) */
  --from: #688297;   /* 前の波(上。スレート) */
  --to: #4A5B66;     /* 前の波(下。墨のスレート) */
  position: fixed;
  inset: 0;
  z-index: 10;
  display: grid;
  place-items: center;
  background: var(--ground);
}

.liquid-loader__dial {
  position: relative;
  width: 240px;
  height: 240px;
}

.liquid-loader__svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: hidden;
  border-radius: 50%;
}

.liquid-loader__ring { fill: none; stroke: var(--ring); stroke-width: 5; }
.liquid-loader__glass { fill: var(--glass); }
.liquid-loader__wave--front { fill: url(#liquid-loader-grad); }
.liquid-loader__wave--back { fill: var(--back); }
.liquid-loader__from { stop-color: var(--from); }
.liquid-loader__to { stop-color: var(--to); }
.liquid-loader__shine { fill: none; stroke: #ffffff; stroke-width: 6; stroke-linecap: round; opacity: 0.75; }

.liquid-loader__digits text {
  font: 800 52px/1 "HIGASHIYUKI Font1", "Avenir Next", "Avenir", "Helvetica Neue", "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Yu Gothic", Meiryo, system-ui, sans-serif;
  letter-spacing: -1px;
  font-variant-numeric: tabular-nums;
}

.liquid-loader__digits--above { fill: var(--ink); }
.liquid-loader__digits--below { fill: #ffffff; }
.liquid-loader__unit { font-size: 24px; fill-opacity: 0.7; }

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const TOP = 28, BOTTOM = 241, WAVE = 120, LIFT = 4; // 水面の上端・下端・波長・後ろの波の高さの差

function createLoader(root) {
  const dial = root.querySelector(".liquid-loader__dial");
  const fronts = root.querySelectorAll(".liquid-loader__wave-sync");
  const back = root.querySelector(".liquid-loader__wave--back");
  const digits = root.querySelectorAll(".liquid-loader__digit");
  const wave = { phase: 0 };
  const state = { level: 0, count: 0 };

  // 水位と波の位置を state から毎フレーム描き直す。前の波は左へ 2 波長、後ろの波は右へ 1 波長流れる
  function draw() {
    const surface = BOTTOM + ((TOP - BOTTOM) * state.level) / 100;
    const front = `translate(${(-2 * WAVE * wave.phase).toFixed(2)} ${surface.toFixed(2)})`;
    for (const el of fronts) el.setAttribute("transform", front);
    back.setAttribute("transform", `translate(${(WAVE * wave.phase).toFixed(2)} ${(surface - LIFT).toFixed(2)})`);
    const count = Math.round(state.count);
    for (const d of digits) if (d.textContent !== String(count)) d.textContent = String(count);
    dial.setAttribute("aria-valuenow", String(count));
  }

  // 波は流しっぱなし。動きを減らす設定では止め、静かな水面だけ見せる
  const flow = reduceMotion ? null : gsap.to(wave, { phase: 1, duration: 4.8, ease: "none", repeat: -1, onUpdate: draw });
  draw();

  return {
    // 読み込み率を 0〜1 で渡す。動きを減らす設定ではほぼ一瞬で追いつく
    set(progress) {
      const to = Math.min(1, Math.max(0, progress)) * 100;
      gsap.to(state, { level: to, count: to, duration: reduceMotion ? 0.05 : 0.5, ease: "power2.out", onUpdate: draw });
    },
    // 100% まで届いたあとに呼ぶ
    done() {
      gsap.to(root, {
        autoAlpha: 0,
        duration: reduceMotion ? 0 : 0.3,
        delay: reduceMotion ? 0 : 0.3,
        onComplete: () => {
          flow?.kill();
          root.remove();
        },
      });
    },
  };
}

export const loader = createLoader(document.querySelector(".liquid-loader"));

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。