デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
大きな文字の列(マーキー)は、ページを下へ読み進めている間は左へ流れ、上へ戻ると、いったん止まってから右へ流れ直します。勢いよくスクロールすると一時的に速くなり、スクロールをやめても、最後に動いた向きのまま流れ続けます。
動きは gsap.ticker で毎フレーム進め、進む量を「向き × (1 + 足した速さ)」に比例させています。向きの値は少し時間をかけて反転させるので、流れはいったん止まってから逆へ動き出します。足した速さは、スクロールの速さ(getVelocity)を250で割った値で、上限は6。割る数を小さくすればゆっくりしたスクロールでも速く流れ、上限を上げれば、勢いよく読み飛ばしたときに文字が飛ぶように流れます。
繰り返すトゥイーンの timeScale を負にすれば逆に流れそうに見えますが、それだと巻き戻しが最初の周の先頭に着いたところで止まってしまいます。位置は自分で持つことにしました。複製してつないだ語のグループの幅に対する割合(xPercent)の範囲で折り返しているので、向きが何度変わっても途切れません。
スクロールを止めたあとは、足した速さを1.2秒かけて元へ戻します。戻し方を power2.in にしてあるので、しばらく速いまま流れてから、普段の速さへ落ち着きます。
速さを上げ直すのは、今より速いスクロールが来たときだけです。スクロールの終わりぎわは速さが落ちていくので、値を受け取るたびに上げ直すと、遅くなった値から戻ることになって、加速がすぐに消えてしまいます。
マーキーの語は下の一覧と同じなので、マーキー全体をスクリーンリーダーの対象から外しています。流れ続けるものなので一時停止のボタンを置き、画面の外にある間は ScrollTrigger の onToggle で ticker から外して、動かさないようにしています。
コード
<section class="services" aria-labelledby="services-title">
<div class="services__intro">
<p class="services__eyebrow">Services</p>
<h2 class="services__title" id="services-title">ブランドも、サイトも、動きも。<br>ひとつのチームでつくります。</h2>
<p class="services__lead">ここに説明文が入ります。</p>
</div>
<!-- 文字のマーキー。下へスクロールすると左へ、上へ戻すと右へ流れる。語は下の一覧と同じなので、マーキーごと読み上げから外す。
流すための複製は JS が作る -->
<div class="services__band" aria-hidden="true">
<ul class="services__words">
<li>Branding</li>
<li>Websites</li>
<li>Motion</li>
<li>Apps</li>
</ul>
</div>
<!-- 5 秒より長く動き続けるので、止める手段を置く。流すときだけ JS が表示する -->
<div class="services__foot">
<button class="services__toggle" type="button" aria-pressed="false" hidden>一時停止</button>
</div>
<ul class="services__list">
<li>
<h3>ブランドの設計</h3>
<p>ここに説明文が入ります。</p>
</li>
<li>
<h3>ウェブサイト制作</h3>
<p>ここに説明文が入ります。</p>
</li>
<li>
<h3>モーションデザイン</h3>
<p>ここに説明文が入ります。</p>
</li>
<li>
<h3>アプリの開発</h3>
<p>ここに説明文が入ります。</p>
</li>
</ul>
</section>
.services {
--bg: #111111;
--ink: #eeece6;
--mute: #a8a6a0;
--line: rgb(238 236 230 / 0.14);
--band-size: clamp(56px, 9vw, 112px); /* マーキーの字の大きさ */
padding: 96px 0;
background: var(--bg);
color: var(--ink);
}
.services__intro,
.services__foot,
.services__list {
max-width: 1088px;
margin-inline: auto;
padding-inline: 24px;
}
.services__eyebrow {
margin: 0 0 16px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--mute);
}
.services__title {
max-width: 16em;
margin: 0 0 24px;
font-size: clamp(28px, 4vw, 44px);
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.02em;
word-break: auto-phrase; /* 和文を文節の切れ目で折り返す(対応するブラウザのみ) */
}
.services__lead { max-width: 32em; margin: 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
/* マーキーは明暗を反転した明るい背景。はみ出たセットを隠して、横に並べる */
.services__band {
display: flex;
overflow: hidden;
margin-top: 72px;
background: var(--ink);
color: var(--bg);
}
.services__words {
display: flex;
flex: none;
margin: 0;
padding: 0.3em 0 0.26em;
list-style: none;
font-size: var(--band-size);
font-weight: 800;
line-height: 1;
letter-spacing: -0.02em;
white-space: nowrap;
}
.services__words li { display: flex; align-items: center; }
/* 語の後ろの丸。セットの最後にも付けるので、セットとセットの継ぎ目も語の間と同じになる */
.services__words li::after { content: ""; width: 0.25em; height: 0.25em; margin: 0 0.4em; border-radius: 50%; background: currentColor; }
.services__foot { display: flex; justify-content: flex-end; margin-top: 16px; }
.services__toggle {
height: 40px;
padding: 0 20px;
border: 0;
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--ink);
background: transparent;
color: var(--ink);
font: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
}
.services__toggle[aria-pressed="true"] { background: var(--ink); color: var(--bg); }
.services__toggle:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
.services__list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 32px;
margin-top: 48px;
list-style: none;
}
.services__list li { padding-top: 24px; border-top: 1px solid var(--line); }
.services__list h3 { margin: 0 0 8px; font-size: 22px; line-height: 1.3; letter-spacing: -0.01em; }
.services__list p { margin: 0; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; word-break: auto-phrase; }
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
const SPEED = 60; // 普段の速さ(px/秒)
const TURN = 0.4; // 向きを切り替える時間(秒)
const SETTLE = 1.2; // 足した速さが 0 に戻るまでの時間(秒)
const MAX_BOOST = 6; // 足す速さの上限(普段の速さの何倍を足すか)
const PER_BOOST = 250; // スクロールの速さ(px/秒)がこれだけ増えるごとに、普段の速さを 1 つぶん足す
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// マーキーの幅は書体で決まるので、読み込みを待ってから測る
await document.fonts.ready;
document.querySelectorAll(".services").forEach((section) => {
// 動きを減らす設定では流さない(マーキーは最初の語が並んだまま)
if (reduceMotion) return;
const band = section.querySelector(".services__band");
const list = band.querySelector(".services__words");
const toggle = section.querySelector(".services__toggle");
// いちばん広い画面でも途切れないよう、足りるだけセットを複製する
const copies = Math.ceil(Math.max(screen.width, innerWidth) / list.offsetWidth) + 1;
for (let i = 0; i < copies; i++) band.append(list.cloneNode(true));
const lists = band.querySelectorAll(".services__words");
// direction は 1 で左へ(下へスクロールするとき)、-1 で右へ。run は一時停止のボタンで 0 になる
const speed = { direction: 1, boost: 0, run: 1 };
let direction = 1;
let offset = 0; // セットの幅に対する %(-100〜0)。1 セットぶん動くと元の見た目に重なるので、折り返しても継ぎ目が出ない
const tick = (time, deltaTime) => {
const rate = speed.direction * (1 + speed.boost) * speed.run;
offset = gsap.utils.wrap(-100, 0, offset - (SPEED / list.offsetWidth) * 100 * rate * (deltaTime / 1000));
gsap.set(lists, { xPercent: offset });
};
ScrollTrigger.create({
trigger: band,
start: "top bottom",
end: "bottom top",
// マーキーが画面にあるときだけ動かす
onToggle: (self) => (self.isActive ? gsap.ticker.add(tick) : gsap.ticker.remove(tick)),
onUpdate(self) {
// 向きが変わったら、0.4 秒かけて止まってから逆へ動き出す
if (self.direction !== direction) {
direction = self.direction;
gsap.to(speed, { direction, duration: TURN, ease: "power2.inOut", overwrite: "auto" });
}
// 今より速いスクロールが来たら、足す速さを上げ直し、止めたあと 1.2 秒かけて 0 へ戻す
const boost = Math.min(MAX_BOOST, Math.abs(self.getVelocity()) / PER_BOOST);
if (boost > speed.boost) {
gsap.fromTo(speed, { boost }, { boost: 0, duration: SETTLE, ease: "power2.in", overwrite: "auto" });
}
},
});
// キーボードやタッチでも止められるボタン。名前は変えず、押されているかを aria-pressed で伝える
toggle.hidden = false;
toggle.addEventListener("click", () => {
const paused = toggle.getAttribute("aria-pressed") !== "true";
toggle.setAttribute("aria-pressed", String(paused));
gsap.to(speed, { run: paused ? 0 : 1, duration: 0.4, ease: "power2.out", overwrite: "auto" });
});
});