デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- ScrollTrigger
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
ページの最後に置く問い合わせの節を画面に固定し、スクロールに合わせて、節の見出しほどの大きさだった見出しを画面の幅いっぱいまで拡大します。拡大しきって止まったところで、相談のボタンとメールアドレスを下から出します。見出しの上のラベルと下の説明文は、拡大し始めると離れながら薄れ、最後は大きな見出しとボタンだけが残ります。
見出しは拡大しきったときの大きさで組んでおき、scale を小さい値から 1 へ上げます。小さく組んで拡大すると、縮んだ文字の画像を引き伸ばすことになり、ぼやけて見えるためです。拡大は後半ほど速める sine.in にして、倍率が上がるほど伸びが鈍く見える感じを打ち消しています。固定する区間は画面の高さの 1.2 倍で、短くすると迫り方が急になります。見出しの行は、行末の「、」や「。」を数えず、その手前までの字面の左右の端の中央でそろえ、約物は右へぶら下げます。約物まで含めて中央にそろえると、約物の空きの分だけ字が左へ寄って見えます。
ボタンはスクロール量に連動させず、固定した区間の進みが 99% を越えたときに実時間で 0.7 秒かけて出し、下回ったら倍の速さで引っ込めます。連動させると、止める位置しだいでボタンが半分だけ見えたまま残ります。隠すのは opacity だけなので、Tab でボタンにたどり着けます。節に focusin の受け口を付けて出しておかないと、見えないボタンに焦点が当たります。
つまみは、迫る前の字の大きさ(CSS の変数で 40px)、固定する区間の長さ、ボタンを出すしきい値です。拡大しきった字の大きさは、画面の幅と高さの両方に収まるように決めています。動きを減らす設定では、固定も拡大もせず、大きな見出しとボタンをふつうに並べます。見出しを左右から寄せる形や、文字の穴から景色をのぞかせる形と違い、見出しそのものが迫ってきて、そのまま行動を促す形です。問い合わせ・採用・資料請求など、ページの最後でひとつの行動に誘いたい節に向いています。
コード
<!-- ページの最後の節。この後ろにフッターが続く -->
<section class="cta-zoom" aria-labelledby="cta-zoom-title">
<div class="cta-zoom__center">
<p class="cta-zoom__eyebrow">Contact</p>
<!-- 見出しは迫りきったときの大きさで組む。迫る前は JS が scale で縮める -->
<h2 class="cta-zoom__title" id="cta-zoom-title">
<span>いっしょに、</span><span>つくりましょう。</span>
</h2>
<p class="cta-zoom__lead">ここに説明文が入ります。</p>
</div>
<div class="cta-zoom__actions">
<a class="cta-zoom__button" href="/contact/">
相談してみる
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</a>
<a class="cta-zoom__mail" href="mailto:sample@higashiyuki.com">sample@higashiyuki.com</a>
</div>
</section>
.cta-zoom {
--bg: #111111;
--ink: #eeece6;
--mute: #a8a6a0;
--start: 40px; /* 迫る前の見出しの字の大きさ(JS が数値として読むので px で書く) */
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 40px;
padding: 96px 24px;
overflow-x: clip;
background: var(--bg);
color: var(--ink);
text-align: center;
}
.cta-zoom__center { position: relative; }
.cta-zoom__eyebrow { margin: 0 0 24px; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mute); }
/* 迫りきった大きさ。幅と高さの両方に収まるように決める */
.cta-zoom__title {
margin: 0;
font-size: clamp(28px, min(11vw, 45vh - 84px), 200px); /* 長いほうの行(8 字)が幅に、2 行が高さに収まる大きさ */
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.02em;
}
.cta-zoom__title span { display: block; white-space: nowrap; }
.cta-zoom__lead { margin: 24px 0 0; word-break: keep-all; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }
.cta-zoom__actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 16px 32px;
}
.cta-zoom__button {
display: inline-flex;
align-items: center;
gap: 10px;
height: 48px;
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;
}
.cta-zoom__button svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.cta-zoom__mail { color: inherit; font-size: 15px; font-weight: 600; letter-spacing: 0.04em; text-underline-offset: 0.4em; }
.cta-zoom :focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* JS が動くときだけ、画面の高さの舞台にして、ラベルと説明文を迫る前の小さな見出しの上下に置く */
.cta-zoom.is-zoom { box-sizing: border-box; height: 100vh; height: 100svh; padding-block: 48px; }
.cta-zoom.is-zoom .cta-zoom__eyebrow,
.cta-zoom.is-zoom .cta-zoom__lead {
position: absolute;
left: 50%;
width: max-content;
max-width: calc(100vw - 48px);
margin: 0;
translate: -50% 0;
}
/* 小さな見出しの高さの半分は --start の 1.1 倍(2 行・行の高さ 1.1) */
.cta-zoom.is-zoom .cta-zoom__eyebrow { bottom: calc(50% + var(--start) * 1.1 + 24px); }
.cta-zoom.is-zoom .cta-zoom__lead { top: calc(50% + var(--start) * 1.1 + 20px); }
@media (max-width: 600px) {
.cta-zoom { --start: 24px; }
.cta-zoom__lead { font-size: 16px; }
}
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".cta-zoom").forEach((section) => {
const title = section.querySelector(".cta-zoom__title");
// 行末の約物(「、」「。」)は数えず、その手前までの字面の左右の端の中央を行の中央にそろえる。
// 約物は右へぶら下がる(含めて中央にすると、字が左へ寄って見える)。ずれは em で持ち、字の大きさが変わっても合う
document.fonts.ready.then(() => {
const style = getComputedStyle(title), ruler = document.createElement("canvas").getContext("2d");
Object.assign(ruler, { font: `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`, letterSpacing: style.letterSpacing, textAlign: "left", lang: document.documentElement.lang || "ja" });
title.querySelectorAll("span").forEach((line) => {
const { width } = ruler.measureText(line.textContent);
const ink = ruler.measureText(line.textContent.replace(/[、。,.」』)]+$/u, ""));
line.style.translate = `${(width / 2 - (ink.actualBoundingBoxRight - ink.actualBoundingBoxLeft) / 2) / parseFloat(style.fontSize)}em`;
});
});
// 動きを減らす設定では、迫りきった見出しとボタンを、ふつうに並べたままにする
if (reduceMotion) return;
section.classList.add("is-zoom");
const actions = section.querySelectorAll(".cta-zoom__actions > *");
// 見出しは迫りきった大きさで組んである。迫る前の大きさ(CSS の --start)との比まで縮めておく
const startScale = () => {
const start = parseFloat(getComputedStyle(section).getPropertyValue("--start"));
return start / parseFloat(getComputedStyle(title).fontSize);
};
// ボタンは迫りきったところで実時間で出す。opacity を先に上げ、位置は長めに戻す
const pop = gsap
.timeline({ paused: true })
.fromTo(actions, { opacity: 0 }, { opacity: 1, duration: 0.3, stagger: 0.08, ease: "none" }, 0)
.fromTo(actions, { y: 24 }, { y: 0, duration: 0.7, stagger: 0.08, ease: "power3.out" }, 0);
let shown = false;
const show = (on) => {
if (on === shown) return;
shown = on;
if (on) pop.timeScale(1).play();
else pop.timeScale(2).reverse(); // 引っ込めるのは倍の速さで
};
gsap
.timeline({
scrollTrigger: {
trigger: section,
start: "top top",
end: "+=120%", // 迫りきるまでのスクロール量(画面の高さの 1.2 倍)
pin: true,
scrub: true, // スクロール量にそのまま連動させる(数値の scrub は実時間で追うので使わない)
invalidateOnRefresh: true, // 画面の大きさで字の大きさが変わるので、縮める比を測り直す
onUpdate: (self) => show(self.progress > 0.99), // 迫りきったら出し、戻り始めたら引っ込める
},
})
// 文字を含む大きな要素なので 2D のまま描く(3D にすると、縮めた絵を引き伸ばしてぼやける)
.fromTo(title, { scale: startScale }, { scale: 1, duration: 1, ease: "sine.in", force3D: false }, 0)
.to(section.querySelectorAll(".cta-zoom__eyebrow, .cta-zoom__lead"), { opacity: 0, duration: 0.35, ease: "power1.in" }, 0);
// Tab でボタンに来たら、迫る前でも出す(見えないボタンに焦点が当たったままにしない)
section.addEventListener("focusin", () => show(true));
});