デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- SplitText
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
輪郭だけの見出しの文字の中を、塗りが1文字ずつ下から満ちていきます。文字色を地の色にして縁取りだけを描き、paint-order で縁より内側に塗りを重ねると、線の内側半分と、可変フォントの字の中に出る輪郭の重なり線を消せます(見える線は指定した太さの半分になります)。塗りは、同じ字を文字ごとに重ねたインクの色の要素で、clip-path の上下の境目を動かして下から満たします。
字は左から0.12秒ずつずらして満ち、1文字あたり0.6秒かけてpower2.inOutで塗りきります。字間は0.01emに広げています。SplitTextで字を分けると詰め(カーニング)が消え、詰めたままだと隣り合う字の輪郭どうしが触れ、後ろの字の塗りが前の字の輪郭を切ってしまうためです。
分割は書体の読み込みを待ってから行います。待たずに分けると、仮の書体の幅で位置が決まり、本物の書体に差し替わったところで塗りと輪郭がずれます。写真の上など地の色が一色でないときは文字色を透明にできますが、その場合は可変フォントだと字の中に重なりの線が出るので、静的な書体を選んでください。
つまみは字間・ずらす間隔・塗りきるまでの時間です。間隔を広げるほど、塗りが文字の列を通り抜ける波がゆっくり見えます。見出しやセクションの導入など、字そのものを見せ場にしたい所に向いています。
コード
<section class="outline-fill">
<h2 class="outline-fill__heading">ひと目で伝わる</h2>
</section>
.outline-fill {
display: grid;
place-items: center;
min-height: 60vh;
padding: 96px 6vw;
background: #f2f1ed;
}
.outline-fill__heading {
margin: 0;
color: #f2f1ed;
font-size: clamp(40px, 10vw, 128px);
font-weight: 800;
line-height: 1;
letter-spacing: 0.01em;
text-align: center;
-webkit-text-stroke: 0.028em #262626;
paint-order: stroke fill;
}
/* SplitText が文字ごとに付ける箱。塗りを重ねる基準点にする */
.outline-fill__heading :is(.char) {
position: relative;
}
/* 文字ごとに重ねる塗り。輪郭の内側半分と、可変フォントの字の中の重なり線を隠す。既定は下に隠しておく */
.outline-fill__fill {
position: absolute;
left: 0;
top: 0;
color: #262626;
-webkit-text-stroke: 0;
clip-path: inset(100% 0 0 0);
}
import { gsap } from "gsap";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(SplitText);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 字形が確定してから分ける。仮の書体の幅で塗りを重ねると、書体が差し替わったときにずれる
document.fonts.ready.then(() => {
document.querySelectorAll(".outline-fill__heading").forEach((heading) => {
const split = SplitText.create(heading, { type: "chars", charsClass: "char" });
// 文字ごとに同じ字を塗りの色で重ねる。CSS の clip-path で下に隠してある
const fills = split.chars.map((char) => {
const fill = document.createElement("span");
fill.className = "outline-fill__fill";
fill.textContent = char.textContent;
char.append(fill);
return fill;
});
if (reduceMotion) {
gsap.set(fills, { clipPath: "inset(0% 0 0 0)" });
return;
}
// 画面に入ったら 1 回だけ、左の文字から順に塗りつぶす
const io = new IntersectionObserver(
(entries, self) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
self.unobserve(entry.target);
gsap.to(fills, { clipPath: "inset(0% 0 0 0)", duration: 0.6, ease: "power2.inOut", stagger: 0.12 });
}
},
{ rootMargin: "0px 0px -15%" }
);
io.observe(heading);
});
});