デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
上のチップを押すと、合わない作品が縮みながら消え、残った作品が新しい位置へ滑って詰まります。「すべて」に戻すと、消えていた作品がそれぞれの位置で膨らんで現れます。並べ替える前にすべての作品の位置と大きさを測り、並べ替えたあとの位置との差を補間する FLIP という型で、コードの節では GSAP の Flip プラグインで組んでいます。動いている途中で別のチップを押しても、今いる位置から測り直して新しい並びへ向かい直すので、作品が跳びません。
残った作品は 0.75 秒の power3.inOut で滑り、新しい並びの順に 0.03 秒ずつずらすので、左上から順に詰まっていくように見えます。合わない作品は 0.35 秒で 0.6 倍まで縮めながら消し、加わる作品は 0.45 秒待ってから、同じ 0.6 倍から膨らませます。消える作品が退いてから出すので、同じ場所で 2 枚が重なりません。
動いている間は、作品を流れから外して(absolute)動かします。外さないと、消えていく作品がまだ場所を取っているあいだにグリッドが詰め直され、滑っている作品の行き先がずれます。ただし、すべての作品を流れから外すとグリッドの高さが 0 に潰れ、下の中身が跳ねます。グリッドの高さも前の高さから新しい高さへ補間し、作品が流れに戻ったところで高さの指定を外します。
押しているチップは aria-pressed で伝え、絞り込んだ件数は読み上げにだけ届く文で知らせます。チップの件数は作品の数から数えるので、作品を足してもチップを書き直さずに済みます。動きを減らす設定では、並べ替えたまま動かしません。
つまみは、滑る時間とずらしの幅、消える・加わるときの大きさです。ずらしを広げると、作品が多いときに最後の作品がいつまでも動き出さないので、件数に合わせて詰めます。作品集や商品一覧、ブログや事例のカテゴリの絞り込みに向いています。
コード
<section class="gallery" aria-labelledby="gallery-title">
<div class="gallery__head">
<div>
<p class="gallery__eyebrow">Works</p>
<h2 class="gallery__title" id="gallery-title">制作実績</h2>
</div>
<!-- 選んでいるチップは aria-pressed で伝える。件数は作品の数から JS が入れる -->
<div class="gallery__filters" role="group" aria-label="分類で絞り込む">
<button class="chip" type="button" data-filter="all" aria-pressed="true">すべて <span class="chip__count"></span></button>
<button class="chip" type="button" data-filter="branding" aria-pressed="false">ブランディング <span class="chip__count"></span></button>
<button class="chip" type="button" data-filter="web" aria-pressed="false">Web制作 <span class="chip__count"></span></button>
<button class="chip" type="button" data-filter="motion" aria-pressed="false">映像 <span class="chip__count"></span></button>
</div>
</div>
<!-- 絞り込んだ件数を読み上げにだけ知らせる -->
<p class="gallery__status" role="status"></p>
<ul class="gallery__grid">
<li class="work" data-cat="branding">
<a class="work__link" href="/work/harvest/">
<img class="work__image" src="/images/work/harvest.jpg" alt="" width="640" height="400">
<span class="work__cat">ブランディング</span>
<span class="work__title">みのり農園のロゴ</span>
</a>
</li>
<li class="work" data-cat="web">
<a class="work__link" href="/work/harbor/">
<img class="work__image" src="/images/work/harbor.jpg" alt="" width="640" height="400">
<span class="work__cat">Web制作</span>
<span class="work__title">みなと銀行のアプリ</span>
</a>
</li>
<li class="work" data-cat="motion">
<a class="work__link" href="/work/pulse/">
<img class="work__image" src="/images/work/pulse.jpg" alt="" width="640" height="400">
<span class="work__cat">映像</span>
<span class="work__title">ブランド紹介映像</span>
</a>
</li>
<li class="work" data-cat="web">
<a class="work__link" href="/work/northlight/">
<img class="work__image" src="/images/work/northlight.jpg" alt="" width="640" height="400">
<span class="work__cat">Web制作</span>
<span class="work__title">北光工業の製品サイト</span>
</a>
</li>
<li class="work" data-cat="branding">
<a class="work__link" href="/work/oat-ember/">
<img class="work__image" src="/images/work/oat-ember.jpg" alt="" width="640" height="400">
<span class="work__cat">ブランディング</span>
<span class="work__title">焙煎所のパッケージ</span>
</a>
</li>
<li class="work" data-cat="motion">
<a class="work__link" href="/work/loop-reel/">
<img class="work__image" src="/images/work/loop-reel.jpg" alt="" width="640" height="400">
<span class="work__cat">映像</span>
<span class="work__title">SNS用の短い動画</span>
</a>
</li>
<li class="work" data-cat="web">
<a class="work__link" href="/work/freight/">
<img class="work__image" src="/images/work/freight.jpg" alt="" width="640" height="400">
<span class="work__cat">Web制作</span>
<span class="work__title">配送管理システム</span>
</a>
</li>
<li class="work" data-cat="branding">
<a class="work__link" href="/work/kiln/">
<img class="work__image" src="/images/work/kiln.jpg" alt="" width="640" height="400">
<span class="work__cat">ブランディング</span>
<span class="work__title">陶芸工房のロゴ</span>
</a>
</li>
<li class="work" data-cat="motion">
<a class="work__link" href="/work/orbit/">
<img class="work__image" src="/images/work/orbit.jpg" alt="" width="640" height="400">
<span class="work__cat">映像</span>
<span class="work__title">番組のオープニング</span>
</a>
</li>
</ul>
</section>
.gallery {
--bg: #f2f1ed;
--ink: #262626;
--mute: #6b6b6b; /* 見出しに添える英大文字のラベル */
--line: rgba(38, 38, 38, 0.12); /* 選んでいないチップの枠 */
--on-ink: #ffffff; /* 選んだチップの文字 */
--on-image: #ffffff; /* 作品の上のキャプション */
--columns: 240px; /* 作品の最小の幅。画面の幅に合わせて列の数が変わる */
--gap: 16px;
padding: 48px clamp(24px, 7vw, 96px);
background: var(--bg);
color: var(--ink);
}
.gallery__head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-end;
gap: 24px 32px;
margin-bottom: 32px;
}
.gallery__eyebrow { margin: 0 0 16px; color: var(--mute); font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.gallery__title { margin: 0; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.gallery__filters { display: flex; flex-wrap: wrap; gap: 8px; }
/* 選んでいないチップは罫線の色の枠、選んだチップは墨色の塗り */
.chip {
display: inline-flex;
white-space: nowrap;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 16px;
border: 0;
border-radius: 999px;
background-color: transparent;
box-shadow: inset 0 0 0 1px var(--line);
color: var(--ink);
font: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
transition: background-color 0.25s, box-shadow 0.25s, color 0.25s;
}
.chip[aria-pressed="true"] { background-color: var(--ink); box-shadow: inset 0 0 0 1px var(--ink); color: var(--on-ink); }
.chip:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
.chip__count { font-size: 12px; opacity: 0.7; font-variant-numeric: tabular-nums; }
.gallery__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--columns), 1fr));
gap: var(--gap);
margin: 0;
padding: 0;
list-style: none;
}
/* キャプションは作品の下端に積み、画像と幕はその奥に敷く */
.work__link {
position: relative;
isolation: isolate;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 8px;
aspect-ratio: 8 / 5;
padding: 16px;
overflow: hidden;
border-radius: 4px;
color: var(--on-image);
text-decoration: none;
}
.work__link:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
.work__image { position: absolute; inset: 0; z-index: -1; width: 100%; height: 100%; object-fit: cover; }
/* 画像の明るさに関わらずキャプションが読めるよう、下から暗い幕をかける */
.work__link::after {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background: linear-gradient(0deg, rgba(17, 17, 17, 0.64), rgba(17, 17, 17, 0.4) 36%, rgba(17, 17, 17, 0) 64%);
}
.work__cat { font-size: 12px; font-weight: 700; line-height: 1; letter-spacing: 0.06em; opacity: 0.86; }
.work__title { font-size: 20px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
/* 読み上げにだけ届く文。画面には出さない */
.gallery__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.chip { transition: none; }
}
import { gsap } from "gsap";
import { Flip } from "gsap/Flip";
gsap.registerPlugin(Flip);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const MOVE = 0.75; // 残った作品が新しい位置へ滑る時間(秒)
const STAGGER = 0.03; // 新しい並びの 1 つごとのずれ
const LEAVE = 0.35; // 合わない作品が縮んで消える時間
const ENTER = 0.5; // 加わる作品が膨らむ時間
const ENTER_DELAY = 0.45; // 消える作品が退いてから膨らませる
const SMALL = 0.6; // 消える・加わるときの大きさ
document.querySelectorAll(".gallery").forEach((gallery) => {
const chips = [...gallery.querySelectorAll(".chip")];
const grid = gallery.querySelector(".gallery__grid");
const works = [...gallery.querySelectorAll(".work")];
const status = gallery.querySelector(".gallery__status");
const matches = (work, filter) => filter === "all" || work.dataset.cat === filter;
const count = (filter) => works.filter((work) => matches(work, filter)).length;
// 件数は作品の数から数える。作品を足しても、チップを書き直さなくてよい
chips.forEach((chip) => {
chip.querySelector(".chip__count").textContent = count(chip.dataset.filter);
});
function select(chip) {
const filter = chip.dataset.filter;
const from = grid.offsetHeight; // 動いている途中なら、途中の高さ
// 1. 今の位置と大きさを測る(動いている途中なら、途中の位置を測る)
const state = Flip.getState(works);
// 2. 並びを変える。合わない作品を外すと、グリッドが詰め直される
works.forEach((work) => {
work.style.display = matches(work, filter) ? "" : "none";
});
chips.forEach((c) => c.setAttribute("aria-pressed", String(c === chip)));
status.textContent = `${count(filter)}件の作品を表示しています`;
// 動きを減らす設定では、並べ替えたまま動かさない
if (reduceMotion) return;
grid.style.height = "";
const to = grid.offsetHeight;
// 3. 測った位置から、新しい位置へ補間する
Flip.from(state, {
duration: MOVE,
ease: "power3.inOut",
stagger: STAGGER,
absolute: true, // 動いている間は流れから外す。外さないと、詰め直しに引きずられて行き先がずれる
onEnter: (els) => gsap.fromTo(els, { scale: SMALL, autoAlpha: 0 }, { scale: 1, autoAlpha: 1, duration: ENTER, delay: ENTER_DELAY, ease: "power3.out" }),
onLeave: (els) => gsap.to(els, { scale: SMALL, autoAlpha: 0, duration: LEAVE, ease: "power2.in" }),
})
// 作品を流れから外すと、グリッドの高さが 0 に潰れて下の中身が跳ねる。
// 前の高さから新しい高さへ補間し、作品が流れに戻る最後に高さを外す
.fromTo(grid, { height: from }, { height: to, duration: MOVE, ease: "power3.inOut" }, 0)
.set(grid, { clearProps: "height" });
}
chips.forEach((chip) => chip.addEventListener("click", () => select(chip)));
});