デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
画面に散らばった粒が、弧を描きながら集まって見出しの文字になる背景です。見えている見出しの実際の位置と書体をもとに、Canvas 2Dで一度だけ文字を描き、文字の上にある点を間隔ごとに採っています。行は、行末の「、」や「。」を数えず、その手前までの字面の左右の端の中央でそろえ、約物は右へぶら下げます。約物まで含めて中央にそろえると、約物の空きの分だけ字が左へ寄って見えます。measureText で約物の手前までの字面を測り、ずれの分だけ行を動かします。
粒は文字の位置へ直線ではなく、進む向きと直角に膨らんだ弧をたどって集まります。左側の粒から先に動き出すので、見出しを左から読むのと同じ順で文字が組み上がって見えます。粒の半分強は見出しと同じ色にし、残りにくすんだ4色を混ぜています。
見出しの文字そのものは画面に残したまま透明にしておき、読み上げには影響しません。粒が集まり終えたらその位置に留まり、動きを減らす設定では粒を出さず、見出しの文字をそのまま表示します。
粒の色の配分や集まる速さを変えるだけで、印象を静かにも賑やかにも調整できます。スタジオやプロダクト、イベントのティザーのように、見出しの登場そのものを見せ場にしたいヒーローに向いています。
コード
<section class="particles-hero">
<!-- 粒は飾りなので読み上げから外す。見出しの文字そのものは下に置いて読み上げに残す -->
<canvas class="particles-hero__canvas" aria-hidden="true"></canvas>
<p class="particles-hero__eyebrow">DESIGN STUDIO</p>
<h1 class="particles-hero__title">
<span class="particles-hero__line">ひらめきを、</span>
<span class="particles-hero__line">かたちに。</span>
</h1>
<p class="particles-hero__lead">ここに説明文が入ります。</p>
<a class="particles-hero__button" href="/contact/">制作のご相談</a>
</section>
.particles-hero {
--bg: #f2f1ed;
--ink: #262626;
/* 粒の色(見出しと同じ色を主に、残りに数色を混ぜる) */
--c1: #262626;
--c2: #688297; /* スレート */
--c3: #b27771; /* くすみローズ */
--c4: #a28767; /* グレージュ */
--c5: #739374; /* セージ */
--gutter: clamp(24px, 7vw, 96px);
position: relative;
padding: 96px var(--gutter);
background: var(--bg);
color: var(--ink);
text-align: center;
}
.particles-hero__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
pointer-events: none;
}
.particles-hero.is-forming .particles-hero__canvas { opacity: 1; }
.particles-hero.is-forming .particles-hero__title { opacity: 0; }
.particles-hero__eyebrow {
margin: 0;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: color-mix(in srgb, var(--ink) 60%, transparent);
}
.particles-hero__title {
margin: 24px 0 0;
font-size: clamp(40px, 10vw, 128px); /* 粒で描く字そのものが見どころなので、見出しの段より大きくする */
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
}
.particles-hero__line { display: block; }
.particles-hero__lead {
max-width: 34em;
margin: 24px auto 0;
font-size: 18px;
line-height: 1.8;
word-break: auto-phrase; /* 狭い画面で、語の途中(「プ/ロダクト」)で折り返さない */
}
.particles-hero__button {
display: inline-grid;
place-items: center;
height: 48px;
margin-top: 32px;
padding: 0 32px;
border-radius: 999px;
background: var(--ink);
color: var(--bg);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
}
.particles-hero__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
import { gsap } from "gsap";
const GAP = 1 / 25; // 文字の上の点を採る間隔(字の大きさに対する比。128px の字で 5px)
const RADIUS = [0.4, 0.54]; // 粒の半径の範囲(点の間隔に対する比)
const CURVE = 18; // 集まるときの弧の曲がり(px)
const GATHER = { start: 0.2, spread: 0.8, duration: 1.1 }; // 集まり始める時刻・最後の粒までのずれ・1 粒の所要時間
const HOLD_FROM = GATHER.start + GATHER.spread + GATHER.duration;
const WEIGHTS = [0.56, 0.16, 0.14, 0.09, 0.05]; // 5 色の粒の割合(半分強は見出しと同じ色にする)
const TAU = Math.PI * 2;
const gatherEase = gsap.parseEase("power3.inOut");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".particles-hero").forEach((hero) => {
// 字形が確定してから点を採る。仮の書体の幅で採ると、本物の書体に替わったところで粒と文字がずれる
document.fonts.ready.then(() => {
const canvas = hero.querySelector(".particles-hero__canvas");
const title = hero.querySelector(".particles-hero__title");
// 粒の色は CSS 変数から読む
const heroStyle = getComputedStyle(hero);
const palette = WEIGHTS.map((w, i) => [heroStyle.getPropertyValue(`--c${i + 1}`).trim(), w]);
const box = hero.getBoundingClientRect();
const W = Math.round(box.width), H = Math.round(box.height);
const dpr = Math.min(window.devicePixelRatio, 2);
Object.assign(canvas, { width: W * dpr, height: H * dpr });
const g = canvas.getContext("2d", { willReadFrequently: true });
g.setTransform(dpr, 0, 0, dpr, 0, 0);
// 見出しの行ごとに、実際の位置と書体・字間で文字を描き、上にある点を採る
const lineStyle = getComputedStyle(title);
const gap = Math.max(2.5, parseFloat(lineStyle.fontSize) * GAP);
Object.assign(g, { fillStyle: "#000", textBaseline: "middle", textAlign: "center", lang: document.documentElement.lang || "ja" });
Object.assign(g, { font: `${lineStyle.fontWeight} ${lineStyle.fontSize} ${lineStyle.fontFamily}`, letterSpacing: lineStyle.letterSpacing });
const lines = title.querySelectorAll(".particles-hero__line");
// 行末の約物(「、」「。」)は数えず、その手前までの字面の中央を行の中央にそろえ、約物は右へぶら下げる。textAlign が center なので、字面の端は約物の手前までの文字列の中央から測られる(ずれは em で持つ)
for (const line of lines) {
const full = g.measureText(line.textContent).width, ink = g.measureText(line.textContent.replace(/[、。,.」』)]+$/u, ""));
line.style.translate = `${(full - ink.width - ink.actualBoundingBoxRight + ink.actualBoundingBoxLeft) / 2 / parseFloat(lineStyle.fontSize)}em`;
}
if (reduceMotion) return; // 動きを減らす設定では、見出しの文字をそのまま出す
for (const line of lines) {
const rect = line.getBoundingClientRect();
g.fillText(line.textContent, rect.left - box.left + rect.width / 2, rect.top - box.top + rect.height / 2);
}
const { data } = g.getImageData(0, 0, canvas.width, canvas.height);
const points = [];
for (let y = gap / 2; y < H; y += gap) {
for (let x = gap / 2; x < W; x += gap) {
if (data[(Math.floor(y * dpr) * canvas.width + Math.floor(x * dpr)) * 4 + 3] > 128) points.push([x, y]);
}
}
// 粒ごとの値は組み立てるときに決めて、以後は変えない
const xs = points.map(([x]) => x);
const minX = Math.min(...xs);
const maxX = Math.max(...xs) || 1;
const particles = points.map(([x, y]) => {
let pick = Math.random();
let color = 0;
while (color < WEIGHTS.length - 1 && pick > WEIGHTS[color]) pick -= WEIGHTS[color++];
const along = (x - minX) / (maxX - minX || 1);
return {
tx: x,
ty: y,
sx: -0.1 * W + Math.random() * 1.2 * W,
sy: -0.1 * H + Math.random() * 1.2 * H,
curve: (Math.random() - 0.5) * 2 * CURVE,
gather: GATHER.start + (along * 0.7 + Math.random() * 0.3) * GATHER.spread,
r: gap * (RADIUS[0] + Math.random() * (RADIUS[1] - RADIUS[0])),
color,
};
});
const groups = palette.map(([color], i) => ({ color, items: particles.filter((p) => p.color === i) }));
// 集まり具合 k: 0 = 散らばっている、1 = 文字の位置。直線ではなく弧をたどって集まる
function draw(t) {
g.clearRect(0, 0, W, H);
for (const { color, items } of groups) {
g.fillStyle = color;
g.beginPath();
for (const p of items) {
const k = gatherEase(Math.min(1, Math.max(0, (t - p.gather) / GATHER.duration)));
const dx = p.tx - p.sx;
const dy = p.ty - p.sy;
const len = Math.hypot(dx, dy) || 1;
const bulge = Math.sin(Math.PI * k) * p.curve;
const x = p.sx + dx * k - (dy / len) * bulge;
const y = p.sy + dy * k + (dx / len) * bulge;
g.moveTo(x + p.r, y);
g.arc(x, y, p.r, 0, TAU);
}
g.fill();
}
}
hero.classList.add("is-forming");
const clock = { t: 0 };
gsap.to(clock, { t: HOLD_FROM, duration: HOLD_FROM, ease: "none", onUpdate: () => draw(clock.t) });
});
});