デモ
デモを読み込めませんでした。
仕様
- 用途
- ローディング
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
丸の中で 2 枚の波が逆向きに流れながら、読み込みの進み具合に合わせて水位が上がっていきます。中央の数字は液体に沈んだ部分だけ白く抜け、沈んでいない部分は濃い色のまま残ります。
前の波は左へ、後ろの波は右へ流れ、どちらも 1 周で波の形がぴったり重なる長さだけ進むので、継ぎ目なく流れ続けます。進み具合が届くたびに 0.5 秒ほどで水位と数字を追いつかせると、飛び飛びの値でもなめらかに見えます。
波を流す動きと、進み具合を追いかける動きを同じ土台で描いていて、どちらか一方だけを更新すると数字と波の位置がずれるので、必ずまとめて描き直します。動きを減らす設定では波を流す動きだけを止め、進み具合の追いつきはほぼ一瞬にして、待たせる時間を長くしません。
波の高さや流れる速さを変えれば、静かな印象にも賑やかな印象にも調整できます。アップロードや同期、インストールなど、残りの量が数でわかる待ち時間のカードに向いています。
コード
<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>
.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; }
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"));