本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
セクション

スクロールの向きに合わせて、セクションの文字の帯が流れる向きを変え、速さも増す

デモ

操作:スクロール・クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。 触っている間は、ホイールでデモの中がスクロールします。

仕様

動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

サービス紹介の節を横切る大きな文字の帯を、ページを下へ読むと左へ、上へ戻すと右へ流します。帯の中身は、語を並べた組を複製して横につないだものです。どの組も自分の幅の分だけ動いたら巻き戻すので、途切れずに流れ続けます。スクロールをやめても、最後に動かした向きのまま流れ続けます。

帯は gsap.ticker で毎コマ進め、進む量に「向き × (1 + 足した速さ)」を掛けます。向きは 0.4 秒かけて 1 から -1 へ補間するので、帯はいったん止まってから逆へ動き出します。足した速さは、スクロールの速さ(getVelocity、px/秒)を 250 で割った値で、上限は 6 です。スクロールを止めたあとは 1.2 秒かけて 0 へ戻し、戻す動きを power2.in にして、しばらく速いまま流れてから普段の 1 秒に 60px へ落ち着かせます。

繰り返すトゥイーンの timeScale を負にして逆向きに流すやり方は、巻き戻しが最初の周の頭に着いた所で止まってしまいます。ここでは位置を自分で持ち、組の幅に対する割合(xPercent の -100〜0)で折り返すので、向きが何度変わっても途切れません。足す速さは、今より速いスクロールが来たときだけ上げ直します。スクロールの終わりは速さが落ちていくので、知らせのたびに上げ直すと、遅くなった値からの戻りになって、加速がすぐに消えます。

帯の語は下の一覧と同じなので、帯ごと読み上げから外しています。5 秒より長く動き続けるので、一時停止のボタンを置き、押すと 0.4 秒かけて止めます。帯が画面の外にある間は、ScrollTrigger の出入り(onToggle)で ticker から外し、動かしません。動きを減らす設定では流さず、最初の語が並んだまま止めておきます。

つまみは、普段の速さ、向きを切り替える時間、速さを割る数と上限、戻す時間です。割る数を小さくすると、ゆっくりしたスクロールでも速くなり、上限を大きくすると、勢いよく読み飛ばしたときに帯が飛ぶように流れます。いつも同じ向きに流れる帯と違い、読む向きに帯が付いてくるので、サービスや得意分野を大きく見せる節や、実績の区切り、問い合わせへの誘導の前に向いています。

コード

HTML
<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>

CSS
.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; }

JavaScript
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" });
  });
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。