デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- DrawSVGPlugin
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
見出しの強調したい語の下に、手描き風の下線がすっと引かれます。語の幅を測り、線の太さが伸び縮みしないようその幅ぴったりのSVGを作って下に重ねています。線は2本の曲線で、1本目が長く弧を描き、2本目が短く下をなぞって重なります。
下線の上端は、語のベースラインを測ってその0.22倍下に置いています。和文でも欧文でも、書体や行の高さを替えても、字の下がり(欧文の p・y など)との間が変わりません。1本目は0.8秒、2本目は少し遅れて0.55秒かけて描きます。
線の端は丸くしているので、描き終わったところに小さな点が残りやすくなります。消すときは、線を縮めきる直前に透明にして点を隠しています。
つまみは線の色・太さ・ベースラインからの間隔・反り方です。色を変えるだけでも印象が変わります。見出しの一部や、LP の強調したい語に向いています。
コード
<section class="underline-draw">
<h2 class="underline-draw__heading">
伝えたい言葉を、<br>
<span class="underline-draw__word">まっすぐ届ける</span>。
</h2>
</section>
.underline-draw {
--accent: #688297; /* 下線の色 */
display: grid;
place-items: center;
min-height: 60vh;
padding: 96px 6vw;
background: #f2f1ed;
color: #262626;
text-align: center;
}
.underline-draw__heading {
margin: 0;
font-size: clamp(28px, 6vw, 64px);
font-weight: 800;
line-height: 1.3;
letter-spacing: -0.02em;
}
.underline-draw__word {
position: relative;
display: inline-block;
white-space: nowrap;
}
import { gsap } from "gsap";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
gsap.registerPlugin(DrawSVGPlugin);
const GAP = 0.22; // ベースラインから線までの間隔(字の大きさに対する比)
const SVG_NS = "http://www.w3.org/2000/svg";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 語の箱の上端からベースラインまで(px)。高さ 0 の inline-block は下端をベースラインにそろえて置かれる
function baselineOf(word) {
const probe = document.createElement("span");
probe.style.cssText = "display:inline-block;width:0;height:0;";
word.prepend(probe);
const y = probe.offsetTop;
probe.remove();
return y;
}
// 語の幅に合わせた下線の SVG を作る。1 本目は右上がりにゆるく反らせ、2 本目は短く下をなぞる
function underline(width, size, top) {
const height = size * 0.34;
const x = (f) => (f * width).toFixed(1);
const y = (f) => (f * height).toFixed(1);
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", `0 0 ${width} ${height.toFixed(1)}`);
svg.setAttribute("width", String(width));
svg.setAttribute("height", height.toFixed(1));
svg.setAttribute("aria-hidden", "true");
svg.style.cssText = `position:absolute;left:0;top:${Math.round(top)}px;overflow:visible;`;
const paths = [
`M ${x(0.01)} ${y(0.7)} C ${x(0.3)} ${y(0.25)}, ${x(0.68)} ${y(0.12)}, ${x(0.99)} ${y(0.42)}`,
`M ${x(0.1)} ${y(1)} C ${x(0.38)} ${y(0.72)}, ${x(0.7)} ${y(0.68)}, ${x(0.93)} ${y(0.86)}`,
].map((d) => {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", d);
path.setAttribute("fill", "none");
path.style.stroke = "var(--accent)"; // 色は CSS の --accent で変える
path.setAttribute("stroke-width", (size * 0.075).toFixed(1));
path.setAttribute("stroke-linecap", "round");
svg.append(path);
return path;
});
return { svg, paths };
}
// 語の幅とベースラインは字形で決まるので、フォントが確定してから測って線を作る
document.fonts.ready.then(() => {
document.querySelectorAll(".underline-draw__word").forEach((word) => {
const size = Number.parseFloat(getComputedStyle(word).fontSize);
const { svg, paths } = underline(word.offsetWidth, size, baselineOf(word) + size * GAP);
word.append(svg);
if (reduceMotion) {
gsap.set(paths, { drawSVG: "0% 100%" });
return;
}
const draw = gsap.fromTo(
paths,
{ drawSVG: "0% 0%" },
{ drawSVG: "0% 100%", duration: 0.8, ease: "power2.inOut", stagger: 0.55, paused: true }
);
// 画面に入ったら 1 回だけ引く
const io = new IntersectionObserver(
(entries, self) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
self.unobserve(entry.target);
draw.play();
}
},
{ rootMargin: "0px 0px -20%" }
);
io.observe(word);
});
});