本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
カード

スクロールで固定したセクションが横に流れる

デモ

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

仕様

用途
カード
動作
DOM
種類
レシピ
使うもの
ScrollTrigger
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

縦にスクロールしているあいだ、セクションを画面に固定し、その中でカードの列を横へ動かします。固定は ScrollTrigger の pin に任せ、横への動きはスクロール量に結んだトゥイーン 1 本で作ります。固定しておく縦の長さを、列を横に動かす距離(列の幅から画面の幅を引いた分)とちょうど同じにしているので、最後のカードが右端に着いた瞬間に固定が外れ、次のセクションへ進みます。

横の動きにはイージングを付けず(none)、スクロール量にそのまま結びます。縦に 1px スクロールすると列がちょうど 1px 横へ動くので、ホイールや指の動きと列の動きが食い違いません。イージングを付けると、同じだけスクロールしても、列の進み方が場所によって変わります。結び方も遅れを付けない scrub の true にしていて、数値にすると、指を止めたあとも列が遅れて追いかけてきます。

動かす距離と固定する長さは、画面の幅で変わります。読み込んだときの値のままにすると、画面の向きや幅を変えたあとで、最後のカードが右端まで届かなかったり、流れきったあとも空いた画面のまま固定が続いたりします。どちらも関数で渡し、幅が変わるたびに測り直す指定を付けておきます。測る前に、固定したときの形(画面の高さいっぱい)にしておくことも忘れないでください。

カードをリンクにするなら、キーボードでの移動も考えます。はみ出しを隠す指定が hidden のままだと、Tab で画面の外のカードへ移ったとき、ブラウザがそのカードを見せようとして列の外枠を横にずらし、スクロールと列の位置が合わなくなります。overflow: clip にするとずらされなくなるので、そのうえで、カードに移ったら、そのカードが画面の中央に来る位置まで縦にスクロールさせます。

つまみは、カードの幅と間隔、枚数です。枚数を増やすほど固定の区間が長くなり、読む人がなかなか先へ進めないと感じるので、5 枚前後が目安です。動きを減らす設定では固定をやめ、指やトラックパッドで横にスクロールできる列のまま見せます。制作実績や事例の一覧、サービスの手順、年表など、横に並べて順に見せたい節に向いています。

コード

HTML
<section class="h-scroll" aria-labelledby="h-scroll-title">
  <div class="h-scroll__intro">
    <p class="h-scroll__eyebrow">WORKS (05)</p>
    <h2 class="h-scroll__title" id="h-scroll-title">これまでの制作実績</h2>
  </div>

  <!-- 画面に固定して横に流す部分。スクリプトが無いときや動きを減らす設定では、横にスクロールできる列のまま -->
  <div class="h-scroll__viewport">
    <ol class="h-scroll__track">
      <li class="h-card">
        <a class="h-card__link" href="/work/northwind/">
          <img class="h-card__image" src="/images/northwind.jpg" alt="" width="864" height="896">
          <span class="h-card__number" aria-hidden="true">01</span>
          <span class="h-card__name">北風珈琲</span>
          <span class="h-card__meta">ブランディング</span>
        </a>
      </li>
      <li class="h-card">
        <a class="h-card__link" href="/work/halcyon/">
          <img class="h-card__image" src="/images/halcyon.jpg" alt="" width="864" height="896">
          <span class="h-card__number" aria-hidden="true">02</span>
          <span class="h-card__name">ひだまり家電</span>
          <span class="h-card__meta">プロダクトサイト</span>
        </a>
      </li>
      <li class="h-card">
        <a class="h-card__link" href="/work/meridian/">
          <img class="h-card__image" src="/images/meridian.jpg" alt="" width="864" height="896">
          <span class="h-card__number" aria-hidden="true">03</span>
          <span class="h-card__name">まちあかり</span>
          <span class="h-card__meta">広告キャンペーン</span>
        </a>
      </li>
      <li class="h-card">
        <a class="h-card__link" href="/work/lumen/">
          <img class="h-card__image" src="/images/lumen.jpg" alt="" width="864" height="896">
          <span class="h-card__number" aria-hidden="true">04</span>
          <span class="h-card__name">こよみ手帳</span>
          <span class="h-card__meta">アプリのUIデザイン</span>
        </a>
      </li>
      <li class="h-card">
        <a class="h-card__link" href="/work/tessera/">
          <img class="h-card__image" src="/images/tessera.jpg" alt="" width="864" height="896">
          <span class="h-card__number" aria-hidden="true">05</span>
          <span class="h-card__name">かけら展</span>
          <span class="h-card__meta">展示デザイン</span>
        </a>
      </li>
    </ol>
  </div>
</section>

CSS
.h-scroll {
  --bg: #111111;
  --ink: #eeece6;
  --mute: #a8a6a0;
  --on-image: #ffffff;
  --card-width: min(432px, 78vw);
  --card-height: min(448px, 64vh);
  --gap: 32px;
  --gutter: clamp(24px, 7vw, 96px);
  background: var(--bg);
  color: var(--ink);
}

.h-scroll__intro { padding: 160px var(--gutter) 48px; }

.h-scroll__eyebrow {
  margin: 0 0 16px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--mute);
}

.h-scroll__title {
  margin: 0;
  font-size: clamp(40px, 6vw, 64px);
  font-weight: 800;
  line-height: 1.1;
  letter-spacing: -0.02em;
  word-break: auto-phrase;
}

/* スクリプトが無いときや動きを減らす設定では、指やトラックパッドで横にスクロールする列 */
.h-scroll__viewport {
  overflow-x: auto;
  padding: 8px 0 64px;
  scroll-snap-type: x mandatory;
}

.h-scroll__track {
  display: flex;
  gap: var(--gap);
  width: max-content;
  margin: 0;
  padding: 0 var(--gutter);
  list-style: none;
}

/* 固定して流すときは、画面の高さいっぱいにして列を縦の中央に置く。
   hidden ではなく clip にするのは、Tab で画面の外のカードに移ったとき、ブラウザに横へずらさせないため */
.h-scroll.is-pinned .h-scroll__viewport {
  display: flex;
  align-items: center;
  height: 100vh;
  padding: 0;
  overflow: clip;
}

.h-card {
  flex: none;
  width: var(--card-width);
  scroll-snap-align: center;
}

.h-card__link {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  box-sizing: border-box;
  height: var(--card-height);
  padding: 24px;
  overflow: hidden;
  border-radius: 4px;
  color: var(--on-image);
  text-decoration: none;
}

.h-card__link:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

/* 写真は枠いっぱいに敷き、下のほうだけ暗くして文字を読ませる */
.h-card__image { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.h-card__link::after { content: ""; position: absolute; inset: 0; background: linear-gradient(0deg, rgb(0 0 0 / 0.5), transparent 42%); }

.h-card__number,
.h-card__name,
.h-card__meta { position: relative; z-index: 1; }

.h-card__number { position: absolute; left: 24px; top: 24px; font-size: 13px; font-weight: 700; letter-spacing: 0.14em; opacity: 0.86; }
.h-card__name { font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.h-card__meta { margin-top: 4px; font-size: 14px; letter-spacing: 0.04em; opacity: 0.86; }

JavaScript
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger);

function pinSideways(section) {
  const viewport = section.querySelector(".h-scroll__viewport");
  const track = section.querySelector(".h-scroll__track");
  // 固定したときの形(画面の高さ)にしてから測る
  section.classList.add("is-pinned");
  // 横に流す距離。これと同じ量だけ縦にスクロールする間、画面に固定しておく
  const distance = () => Math.max(0, track.scrollWidth - viewport.clientWidth);

  const tween = gsap.to(track, {
    x: () => -distance(),
    ease: "none", // スクロール量と横の位置を比例させる
    scrollTrigger: {
      trigger: viewport,
      start: "top top",
      end: () => `+=${distance()}`,
      pin: true,
      scrub: true, // 数値にすると、指を止めたあとも遅れて追いかける
      invalidateOnRefresh: true, // 画面の幅が変わったら、距離を測り直す
    },
  });

  // Tab で画面の外のカードに移ったら、そのカードが中央に来る位置まで縦にスクロールする
  const reveal = (event) => {
    const card = event.target.closest(".h-card");
    if (!card || !event.target.matches(":focus-visible")) return;
    const st = tween.scrollTrigger;
    const left = card.getBoundingClientRect().left - track.getBoundingClientRect().left;
    const offset = left - (viewport.clientWidth - card.offsetWidth) / 2;
    st.scroll(st.start + gsap.utils.clamp(0, distance(), offset));
  };
  track.addEventListener("focusin", reveal);

  // 設定が変わって外すとき。固定とトゥイーンは matchMedia が元に戻す
  return () => {
    track.removeEventListener("focusin", reveal);
    section.classList.remove("is-pinned");
  };
}

// 動きを減らす設定では固定せず、横にスクロールできる列のまま見せる(設定を切り替えると付け外しされる)
gsap.matchMedia().add("(prefers-reduced-motion: no-preference)", () => {
  const undo = [...document.querySelectorAll(".h-scroll")].map(pinSideways);
  return () => undo.forEach((fn) => fn());
});

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

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