デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- ScrollTrigger
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
ScrollTrigger の onUpdate でスクロールの速さ(getVelocity、px/秒)を受け取り、画像の傾き(skewY)に変えて渡します。下へ速く流すほど画像の左が下がるように傾き、上へ戻すと逆に傾きます。止まるとまっすぐに戻るので、流しているあいだだけ、紙がしなるような手応えが出ます。傾けるのは画像だけで、題名はまっすぐのまま読めるようにしています。
傾きは、速さ 220px/秒ごとに 1°、上限は 8° です。値は gsap.quickTo に渡し、0.5 秒かけて追いつかせます(power3.out)。スクロールの速さは知らせのたびに細かく揺れるので、そのまま入れると画像が震えます。追いつくまでの時間を置くと揺れがならされ、止まったときも同じ速さでなめらかに戻ります。
急に止めると onUpdate の知らせが来なくなり、画像が傾いたまま残ります。スクロールが止まったときの知らせ(scrollEnd)で 0 に戻します。また、傾いた画像の角は「幅の半分 × tan(角度)」だけ上下にはみ出し(幅 340px・8° で約 24px)、transform を持つ要素は後から描かれるので、下の題名の上に乗ります。画像と題名の間をはみ出しより広くとり、題名に position: relative を付けて画像より上に描きます。
傾きを強くしたいときは、1° あたりの速さを小さくするか、上限を上げます。上限を上げるなら、画像と題名の間も広げてください。動きを減らす設定では傾けません。作品一覧やブログ・記事の一覧、商品のグリッドなど、同じ形のカードが縦に続くページに向いています。
コード
<section class="works" aria-labelledby="works-title">
<div class="works__head">
<div>
<p class="works__label">Works — 2026</p>
<h2 class="works__title" id="works-title">制作実績</h2>
</div>
<p class="works__lead">ここに説明文が入ります。</p>
</div>
<!-- 傾くのは .work__image だけ。題名はまっすぐのまま読めるようにする -->
<ul class="works__grid">
<li><a class="work" href="/works/kitakaze-coffee/">
<img class="work__image" src="/images/work-kitakaze.jpg" alt="" width="800" height="1000">
<span class="work__title">北風珈琲店</span> <span class="work__meta">ブランディング</span>
</a></li>
<li><a class="work" href="/works/shizuku-denki/">
<img class="work__image" src="/images/work-shizuku.jpg" alt="" width="800" height="1000">
<span class="work__title">しずく電機</span> <span class="work__meta">プロダクトサイト</span>
</a></li>
<li><a class="work" href="/works/komorebi/">
<img class="work__image" src="/images/work-komorebi.jpg" alt="" width="800" height="1000">
<span class="work__title">こもれび</span> <span class="work__meta">アプリのUIデザイン</span>
</a></li>
</ul>
<div class="works__head">
<div>
<p class="works__label">Archive</p>
<h3 class="works__title">2025年</h3>
</div>
<p class="works__count">3件のプロジェクト</p>
</div>
<ul class="works__grid">
<li><a class="work" href="/works/minato-music/">
<img class="work__image" src="/images/work-minato.jpg" alt="" width="800" height="1000">
<span class="work__title">みなと音楽祭</span> <span class="work__meta">広告キャンペーン</span>
</a></li>
<li><a class="work" href="/works/hitotsu/">
<img class="work__image" src="/images/work-hitotsu.jpg" alt="" width="800" height="1000">
<span class="work__title">季刊ひとつ</span> <span class="work__meta">誌面デザイン</span>
</a></li>
<li><a class="work" href="/works/ishi-to-kami/">
<img class="work__image" src="/images/work-ishi-to-kami.jpg" alt="" width="800" height="1000">
<span class="work__title">石と紙展</span> <span class="work__meta">展示デザイン</span>
</a></li>
</ul>
<div class="works__head">
<div>
<p class="works__label">Archive</p>
<h3 class="works__title">2024年</h3>
</div>
<p class="works__count">3件のプロジェクト</p>
</div>
<ul class="works__grid">
<li><a class="work" href="/works/yamanocha/">
<img class="work__image" src="/images/work-yamanocha.jpg" alt="" width="800" height="1000">
<span class="work__title">山ノ茶</span> <span class="work__meta">パッケージデザイン</span>
</a></li>
<li><a class="work" href="/works/tsunagu/">
<img class="work__image" src="/images/work-tsunagu.jpg" alt="" width="800" height="1000">
<span class="work__title">つなぐ会議</span> <span class="work__meta">カンファレンス</span>
</a></li>
<li><a class="work" href="/works/yasuragi/">
<img class="work__image" src="/images/work-yasuragi.jpg" alt="" width="800" height="1000">
<span class="work__title">やすらぎ</span> <span class="work__meta">ヘルスケアアプリ</span>
</a></li>
</ul>
<div class="works__head">
<div>
<p class="works__label">Archive</p>
<h3 class="works__title">2023年以前</h3>
</div>
<a class="works__more" href="/works/archive/">過去の作品をすべて見る →</a>
</div>
</section>
.works {
--bg: #111111;
--ink: #eeece6;
--mute: #898989;
--mute-small: #a8a6a0;
--holder: #262626; /* 画像が読み込まれるまでの地 */
padding: 0 clamp(16px, 7.5vw, 96px) 96px;
background: var(--bg);
color: var(--ink);
}
/* 見出しの区間。上下に広く間をとり、画像の行と交互に並べる */
.works__head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 24px 64px;
padding-block: clamp(96px, 24vh, 240px);
}
.works__label {
margin: 0;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--mute-small);
}
.works__title {
margin: 16px 0 0;
font-size: clamp(40px, 6vw, 72px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
}
.works__lead { margin: 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
.works__count { margin: 0; font-size: 18px; letter-spacing: 0.04em; color: var(--mute); font-variant-numeric: tabular-nums; }
.works a { color: inherit; }
.works a:focus-visible { outline: 2px solid var(--ink); outline-offset: 8px; }
.works__more {
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration-thickness: 1px;
text-underline-offset: 0.4em;
}
/* 3 列。真ん中の列だけ下げて段差をつける */
.works__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: start;
gap: 48px 32px;
margin: 0;
padding: 0;
list-style: none;
}
.works__grid > :nth-child(3n + 2) { margin-top: 48px; }
/* 画像と題名の間(32px)は、傾いた角のはみ出し(幅の半分 × tan 8° ≒ 24px)より広くとる */
.work {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: last baseline;
gap: 32px 16px;
text-decoration: none;
}
.work__image {
grid-column: 1 / -1;
display: block;
width: 100%;
height: auto;
aspect-ratio: 4 / 5;
object-fit: cover;
border-radius: 4px;
background: var(--holder);
}
/* transform を持つ画像は後から描かれるので、位置を持たせて題名を画像より上に描く */
.work__title,
.work__meta { position: relative; }
.work__title { font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.work__meta { font-size: 14px; letter-spacing: 0.04em; color: var(--mute-small); white-space: nowrap; }
@media (max-width: 767px) {
.works__grid { grid-template-columns: minmax(0, 1fr); }
.works__grid > :nth-child(3n + 2) { margin-top: 0; }
}
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
const MAX_SKEW = 8; // 傾きの上限(deg)。上げるなら、画像と題名の間も広げる
const PER_DEGREE = 220; // スクロールの速さ(px/秒)がこれだけ増えるごとに 1° 傾ける
// 動きを減らす設定では傾けない(設定を切り替えると付け外しされる)
gsap.matchMedia().add("(prefers-reduced-motion: no-preference)", () => {
const images = gsap.utils.toArray(".work__image");
if (images.length === 0) return;
// 傾きは 0.5 秒かけて追いつかせる。速さの細かい揺れがならされ、止まるとなめらかに戻る
const skewTo = gsap.quickTo(images, "skewY", { duration: 0.5, ease: "power3.out" });
const clamp = gsap.utils.clamp(-MAX_SKEW, MAX_SKEW);
ScrollTrigger.create({
// getVelocity() は px/秒。下へ流すと負の角度(画像の左が下がる)、上へ戻すと逆に傾く
onUpdate: (self) => skewTo(clamp(-self.getVelocity() / PER_DEGREE)),
});
// 急に止まったときは onUpdate が来ないので、スクロールが止まったらまっすぐに戻す
const settle = () => skewTo(0);
ScrollTrigger.addEventListener("scrollEnd", settle);
return () => ScrollTrigger.removeEventListener("scrollEnd", settle);
});