デモ
デモを読み込めませんでした。
仕様
- 用途
- ローディング
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- MotionPathPlugin
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
MotionPathPlugin で、点を ∞ の形のパスに沿わせます。イーズを無しにすると、一定の速さで回り続けます。
尾は、大きさ・色・不透明度を少しずつ変えた 12 個の点を、0.028 秒ずつずらして走らせたものです(stagger の繰り返し)。色は、先頭のローズから尾の紫へ移ります。地は生成りで、∞ の軌跡は淡い溝として敷いています。
点を、中心を原点にした circle にして、パスと同じ SVG の中に置くと、位置合わせの指定なしで、パスの座標がそのまま点の位置になります。∞ は 4 本の 3 次ベジェ曲線でできていて、交わる所でも向きが途切れずにつながります。
コード
<div class="infinity-loader" role="status">
<svg class="infinity-loader__svg" viewBox="-90 -45 180 90" aria-hidden="true">
<!-- 中心で交わる 8 の字。4 本の 3 次ベジェで、交わる所でも向きが途切れずにつながる -->
<path class="infinity-loader__track"
d="M0,0 C25,-35 75,-35 75,0 C75,35 25,35 0,0 C-25,-35 -75,-35 -75,0 C-75,35 -25,35 0,0 Z" />
</svg>
<p class="infinity-loader__text">読み込み中</p>
</div>
.infinity-loader {
--size: 480px; /* ∞ の横幅 */
--track: 7; /* 軌跡の溝の太さ(viewBox 180×90 の中の値) */
--head: #ff3d68; /* 先頭の点の色 */
--tail: #7c3aed; /* 尾の点の色 */
--line: rgba(38, 38, 38, 0.12); /* 軌跡の溝の色 */
--ground: #f2f1ed; /* 地の色 */
position: fixed;
inset: 0;
z-index: 10;
display: grid;
place-items: center;
background: var(--ground);
}
/* 高さは viewBox の縦横比から決まる。点は溝からはみ出すので切らない */
.infinity-loader__svg {
width: min(var(--size), 72vw);
height: auto;
overflow: visible;
}
.infinity-loader__track {
fill: none;
stroke: var(--line);
stroke-width: var(--track);
stroke-linecap: round;
stroke-linejoin: round;
}
/* 読み上げにだけ届く文言。画面には出さない */
.infinity-loader__text {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
import { gsap } from "gsap";
import { MotionPathPlugin } from "gsap/MotionPathPlugin";
gsap.registerPlugin(MotionPathPlugin);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SVG_NS = "http://www.w3.org/2000/svg";
const LAP = 2.4; // 1 周の時間(秒)
const COUNT = 12; // 尾を含む点の数
const EACH = 0.028; // 点どうしの遅れ(秒)
function createLoader(root) {
const svg = root.querySelector(".infinity-loader__svg");
const track = root.querySelector(".infinity-loader__track");
// 色は CSS が正。GSAP は var(--name) のままでは混ぜられないので、値にして渡す
const styles = getComputedStyle(root);
const head = styles.getPropertyValue("--head").trim();
const tail = styles.getPropertyValue("--tail").trim();
// 先頭ほど大きく濃く、尾ほど小さく薄い点
const dots = Array.from({ length: COUNT }, (_, i) => {
const p = i / (COUNT - 1);
const dot = document.createElementNS(SVG_NS, "circle");
// 中心を原点にして軌跡と同じ SVG に置くと、位置合わせの指定なしでパスの座標が点の位置になる
dot.setAttribute("cx", "0");
dot.setAttribute("cy", "0");
dot.setAttribute("r", String(6.5 - 4 * p));
dot.setAttribute("fill", gsap.utils.interpolate(head, tail, p));
dot.setAttribute("fill-opacity", String(1 - 0.7 * p));
return dot;
});
svg.append(...[...dots].reverse()); // 先頭の点がいちばん上に来るよう、逆順に重ねる
// イーズ無しで速さを一定にすると、1 周で元の位置に戻るのでそのまま繰り返せる
const run = gsap.to(dots, {
motionPath: { path: track },
duration: LAP,
ease: "none",
stagger: { each: EACH, repeat: -1 },
});
// 先頭だけが走っている立ち上がりを見せないよう、尾がそろった所から始める
run.time(LAP);
if (reduceMotion) {
// 走らせず、軌跡の上に等間隔で並べて、順に明滅させる
run.pause();
dots.forEach((dot, i) => gsap.set(dot, { motionPath: { path: track, start: i / COUNT, end: i / COUNT } }));
gsap.to(dots, { opacity: 0.25, duration: 0.6, repeat: -1, yoyo: true, stagger: 0.08 });
}
return {
// 読み込みが終わったら呼ぶ。呼ぶまで回り続ける
// 返ってくるタイムラインに、ページ自身の出現をつなげられる(例: loader.done().add(heroIn, "-=0.2"))
done() {
return gsap
.timeline({ onComplete: () => { gsap.killTweensOf(dots); root.remove(); } })
.to(dots, { scale: 0, duration: 0.3, ease: "back.in(2)", stagger: 0.015 })
.to(root, { autoAlpha: 0, duration: 0.3, ease: "power1.in" }, "<0.15")
.timeScale(reduceMotion ? 3 : 1); // 動きを減らす設定では、ほぼ一瞬で片づける
},
};
}
// 例: 画像や fetch の完了を待って loader.done()
export const loader = createLoader(document.querySelector(".infinity-loader"));