デモ
デモを読み込めませんでした。
仕様
- 用途
- ローディング
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
読み込み中の画面の下に、オリーブグレーの色の幕をもう 1 枚重ねておきます。読み込みが終わったら、進み具合の数字と帯を消し、読み込み中の画面と色の幕を、同じイージングのまま少しずらして上へ抜きます。ずらした分だけ 2 枚の間に色の帯が走り、幕が上がったことがはっきり伝わります。幕が通り過ぎたところで、ページの見出しが行ごとにマスクの下からせり上がります。
2 枚はどちらも 1 秒の power3.inOut で上げ、色の幕を 0.1 秒遅らせます。遅れを大きくすると色の帯が太くなり、幕の印象が強まります。見出しは、色の幕が動き出して 0.52 秒後から、下の行を先に 0.08 秒ずつずらし、0.9 秒の expo.out でせり上げます。幕は下から開くので、下の行から出すと、縁が通り過ぎた順に文字が現れます。
見出しの行を包むマスクを行の箱ぴったりにすると、欧文の g や y のように下へはみ出す字の端が切れて、別の字に読めます。和文の見出しにも英字や数字は混ざるので、マスクの下に字の大きさの 0.06 倍ほどの余白を足し、同じだけ負の余白(margin-bottom)で詰めて、行の間隔を保ちます。
実際の読み込みに合わせて動かすため、進み具合を 0〜1 で渡す set と、読み込みの終わりを知らせる done を用意します。渡した値へは 0.4 秒で追いかけるので、飛び飛びに届く進み具合でもなめらかに進みます。終わりを知らせると 100 まで進めてから幕を上げ、返すタイムラインにページ自身の出現をつなげられます。進み具合は帯に進捗の役割と今の値を持たせて読み上げに届け、大きな数字は読み上げから外します。
動きを減らす設定では、幕を動かさずに薄くして消します。つまみは、幕の色と遅れ、上げる時間です。読み込みがすぐ終わるページでは、幕だけが一瞬よぎって騒がしく見えるので、画像の多いポートフォリオや、最初に大きな画像を読むページのように、待ち時間が実際にある所に向いています。
コード
<!-- 読み込み中の幕。ページの上に重ねておき、読み込みが終わったら上へ抜ける -->
<div class="curtain">
<div class="curtain__accent"></div>
<div class="curtain__panel">
<div class="curtain__content">
<div class="curtain__row">
<p class="curtain__label">読み込み中</p>
<p class="curtain__count" aria-hidden="true"><span class="curtain__digits">0</span><span class="curtain__unit">%</span></p>
</div>
<div class="curtain__track" role="progressbar" aria-label="読み込みの進み具合"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="curtain__bar"></div>
</div>
</div>
</div>
</div>
<main class="page">
<!-- 見出しは行ごとにマスクで包み、幕が上がったあと下の行からせり上げる -->
<h1 class="page__title">
<span class="page__line"><span class="page__text">心が動く、</span></span>
<span class="page__line"><span class="page__text">ものづくり。</span></span>
</h1>
<p class="page__lead">ここに説明文が入ります。</p>
<a class="page__button" href="/work/">制作実績を見る</a>
</main>
.curtain {
--panel: #262626; /* 読み込み中の幕 */
--accent: #c8bf83; /* 追いかける色の幕 */
--ink: #eeece6; /* 数字と進み具合の帯 */
--mute: #898989; /* % */
--mute-small: #a8a6a0; /* 小さい文言 */
--line: rgba(238, 236, 230, 0.14); /* 帯の溝 */
position: fixed;
inset: 0;
z-index: 10;
}
/* 下に色の幕、上に読み込み中の幕。どちらも画面の高さぶん上へ送って抜く */
.curtain__accent,
.curtain__panel { position: absolute; inset: 0; }
.curtain__accent { background: var(--accent); }
.curtain__panel {
display: grid;
align-content: center;
padding: 0 clamp(24px, 7vw, 96px);
background: var(--panel);
color: var(--ink);
}
.curtain__row { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; }
.curtain__label { margin: 0 0 10px; color: var(--mute-small); font-size: 13px; font-weight: 600; letter-spacing: 0.06em; }
/* 数字と % は 1 行に置く(flex や grid で並べると、% が下の段へ落ちる) */
.curtain__count {
margin: 0;
font-size: clamp(48px, 8vw, 72px);
font-weight: 800;
line-height: 1;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.curtain__unit { margin-left: 4px; color: var(--mute); font-size: 0.4em; }
.curtain__track { height: 4px; margin-top: 16px; overflow: hidden; background: var(--line); }
.curtain__bar { height: 100%; background: var(--ink); transform: scaleX(0); transform-origin: 0 50%; }
/* 幕の下のページ */
.page {
--bg: #111111;
--ink: #eeece6;
min-height: 100vh;
box-sizing: border-box;
padding: 200px clamp(24px, 7vw, 96px) 96px;
background: var(--bg);
color: var(--ink);
}
.page__title { margin: 0; font-size: clamp(36px, 7vw, 72px); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; }
/* 行を包むマスク。下に余白を足し、欧文の g や y の下が切れないようにする(同じだけ負の余白で行の間隔を保つ) */
.page__line { display: block; overflow: hidden; padding-bottom: 0.06em; margin-bottom: -0.06em; }
.page__text { display: block; }
.page__lead { max-width: 34em; margin: 32px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
.page__button {
display: inline-block;
margin-top: 40px;
padding: 0 32px;
border-radius: 999px;
background: var(--ink);
color: var(--bg);
font-size: 14px;
font-weight: 600;
line-height: 48px;
letter-spacing: 0.04em;
text-decoration: none;
}
.page__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const LIFT = 1; // 幕が上がる時間(秒)
const LAG = 0.1; // 色の幕の遅れ。大きいほど、2 枚の間の色の帯が太くなる
function createLoader(root) {
const accent = root.querySelector(".curtain__accent");
const panel = root.querySelector(".curtain__panel");
const content = root.querySelector(".curtain__content");
const track = root.querySelector(".curtain__track");
const bar = root.querySelector(".curtain__bar");
const digits = root.querySelector(".curtain__digits");
const state = { value: 0 };
let ending = false;
const draw = () => {
const value = String(Math.round(state.value));
bar.style.transform = `scaleX(${state.value / 100})`;
digits.textContent = value;
track.setAttribute("aria-valuenow", value);
};
return {
// 進み具合を 0〜1 で渡す。飛び飛びに届いても、0.4 秒で追いかけるのでなめらかに進む
set(progress) {
if (ending) return; // 幕を上げ始めたら、遅れて届いた値で戻さない
const to = Math.min(1, Math.max(0, progress)) * 100;
gsap.to(state, { value: to, duration: 0.4, ease: "power2.out", onUpdate: draw, overwrite: true });
},
// 読み込みが終わったら呼ぶ。100 まで進め、中身を消してから、2 枚の幕を同じイージングで少しずらして上げる。
// 返ってくるタイムラインに、ページ自身の出現をつなげられる
done() {
ending = true;
const tl = gsap
.timeline({ onComplete: () => root.remove() })
.to(state, { value: 100, duration: 0.35, ease: "power2.in", onUpdate: draw, overwrite: true });
// 動きを減らす設定では、幕を動かさずに薄くして消す
if (reduceMotion) return tl.to(root, { autoAlpha: 0, duration: 0.3, ease: "power1.out" });
return tl
.to(content, { autoAlpha: 0, y: -24, duration: 0.35, ease: "power2.in" }, "+=0.1")
.to(panel, { yPercent: -100, duration: LIFT, ease: "power3.inOut" })
.to(accent, { yPercent: -100, duration: LIFT, ease: "power3.inOut" }, `<${LAG}`);
},
};
}
export const loader = createLoader(document.querySelector(".curtain"));
// 例: ページの画像が読めるたびに進め、すべて読み終えたら(load)幕を上げて見出しを出す。
// loading="lazy" の画像は画面に入るまで読まれないので、数に入れない
const images = [...document.images].filter((image) => image.loading !== "lazy");
let loaded = 0;
images.forEach((image) => image.decode().catch(() => {}).then(() => loader.set(++loaded / images.length)));
addEventListener("load", () => {
const tl = loader.done();
// 見出しは、幕の縁が行を通り過ぎるころから、下の行を先にせり上げる(幕は下から開くため)
if (!reduceMotion) tl.from(".page__text", { yPercent: 110, duration: 0.9, ease: "expo.out", stagger: { each: 0.08, from: "end" } }, "<0.52");
}, { once: true });