デモ
デモを読み込めませんでした。
仕様
- 用途
- ローディング
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
進み具合が上がるにつれて、輪郭だけで描いた大きな「LOADING」の中が、下から白く満ちていきます。塗りの上端には横線を引き、その両端に工程名と%を乗せました。仕組みは、輪郭の文字の上に白(#FFFFFF)で塗った同じ文字を重ね、clip-path で見せる範囲の上端を下から上へ動かしているだけです。輪郭のほうは文字の色を薄めた線にしてあるので、満ちきったところで白い「LOADING」が浮かび上がります。
塗りが動く範囲は、ベースラインのすぐ下から字の上端のすぐ上まで。ぴったり合わせると、O や G のような丸い文字は、線から少しはみ出している部分が平らに切れてしまいます。上下に少しずつ余白を残したのは、そのためです。
進み具合が飛び飛びに届いても塗りがなめらかに上がるよう、値が変わるたびに0.5秒ほどの power2.out で新しい値まで追いかけています。100%に達したら一呼吸おいてから、塗りを上へ抜いて消します。満ちた状態をすぐに消さないので、読み込みが終わったことが目に残ります。
文言は、HTML の輪郭側だけを書き換えれば足ります。塗り側の文字は、スクリプトが読み込み時に輪郭側を複製して作っているからです。ただし複製するのは最初の1回だけで、動き始めてから輪郭側を書き換えても、塗り側には届きません。塗りの色は CSS 変数の --fill で、文字の大きさは font-size で変えます。
特設サイトやポートフォリオの起動画面のように、ローディングそのものを演出として見せる場面のための表示です。数字だけで足りる場面なら、もっと控えめな表示のほうが読みやすいこともあります。
コード
<!-- 読み込みの進み具合。数字と工程名は HTML に書き、JS はそこから読んで動かす -->
<div class="fill-loader">
<div class="fill-loader__panel" role="progressbar" aria-label="読み込みの進み具合"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<p class="fill-loader__word" aria-hidden="true">LOADING</p>
<p class="fill-loader__row" aria-hidden="true">
<span class="fill-loader__step">フォントを読み込み中</span>
<span class="fill-loader__percent"><span class="fill-loader__digits">0</span><span class="fill-loader__unit">%</span></span>
</p>
</div>
</div>
.fill-loader {
--ink: #eeece6; /* 輪郭・線・工程名・% の色 */
--mute: #a8a6a0; /* % の単位の色 */
--ground: #111111; /* 背景色 */
--fill: #ffffff; /* 満ちる塗りの色 */
position: fixed;
inset: 0;
z-index: 10;
display: grid;
place-items: center;
background: var(--ground);
color: var(--ink);
}
/* 塗りの top(JS が書く)の基準にする。行の高さは word の font-size に一致する */
.fill-loader__panel { position: relative; }
.fill-loader__word {
position: relative;
margin: 0;
/* 塗りの縁(script.js の BASE / CAP)は、このフォントスタックで測った値。書体を変えたら合わせ直す */
font-family: "HIGASHIYUKI Font1", "Avenir Next", "Avenir", "Helvetica Neue", "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Yu Gothic", Meiryo, system-ui, sans-serif;
font-size: clamp(40px, 20vw, 144px);
font-weight: 800;
line-height: 1;
letter-spacing: -0.02em;
white-space: nowrap;
color: var(--ground);
-webkit-text-stroke: 0.028em color-mix(in srgb, var(--ink) 30%, transparent);
paint-order: stroke fill;
}
/* 塗りは輪郭と同じ文字を JS が複製し、塗りの色で塗って clip-path の上端・下端で隠す */
.fill-loader__fill {
position: absolute;
inset: 0;
color: var(--fill);
}
/* 塗りの縁の線。工程名と % を両端に乗せる。top は JS が塗りの上端に合わせて動かす */
.fill-loader__row {
position: absolute;
left: 0;
right: 0;
top: 85%;
margin: 0;
height: 2px;
background: var(--ink);
}
.fill-loader__step,
.fill-loader__percent {
position: absolute;
bottom: 8px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.06em;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.fill-loader__step { left: 0; }
.fill-loader__percent { right: 0; }
.fill-loader__unit { margin-left: 0.06em; color: var(--mute); }
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 12 : 1; // 動きを減らす設定では、ほぼ一瞬で進める
// 塗りの縁(clip-path の上端・下端 %)。0% はベースラインのすぐ下、100% は字の上端のすぐ上
const BASE = 85;
const CAP = 7.5;
// 進み具合の節目と、そのときの工程名(読む人がここを差し替える)
const STEPS = [
[0, "フォントを読み込み中"],
[34, "画像を読み込み中"],
[68, "シーンを準備中"],
[100, "準備完了"],
];
function createLoader(root) {
const panel = root.querySelector(".fill-loader__panel");
const word = root.querySelector(".fill-loader__word");
const step = root.querySelector(".fill-loader__step");
const digits = root.querySelector(".fill-loader__digits");
const row = root.querySelector(".fill-loader__row");
// 塗りは輪郭と同じ文字を複製し、塗りの色を付けて clip-path で隠す
const fill = document.createElement("span");
fill.className = "fill-loader__fill";
fill.textContent = word.textContent;
word.append(fill);
const state = { value: 0, wipe: 0 };
const draw = () => {
const value = Math.round(state.value);
const top = BASE - ((BASE - CAP) * state.value) / 100;
const bottom = 100 - (BASE - (BASE - CAP) * state.wipe);
const label = STEPS.findLast(([from]) => value >= from)[1];
fill.style.clipPath = `inset(${top}% 0 ${bottom}% 0)`;
row.style.top = `${top}%`;
digits.textContent = String(value);
step.textContent = label;
panel.setAttribute("aria-valuenow", String(value));
panel.setAttribute("aria-valuetext", `${value}%・${label}`);
};
draw();
return {
// 進み具合を 0〜1 で渡す。飛び飛びに届く値でも、トゥイーンを挟めば滑らかに進む
set(progress) {
const to = Math.min(1, Math.max(0, progress)) * 100;
gsap.to(state, { value: to, duration: 0.5 / SPEED, ease: "power2.out", onUpdate: draw, overwrite: true });
},
// 読み込みが終わったら呼ぶ。100% まで進め、少し置いてから塗りを上へ抜いて消える
done() {
return gsap
.timeline({ onUpdate: draw, onComplete: () => root.remove() })
.to(state, { value: 100, duration: 0.4 / SPEED, ease: "power2.out" })
.to(state, { wipe: 1, duration: 0.5 / SPEED, ease: "power2.inOut" }, "+=0.25")
.to(root, { autoAlpha: 0, duration: 0.3 / SPEED, ease: "power1.in" }, "<0.2");
},
};
}
// 例: 受け取った量を loader.set(loaded / total) で渡し、読み終わったら loader.done()
export const loader = createLoader(document.querySelector(".fill-loader"));