デモ
デモを読み込めませんでした。
仕様
- 用途
- ローディング
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
円を 1 本の線で描き、そのうち見えている長さと、始まりの位置だけを書き換えて弧にします。円周を測らずに済むよう、線の長さを 100 として扱わせる指定(pathLength)を円に付けます。すると、進み具合の % をそのまま長さとして書けます。中央の数字は弧とは別の値で、同じ時間に合わせて数え上げます。
進み方は 0 → 24 → 61 → 100 と段に分け、あいだに 0.2 秒ほどの空きを置きます。等速で 100 まで上がると、読み込みの様子ではなく飾りに見えます。100 に届いたら輪を 0.18 秒だけ 1.06 倍に弾ませ、そのあと 0.55 秒かけて弧を尻尾のほうから抜きます。
丸い線端は、長さが 0 でも点として残ります。弧が空のあいだは隠しておかないと、12 時の位置に小さな点が居座ります。見えている長さの指定(stroke-dasharray)を CSS にも書かないよう気をつけます。CSS が勝って、いくら書き換えても弧が動きません。
実際の読み込みに合わせるには、受け取った量を 0〜1 で渡せるようにします。届いた数字をそのまま書き込まず、0.4 秒でその値まで動かすと、飛び飛びで届く進捗でもなめらかに見えます。読み上げには、輪そのものに進み具合の役割と今の値を持たせ、見えている数字のほうは読み上げから外します。
線の太さと輪の大きさは、CSS の値を変えるだけで合わせられます。ファイルの受信や画像の先読みのように、残りが数で分かる待ちに向いています。数が分からないものに当てると、止まったままの弧が故障に見えるので避けます。
コード
<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: #4f46e5; /* 弧の色(始まり) */
--to: #06b6d4; /* 弧の色(終わり) */
--ink: #262626; /* 数字の色 */
--mute: #6b6b6b; /* % の色 */
--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"));