デモ
デモを読み込めませんでした。
仕様
- 用途
- ローディング
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
円いゲージの弧が伸びるのに合わせて中央の数字が数え上がり、100に届くと輪が小さく弾んでから、弧が尻尾のほうから消えていきます。円は線で描き、見えている線の長さと描き始めの位置だけを書き換えて弧にしています。円に pathLength を付けて線の長さを100として扱わせているので、円周を計算しなくても、進み具合の%をそのまま長さとして書き込めます。
デモでは、進み方をいくつかの段階に分け、段ごとに少し止めました。一定の速さで最後まで上がると、実際に読み込んでいる様子ではなく、ただの飾りに見えてしまうからです。
つまずきやすいのは線端の丸みです。長さがなくても丸みだけが点として残るので、弧が空のあいだは要素ごと隠しています。そうしないと、12時の位置に小さな点が残ったままになります。
stroke-dasharray は、スクリプトから要素の style に直接書き込み、CSS には置いていません。SVG の属性として書き換える方法だと、CSS に同じ指定があればそちらが優先され、いくら書き換えても弧が動かなくなります。
実際の読み込みに合わせるときは、受け取った量を set に渡します。0.4秒かけてその値まで追いかけるので、進捗が飛び飛びに届いても弧と数字はなめらかです。スクリーンリーダーには輪の要素が進み具合の役割と現在の値を伝え、画面の数字は読み上げに含めていません。量が分からない処理に当てると、弧が途中で止まったままになって壊れているように見えますが、ファイルの受信や画像の先読みのように残りを数で示せる処理なら、その心配はありません。
コード
<div class="ring-loader">
<div class="ring-loader__ring" role="progressbar" aria-label="読み込みの進み具合"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<svg class="ring-loader__svg" viewBox="0 0 100 100" aria-hidden="true">
<defs>
<linearGradient id="ring-loader-arc" x1="0" y1="0" x2="1" y2="1">
<stop class="ring-loader__from" offset="0" />
<stop class="ring-loader__to" offset="1" />
</linearGradient>
</defs>
<circle class="ring-loader__track" cx="50" cy="50" r="42" />
<!-- 線の長さを 100 として扱わせる。12 時から時計回りに描くため -90° 回す -->
<circle class="ring-loader__arc" cx="50" cy="50" r="42"
pathLength="100" transform="rotate(-90 50 50)" />
</svg>
<p class="ring-loader__number" aria-hidden="true"><span class="ring-loader__digits">0</span><span class="ring-loader__unit">%</span></p>
</div>
</div>
.ring-loader {
--size: 232px; /* 輪の直径 */
--stroke: 8; /* 線の太さ(viewBox 100 の中の値) */
--digits: 56px; /* 数字の大きさ */
--from: #4a5b66; /* 弧の色(始まり。濃いスレートカラー) */
--to: #739374; /* 弧の色(終わり。セージ) */
--ink: #262626; /* 数字の色 */
--mute: #898989; /* % の色 */
--line: rgba(38, 38, 38, 0.12); /* トラック(下地の輪)の色 */
--ground: #f2f1ed; /* 背景色 */
position: fixed;
inset: 0;
z-index: 10;
display: grid;
place-items: center;
background: var(--ground);
color: var(--ink);
}
.ring-loader__ring {
position: relative;
width: var(--size);
height: var(--size);
}
.ring-loader__svg {
display: block;
width: 100%;
height: 100%;
}
.ring-loader__track {
fill: none;
stroke: var(--line);
stroke-width: var(--stroke);
}
/* 見えている長さは JS が要素の style に書く。ここで dasharray を決めると、CSS が優先されて動かない */
.ring-loader__arc {
fill: none;
stroke: url(#ring-loader-arc);
stroke-width: var(--stroke);
stroke-linecap: round;
}
.ring-loader__from { stop-color: var(--from); }
.ring-loader__to { stop-color: var(--to); }
/* 数字と % は 1 行に置く。並べるコンテナ(flex / grid)にすると、% が下の段へ落ちる */
.ring-loader__number {
position: absolute;
top: 50%;
left: 0;
right: 0;
margin: 0;
transform: translateY(-50%);
font-size: var(--digits);
font-weight: 800;
line-height: 1;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
text-align: center;
}
.ring-loader__unit {
margin-left: 0.06em;
font-size: 0.45em;
color: var(--mute);
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 12 : 1; // 動きを減らす設定では、ほぼ一瞬で進める
function createLoader(root) {
const ring = root.querySelector(".ring-loader__ring");
const arc = root.querySelector(".ring-loader__arc");
const digits = root.querySelector(".ring-loader__digits");
// 弧の頭(head)・尻尾(tail)と、表示する数字(count)。どれも 0〜100
const state = { head: 0, tail: 0, count: 0 };
const draw = () => {
const visible = state.head - state.tail;
// 長さ 0 でも丸い線端が点で残るので、空のあいだは隠す
arc.style.visibility = visible > 0.05 ? "" : "hidden";
// 線の長さを 100 にしてあるので、% をそのまま長さとして書ける
arc.style.strokeDasharray = `${visible} 100`;
arc.style.strokeDashoffset = String(-state.tail);
digits.textContent = String(Math.round(state.count));
ring.setAttribute("aria-valuenow", String(Math.round(state.count)));
};
return {
// 進み具合を 0〜1 で渡す。飛び飛びで届く値でも、トゥイーンを挟めば滑らかに進む
set(progress) {
const to = Math.min(1, Math.max(0, progress)) * 100;
gsap.to(state, { head: to, count: to, duration: 0.4 / SPEED, ease: "power2.out", onUpdate: draw });
},
// 読み込みが終わったら呼ぶ。100% まで進め、弧を尻尾から抜いて消える
done() {
return gsap
.timeline({ onUpdate: draw, onComplete: () => root.remove() })
.to(state, { head: 100, count: 100, duration: 0.4, ease: "power2.out" })
.to(ring, { scale: 1.06, duration: 0.18, ease: "power2.out", yoyo: true, repeat: 1 })
.to(state, { tail: 100, duration: 0.55, ease: "power2.inOut" })
.to(root, { autoAlpha: 0, duration: 0.3, ease: "power1.in" }, "<0.2")
.timeScale(SPEED);
},
};
}
// 例: 受け取った量を loader.set(loaded / total) で渡し、読み終わったら loader.done()
export const loader = createLoader(document.querySelector(".ring-loader"));