デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- ScrollTrigger
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
ヒーローを画面に固定し(pin)、右に並べた 3 列の画像を、スクロール量に結んだ 1 本のタイムラインで上へ流します。列ごとに画像の枚数と縦横比を変えてあり、どの列も自分の 1 周(元の並びの高さ)だけ動かします。1 周の長さが列で違うので、同じだけスクロールしても列によって流れる速さが変わり、画面に奥行きのあるずれが生まれます。
固定しておく長さは画面の高さの 2.4 倍で、その間に列が流れきります。イージングは付けず(none)、scrub を true にしてスクロール量にそのまま結びます。数値の scrub にすると、指を止めたあとも列が遅れて追いかけ、止めた位置と表示が合わなくなります。列の初めのずれ(列の幅の 11〜105%)で、3 列の画像の境目が横にそろわないようにしています。
列は「見える高さ + 1 周」より長くしておかないと、流す途中で下が切れて地がのぞきます。元の並びを JS で複製して足し、並びの下の間まで含めた 1 周ぶんだけ動かすので、流しきった見た目が最初の見た目と重なり、継ぎ目が出ません。画面の幅が変わると 1 周の長さも変わるので、invalidateOnRefresh で測り直し、足りない複製もそのときに足します。
速さの差は、列の枚数と縦横比で決まります。差を大きくしたいときは列ごとの枚数の差を広げ、全体を速くしたいときは固定する長さを短くします。動きを減らす設定では固定も流しもせず、止めた列のまま見せます。作品集やショップ、イベントのヒーローなど、写真や商品をたくさん見せたい最初の画面に向いています。
コード
<section class="riso-hero" aria-labelledby="riso-hero-title">
<div class="riso-hero__copy">
<p class="riso-hero__eyebrow">RISO PRINT STUDIO</p>
<h1 class="riso-hero__title" id="riso-hero-title">色を一枚ずつ、<br>手で刷る。</h1>
<p class="riso-hero__lead">ここに説明文が入ります。</p>
<p class="riso-hero__actions">
<a class="riso-hero__button" href="/shop/">ショップを見る</a>
<a class="riso-hero__link" href="/studio/">工房を見学する →</a>
</p>
</div>
<!-- 画像の列(飾りなので読み上げから外す)。--ratio は画像の縦横比、--shift は列の初めのずれ(列の幅に対する %)。
列ごとに枚数と縦横比が違うので 1 周の長さが変わり、同じスクロール量でも流れる速さが変わる。流すための複製は JS が作る -->
<div class="riso-hero__columns" aria-hidden="true">
<div class="riso-hero__column" style="--ratio: 4 / 5; --shift: -37.5%">
<div class="riso-hero__set">
<img src="/images/print-01.jpg" alt="" width="800" height="1000">
<img src="/images/print-02.jpg" alt="" width="800" height="1000">
<img src="/images/print-03.jpg" alt="" width="800" height="1000">
</div>
</div>
<div class="riso-hero__column" style="--ratio: 5 / 7; --shift: -105%">
<div class="riso-hero__set">
<img src="/images/print-04.jpg" alt="" width="800" height="1120">
<img src="/images/print-05.jpg" alt="" width="800" height="1120">
<img src="/images/print-06.jpg" alt="" width="800" height="1120">
<img src="/images/print-07.jpg" alt="" width="800" height="1120">
<img src="/images/print-08.jpg" alt="" width="800" height="1120">
</div>
</div>
<div class="riso-hero__column" style="--ratio: 10 / 11; --shift: -11%">
<div class="riso-hero__set">
<img src="/images/print-09.jpg" alt="" width="800" height="880">
<img src="/images/print-10.jpg" alt="" width="800" height="880">
<img src="/images/print-11.jpg" alt="" width="800" height="880">
<img src="/images/print-12.jpg" alt="" width="800" height="880">
</div>
</div>
</div>
</section>
.riso-hero {
--bg: #f2f1ed;
--ink: #262626;
--mute: #6b6b6b;
--on-ink: #ffffff;
--holder: rgb(38 38 38 / 0.12); /* 画像が読み込まれるまでの地 */
--gap: 16px; /* 画像の間 */
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); /* 左に文、右に画像の列 */
column-gap: 48px;
box-sizing: border-box;
height: 100svh; /* 固定する間、画面の高さぴったりにする */
padding: 0 32px 0 clamp(24px, 7.5vw, 96px);
overflow: hidden;
background: var(--bg);
color: var(--ink);
}
.riso-hero__copy { align-self: center; }
.riso-hero__eyebrow {
margin: 0;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--mute);
}
.riso-hero__title {
margin: 24px 0 0;
font-size: clamp(40px, 5vw, 64px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
}
.riso-hero__lead { margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }
.riso-hero__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px 32px;
margin: 48px 0 0;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
}
.riso-hero a { color: inherit; text-decoration: none; }
.riso-hero a:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
.riso-hero .riso-hero__button {
display: inline-flex;
align-items: center;
height: 48px;
padding: 0 32px;
border-radius: 999px;
background: var(--ink);
color: var(--on-ink);
}
/* 右の範囲に 3 列。はみ出した画像は範囲の上下で切る */
.riso-hero__columns {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: start;
gap: var(--gap);
min-height: 0;
overflow: hidden;
}
/* 並びの下にも間を入れておくと、並びの高さがそのまま 1 周の長さになる */
.riso-hero__set { display: grid; gap: var(--gap); padding-bottom: var(--gap); }
.riso-hero__set:first-child { margin-top: var(--shift, 0%); }
.riso-hero__set img {
display: block;
width: 100%;
height: auto;
aspect-ratio: var(--ratio);
object-fit: cover;
border-radius: 4px;
background: var(--holder);
}
/* 狭い画面では、文を上に、列をその下の残りの高さに並べる */
@media (max-width: 767px) {
.riso-hero {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
row-gap: 32px;
padding: 96px 16px 0;
}
.riso-hero__lead { font-size: 16px; }
.riso-hero__actions { margin-top: 32px; }
}
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
function flow(hero) {
const columns = [...hero.querySelectorAll(".riso-hero__column")];
const copies = [];
// 列は「見える高さ + 1 周」より長くしておく(流す途中で下が切れないように)。
// 幅が変わると 1 周の長さも変わるので、測り直すたびに足りない分を足す
const fill = () => {
for (const column of columns) {
const set = column.firstElementChild;
while (column.offsetHeight < column.parentElement.clientHeight + set.offsetHeight) {
copies.push(column.appendChild(set.cloneNode(true)));
}
}
};
fill();
const tl = gsap.timeline({
defaults: { ease: "none" }, // スクロール量と列の位置を比例させる
scrollTrigger: {
trigger: hero,
start: "top top",
end: "+=240%", // 画面の高さの 2.4 倍スクロールする間、ヒーローを固定して列を流す
pin: true,
scrub: true, // 数値にすると、指を止めたあとも列が遅れて追いかける
invalidateOnRefresh: true, // 画面の幅が変わったら、1 周の長さを測り直す
onRefreshInit: fill,
},
});
// どの列も自分の 1 周(元の並びの高さ)だけ上へ流す。1 周が長い列ほど速く、流しきると元の絵に戻る
for (const column of columns) {
const set = column.firstElementChild;
tl.fromTo(column, { y: 0 }, { y: () => -set.offsetHeight }, 0);
}
return () => copies.forEach((copy) => copy.remove());
}
// 動きを減らす設定では固定も流しもせず、止めた列のまま見せる(設定を切り替えると付け外しされる)
gsap.matchMedia().add("(prefers-reduced-motion: no-preference)", () => {
const undo = [...document.querySelectorAll(".riso-hero")].map(flow);
return () => undo.forEach((fn) => fn());
});