デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- ScrollTrigger
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
縦にスクロールしているあいだ、セクションを画面に固定し、その中でカードの列を横へ動かします。固定は ScrollTrigger の pin に任せ、横への動きはスクロール量に結んだトゥイーン 1 本で作ります。固定しておく縦の長さを、列を横に動かす距離(列の幅から画面の幅を引いた分)とちょうど同じにしているので、最後のカードが右端に着いた瞬間に固定が外れ、次のセクションへ進みます。
横の動きにはイージングを付けず(none)、スクロール量にそのまま結びます。縦に 1px スクロールすると列がちょうど 1px 横へ動くので、ホイールや指の動きと列の動きが食い違いません。イージングを付けると、同じだけスクロールしても、列の進み方が場所によって変わります。結び方も遅れを付けない scrub の true にしていて、数値にすると、指を止めたあとも列が遅れて追いかけてきます。
動かす距離と固定する長さは、画面の幅で変わります。読み込んだときの値のままにすると、画面の向きや幅を変えたあとで、最後のカードが右端まで届かなかったり、流れきったあとも空いた画面のまま固定が続いたりします。どちらも関数で渡し、幅が変わるたびに測り直す指定を付けておきます。測る前に、固定したときの形(画面の高さいっぱい)にしておくことも忘れないでください。
カードをリンクにするなら、キーボードでの移動も考えます。はみ出しを隠す指定が hidden のままだと、Tab で画面の外のカードへ移ったとき、ブラウザがそのカードを見せようとして列の外枠を横にずらし、スクロールと列の位置が合わなくなります。overflow: clip にするとずらされなくなるので、そのうえで、カードに移ったら、そのカードが画面の中央に来る位置まで縦にスクロールさせます。
つまみは、カードの幅と間隔、枚数です。枚数を増やすほど固定の区間が長くなり、読む人がなかなか先へ進めないと感じるので、5 枚前後が目安です。動きを減らす設定では固定をやめ、指やトラックパッドで横にスクロールできる列のまま見せます。制作実績や事例の一覧、サービスの手順、年表など、横に並べて順に見せたい節に向いています。
コード
<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>
.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; }
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());
});