デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- ScrollTrigger
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
ヒーローに扇状に並べた商品の写真が、スクロールに合わせて下のセクションのグリッドへ飛んでいき、枠に収まります。2 つの並びでの位置と大きさを測って差を補間する FLIP という型で、コードの節では GSAP の Flip プラグインで組んでいます。写真を扇の置き場に移して位置・大きさ・傾きを記録し、グリッドの枠へ戻してから、記録した位置から今の位置へ動かします。
動きにはイージングを付けず(none)、ヒーローの上端が画面の上端に着いてから、コレクションの下端が画面の下端に着くまでのスクロール量に、そのまま結びます(scrub)。写真は 1 枚が移る長さの 5% ずつ遅らせて飛ばし、着く直前から名前と値段を出します。写真はページの中にあるので、ページと一緒に流れながら位置の差を埋めていきます。
写真は初めからグリッドの枠に置いておき、測るときだけ扇へ移します。スクリプトが動かないときや動きを減らす設定でも、商品の一覧がそのまま並びます。写真は枠の中で position: absolute にしてください。飛んでいる間は写真に大きさが直接入るので、ふつうに置くと枠が写真に合わせて伸び、グリッドがずれて行き先まで狂います。
画面の幅が変わると扇と枠の位置も変わるので、幅が変わったときと、書体を読み込み終えたときに、測り直して作り直します。名前と値段は透明度だけで出し入れし、読み上げからは外しません。Tab で商品へ移ったときは、写真が飛んでいる途中でも名前と値段を見せます。
つまみは、扇の位置と傾き、1 枚ごとのずれ、ヒーローでの写真の大きさです。ずれを広げると、写真が 1 枚ずつ順に飛び立つように見えます。商品一覧・作品集・特集ページで、ヒーローから一覧へ導く所に向いています。
コード
<div class="flip">
<section class="flip-hero" aria-labelledby="flip-hero-title">
<p class="flip-hero__eyebrow">SPRING COLLECTION</p>
<h1 class="flip-hero__title" id="flip-hero-title">朝をゆっくり<wbr>過ごす道具。</h1>
<p class="flip-hero__lead">ここに説明文が入ります。</p>
<!-- 扇の置き場。写真はスクリプトがここに並べてから、下のグリッドへ飛ばす(--x・--y は中心、--r は傾き) -->
<div class="flip-hero__fan" aria-hidden="true">
<div class="flip-hero__spot" style="--x: 14.5%; --y: 71.5%; --r: -10deg"></div>
<div class="flip-hero__spot" style="--x: 28.8%; --y: 66.2%; --r: -6deg"></div>
<div class="flip-hero__spot" style="--x: 43%; --y: 63.8%; --r: -2deg"></div>
<div class="flip-hero__spot" style="--x: 57%; --y: 63.8%; --r: 2deg"></div>
<div class="flip-hero__spot" style="--x: 71.2%; --y: 66.2%; --r: 6deg"></div>
<div class="flip-hero__spot" style="--x: 85.5%; --y: 71.5%; --r: 10deg"></div>
</div>
</section>
<section class="flip-shop" aria-labelledby="flip-shop-title">
<div class="flip-shop__copy">
<p class="flip-shop__eyebrow">COLLECTION</p>
<h2 class="flip-shop__title" id="flip-shop-title">毎日使える、<br>6つの品。</h2>
<p class="flip-shop__lead">ここに説明文が入ります。</p>
<a class="flip-shop__button" href="/shop/">すべての商品を見る <span aria-hidden="true">→</span></a>
</div>
<!-- 写真は初めからグリッドの枠に置く(スクリプトが無いときや動きを減らす設定では、このまま並ぶ) -->
<ul class="flip-shop__grid">
<li><a class="product" href="/shop/ember-vase/">
<span class="product__slot"><img class="product__photo" src="/img/ember-vase.jpg" alt="" width="640" height="640"></span>
<span class="product__caption"><span>陶の花器</span> <span class="product__price">¥9,800</span></span>
</a></li>
<li><a class="product" href="/shop/moss-lamp/">
<span class="product__slot"><img class="product__photo" src="/img/moss-lamp.jpg" alt="" width="640" height="640"></span>
<span class="product__caption"><span>苔色のランプ</span> <span class="product__price">¥18,000</span></span>
</a></li>
<li><a class="product" href="/shop/harbor-mug/">
<span class="product__slot"><img class="product__photo" src="/img/harbor-mug.jpg" alt="" width="640" height="640"></span>
<span class="product__caption"><span>藍ぶちのマグ</span> <span class="product__price">¥4,200</span></span>
</a></li>
<li><a class="product" href="/shop/fern-pot/">
<span class="product__slot"><img class="product__photo" src="/img/fern-pot.jpg" alt="" width="640" height="640"></span>
<span class="product__caption"><span>シダの鉢植え</span> <span class="product__price">¥6,400</span></span>
</a></li>
<li><a class="product" href="/shop/dune-plates/">
<span class="product__slot"><img class="product__photo" src="/img/dune-plates.jpg" alt="" width="640" height="640"></span>
<span class="product__caption"><span>砂色の平皿</span> <span class="product__price">¥8,600</span></span>
</a></li>
<li><a class="product" href="/shop/night-candle/">
<span class="product__slot"><img class="product__photo" src="/img/night-candle.jpg" alt="" width="640" height="640"></span>
<span class="product__caption"><span>夜のキャンドル</span> <span class="product__price">¥3,300</span></span>
</a></li>
</ul>
</section>
</div>
.flip {
--bg: #f2f1ed; /* ヒーロー(明るい地) */
--ink: #262626;
--mute: #6b6b6b;
--shop-bg: #111111; /* コレクション(暗い地) */
--shop-surface: #262626;
--shop-ink: #eeece6;
--shop-mute: #a8a6a0;
--side: clamp(16px, 7.5vw, 96px);
--photo: clamp(80px, 16.5vw, 240px); /* ヒーローでの写真の幅 */
background: var(--bg);
color: var(--ink);
}
.flip-hero { padding: 128px var(--side) 96px; text-align: center; }
.flip-hero__eyebrow, .flip-shop__eyebrow { margin: 0 0 16px; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mute); }
.flip-hero__title { margin: 0; font-size: clamp(36px, 5vw, 64px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; word-break: keep-all; }
.flip-hero__lead { margin: 16px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
/* 扇は、写真を飛ばすときだけ出す(スクリプトが .is-flying を付ける) */
.flip-hero__fan { display: none; }
.flip.is-flying .flip-hero { position: relative; box-sizing: border-box; min-height: 100vh; }
.flip.is-flying .flip-hero__fan { display: block; position: absolute; inset: 0; pointer-events: none; }
.flip-hero__spot {
position: absolute;
left: calc(var(--x) - var(--photo) / 2);
top: calc(var(--y) - var(--photo) * 0.625);
width: var(--photo);
aspect-ratio: 4 / 5;
transform: rotate(var(--r));
}
.flip-shop {
display: grid;
grid-template-columns: 384px minmax(0, 1fr);
align-items: center;
gap: 48px 64px;
box-sizing: border-box;
min-height: 100vh;
padding: 48px var(--side);
background: var(--shop-bg);
color: var(--shop-ink);
}
.flip-shop__eyebrow { color: var(--shop-mute); }
.flip-shop__title { margin: 0; font-size: clamp(32px, 4vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.flip-shop__lead { margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
/* 主ボタン(暗い地では、明るい塗りに暗い文字) */
.flip-shop__button {
display: inline-flex;
align-items: center;
gap: 0.3em;
height: 48px;
margin-top: 32px;
padding: 0 24px;
border-radius: 999px;
background: var(--shop-ink);
color: var(--shop-bg);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
}
.flip-shop__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; margin: 0; padding: 0; list-style: none; }
.product { display: block; color: inherit; text-decoration: none; }
.product__slot { position: relative; display: block; aspect-ratio: 1; border-radius: 4px; background: var(--shop-surface); }
/* 写真は置き場いっぱいに広げる(縦横の比が変わっても歪まない)。絶対配置にするのは、飛んでいる間に
写真の大きさが枠の高さを押し広げ、グリッドがずれないようにするため。飛んでいる写真は、下のボタンの押す所をふさがない */
.product__photo { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border-radius: 4px; pointer-events: none; }
.product__caption {
display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; align-content: center;
gap: 0 8px; min-height: 48px; font-size: 15px; font-weight: 600; letter-spacing: 0.04em;
}
.product__price { font-size: 14px; font-weight: 400; color: var(--shop-mute); font-variant-numeric: tabular-nums; }
.product:focus-visible, .flip-shop__button:focus-visible { outline: 2px solid var(--shop-ink); outline-offset: 4px; }
/* Tab で移った商品は、写真が飛んでいる途中でも名前と値段を見せる(スクリプトが書く style より強くする) */
.product:focus-visible .product__caption { opacity: 1 !important; }
/* 狭い画面では、文言の下にグリッドを 2 列で置く */
@media (max-width: 900px) {
.flip-shop { grid-template-columns: minmax(0, 1fr); }
.flip-shop__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
}
import { gsap } from "gsap";
import { Flip } from "gsap/Flip";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(Flip, ScrollTrigger);
const STAGGER = 0.05; // 1 枚ごとのずれ(1 枚が移る長さ = 1 に対する比)
function flyToGrid(page) {
const hero = page.querySelector(".flip-hero");
const shop = page.querySelector(".flip-shop");
const spots = gsap.utils.toArray(".flip-hero__spot", page); // 始まりの並び(扇)
const photos = gsap.utils.toArray(".product__photo", page);
const slots = photos.map((photo) => photo.parentElement); // 終わりの並び(グリッドの枠)
const captions = gsap.utils.toArray(".product__caption", page);
let ctx;
function build() {
ctx?.revert(); // 前に作った Flip と ScrollTrigger を取り消す(写真は枠に戻る)
// 1. 写真を扇に置いて、位置・大きさ・傾きを測る
photos.forEach((photo, i) => spots[i].append(photo));
const state = Flip.getState(photos);
// 2. 写真をグリッドの枠へ戻す
photos.forEach((photo, i) => slots[i].append(photo));
ctx = gsap.context(() => {
// 3. 測った扇の位置から、枠の位置へ。ease: "none" で、スクロール量にそのまま比例させる
const tl = Flip.from(state, { duration: 1, ease: "none", stagger: STAGGER })
// 名前と値段は、写真が着く直前から出す(opacity だけにして、読み上げからは外さない)
.fromTo(captions, { opacity: 0, y: 8 }, { opacity: 1, y: 0, duration: 0.2, ease: "power1.out", stagger: STAGGER }, 0.8);
ScrollTrigger.create({
animation: tl,
trigger: hero,
start: "top top",
endTrigger: shop,
end: "bottom bottom",
scrub: true, // 数値にすると、指を止めたあとも写真が遅れて追いかける
});
});
}
page.classList.add("is-flying"); // 扇の置き場を出してから測る
build();
// 書体が読み込まれて文言の高さが変わると、測った位置がずれる。そろったら測り直す
let active = true;
document.fonts.ready.then(() => active && build());
// 画面の幅が変わると、扇と枠の位置も変わる。測り直して作り直す(高さだけの変化は、スマホのアドレスバーなので無視する)
let width = innerWidth;
let timer;
const onResize = () => {
if (innerWidth === width) return;
width = innerWidth;
clearTimeout(timer);
timer = setTimeout(build, 200);
};
addEventListener("resize", onResize);
return () => {
active = false;
removeEventListener("resize", onResize);
clearTimeout(timer);
ctx?.revert();
page.classList.remove("is-flying");
};
}
// 動きを減らす設定では飛ばさず、写真をはじめからグリッドに並べておく(設定を切り替えると付け外しされる)
gsap.matchMedia().add("(prefers-reduced-motion: no-preference)", () => {
const undo = [...document.querySelectorAll(".flip")].map(flyToGrid);
return () => undo.forEach((fn) => fn());
});