デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
画像を包む外枠は動かさず、中の画像の箱を半分の大きさ・大きな角丸・透明にしておき、画面に入ったら、中心を軸に元の大きさへ広げながら、角を写真の 4px へ戻します。箱の中の画像は 1.3 倍の寄りから逆に引くので、広がる窓の中で景色が落ち着いていきます。添えた番号・作品名・分野は、少し遅れて 24px 下から順に現れます。
広がりは 1.4 秒、画像の引きは 1.6 秒で、どちらも power3.out です。角の丸みは 120px から 4px へ、大きさと同じ速さで戻すので、丸い窓がそのまま写真の形へ変わっていくように見えます。文字は 0.25 秒遅らせ、0.08 秒ずつずらして上げます。
見張るのは、拡大する箱ではなく、動かない外枠です。拡大する箱を見張ると、縮んだ状態の大きさで画面に入ったかを測られ、出るのが遅れます。また、中心から広げるので、知らせを受ける線は rootMargin で画面の下を 35% 縮めて、深めに置きます。浅くすると、画像の中心がまだ画面の外にあるうちに広がりきってしまいます。
出すのは画面に入った 1 回だけなので、スクロールの位置を追い続ける ScrollTrigger は要らず、ブラウザの IntersectionObserver で足ります。同じときに線を越えた画像は 1 回の知らせにまとまって届くので、その順番に 0.15 秒ずつずらします。隠すのは大きさと不透明度だけなので、画像の説明や作品名は読み上げに届き、動きを減らす設定では最初から出たまま見せます。
縮める倍率を小さくするほど、広がる動きが大きく目を引きます。角の丸みを大きめから始めると、丸い窓から写真が現れる印象が強まります。狭い画面では画像も小さくなるので、角の丸みの始まりも小さくします。実績や制作事例のように、1 件ずつ大きな画像で見せる一覧に向いています。
コード
<section class="work" aria-labelledby="work-title">
<p class="work__eyebrow">Recent work</p>
<h2 class="work__title" id="work-title">新しく手がけた仕事</h2>
<!-- 見張るのは動かない外枠(.scale-in)。広げるのは中の .scale-in__media -->
<article class="project">
<div class="scale-in">
<div class="scale-in__media">
<img src="/img/summer-market.jpg" alt="夕焼けの空に淡い太陽が浮かぶキービジュアル" width="1600" height="1000">
</div>
</div>
<div class="project__text">
<p class="project__number">01</p>
<h3 class="project__name">夏至の市</h3>
<p class="project__meta">ブランドアイデンティティ・2026年</p>
</div>
</article>
<article class="project project--reverse">
<div class="scale-in">
<div class="scale-in__media">
<img src="/img/kodama.jpg" alt="青灰色の渦の上に淡い黄色の円が浮かぶキービジュアル" width="1600" height="1000">
</div>
</div>
<div class="project__text">
<p class="project__number">02</p>
<h3 class="project__name">こだま</h3>
<p class="project__meta">プロダクトローンチ</p>
</div>
</article>
<article class="project">
<div class="scale-in">
<div class="scale-in__media">
<img src="/img/choryu.jpg" alt="緑の地に斜めの細い線が流れる誌面の表紙" width="1600" height="1000">
</div>
</div>
<div class="project__text">
<p class="project__number">03</p>
<h3 class="project__name">潮流</h3>
<p class="project__meta">エディトリアルデザイン</p>
</div>
</article>
</section>
.work {
--bg: #111111;
--ink: #eeece6;
--mute-small: #a8a6a0;
--placeholder: #262626; /* 画像が届くまでの地 */
--radius: 4px; /* 広がりきった画像の角 */
--radius-from: 120px; /* 縮んでいるときの角。JS が読むので px で書く */
padding: 96px 6vw 24px;
background: var(--bg);
color: var(--ink);
}
.work__eyebrow {
margin: 0 0 16px;
font-size: 13px;
font-weight: 700;
line-height: 1;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--mute-small);
}
.work__title {
margin: 0 0 64px;
font-size: clamp(36px, 5vw, 64px);
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.02em;
word-break: auto-phrase;
}
/* 画像と文字を横に並べ、1 件ごとに左右を入れ替える */
.project {
display: grid;
grid-template-columns: 1.7fr 1fr;
gap: 48px;
align-items: end;
margin-bottom: 96px;
}
.project--reverse { grid-template-columns: 1fr 1.7fr; }
.project--reverse .scale-in { order: 1; }
.project--reverse .project__text { text-align: right; }
/* 外枠は動かさず、中の media を中心から広げる */
.scale-in {
position: relative;
aspect-ratio: 16 / 10;
}
.scale-in__media {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: var(--radius);
background: var(--placeholder);
}
.scale-in__media img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.project__text { padding-bottom: 8px; }
.project__number { margin: 0; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; color: var(--mute-small); font-variant-numeric: tabular-nums; }
.project__name { margin: 16px 0 0; font-size: clamp(32px, 3.4vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.project__meta { margin: 8px 0 0; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; color: var(--mute-small); }
/* 狭い画面では画像の下に文字を置き、縮んでいるときの角も小さくする */
@media (max-width: 720px) {
.work { --radius-from: 56px; }
.project,
.project--reverse { grid-template-columns: 1fr; gap: 24px; margin-bottom: 64px; }
.project--reverse .scale-in { order: 0; }
.project--reverse .project__text { text-align: left; }
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const STAGGER = 0.15; // 同じときに画面へ入った画像どうしの間隔(秒)
// 1 件分の出方。中の media を中心から広げながら角を写真の形へ戻し、画像は寄った所から引く。
// 終わりの値(大きさ 1・CSS の角・不透明)は CSS のままなので、from の値だけを渡す。文字は少し遅れて下から順に
function scaleIn(project, radiusFrom) {
const frame = project.querySelector(".scale-in");
return gsap
.timeline({ paused: true })
.from(frame.querySelector(".scale-in__media"), { scale: 0.5, borderRadius: radiusFrom, opacity: 0, duration: 1.4, ease: "power3.out" })
.from(frame.querySelector("img"), { scale: 1.3, duration: 1.6, ease: "power3.out" }, 0)
.from(project.querySelector(".project__text").children, { y: 24, opacity: 0, duration: 0.8, ease: "power3.out", stagger: 0.08 }, 0.25);
}
document.querySelectorAll(".work").forEach((section) => {
// 動きを減らす設定では何もしない(画像は最初から広がっている)
if (reduceMotion) return;
// 縮んでいるときの角は CSS から読む(狭い画面では小さくしている)
const radiusFrom = parseFloat(getComputedStyle(section).getPropertyValue("--radius-from"));
// 隠すのは大きさと opacity だけ。出る前の画像の説明と作品名も、読み上げには届く
const projects = [...section.querySelectorAll(".project")];
const reveals = new Map(projects.map((project) => [project.querySelector(".scale-in"), scaleIn(project, radiusFrom)]));
// 見張るのは、拡大する media ではなく動かない外枠。media を見張ると、縮んだ大きさで入ったかを測られて遅れる。
// 中心から広げるので、線は深めに置く(下を 35% 縮めた画面に、枠の上端が入ったら知らせが来る)。
// 同じときに越えた枠は 1 回の知らせにまとまって届くので、その順番でずらして広げる
const io = new IntersectionObserver(
(entries) => {
entries
.filter((entry) => entry.isIntersecting)
.forEach((entry, i) => {
io.unobserve(entry.target);
gsap.delayedCall(i * STAGGER, () => reveals.get(entry.target).play());
});
},
{ rootMargin: "0px 0px -35% 0px" }
);
reveals.forEach((_, frame) => io.observe(frame));
});