デモ
デモを読み込めませんでした。
仕様
- 用途
- カーソル
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
作品名のリストの上に画像を 1 枚だけ重ねておき、行に入ると開いてポインタについてくるようにします。画像の枠の中には、全作品の画像を行と同じ順に縦に並べた帯を入れ、なぞる行が変わるたびに帯を縦に滑らせて、その行の画像に入れ替えます。下の行へ移れば画像が上へ流れ、上の行へ移れば下へ流れるので、リストの並びと画像の動きがそろいます。リストから出ると、画像は中心へ向かって閉じます。
画像は quickTo で 0.6 秒かけてポインタに追いつきます。傾きは、ポインタと遅れて追う画像との横のずれ 1px につき 0.06° で、最大 12° にとどめます。速く横切るほどずれが広がって大きく傾き、止まれば画像が追いつくので、傾きも自然に 0 へ戻ります。速さを別に測らなくてよく、止まったあとに傾きが残ることもありません。開くのは 0.5 秒の power3.out、閉じるのは 0.35 秒の power2.in です。
画像は、作品名の文字より奥に置きます。手前に置くと、なぞっている行の名前が画像に隠れて読めなくなります。罫線は位置を持たない li に引き、文字は位置を持つリンクに書くと、線は画像の後ろ、文字は画像の手前に描かれます。開き具合は CSS 変数の数値だけをトゥイーンし、clip-path はその変数から組みます。clip-path の文字列そのものを補間すると、ブラウザが値を縮めて返すために形が崩れることがあります。
画像は飾りとして読み上げから外し、作品は行の文字で伝えます。なぞっていない行を淡くするのは CSS の :hover で、キーボードで行へ移ったときは、焦点のある行のほかを淡くして、行の右寄りに画像を開きます。指で触る端末ではつながず、リンクの一覧のままにします。動きを減らす設定では、遅れと傾きと帯の滑りを無くし、その場で画像を切り替えます。
つまみは、画像の大きさ、追いつく時間、傾きの強さと最大、開く・閉じる時間です。傾きを弱めると落ち着いた作品集に、強めると勢いのあるスタジオらしさが出ます。画像を次々に置いて消していく表現と違い、1 枚が追いかけて入れ替わるだけなので一覧が読みやすく、制作会社やデザイン事務所の作品一覧、ニュースや採用情報の一覧に向いています。
コード
<section class="work-list" aria-labelledby="work-list-title">
<div class="work-list__head">
<h2 class="work-list__title" id="work-list-title">制作実績</h2>
<p class="work-list__range">2023〜2026年</p>
</div>
<ul class="work-list__items">
<li><a class="work-list__row" href="/work/minato-akari/"><span class="work-list__name">港町のあかり</span><span class="work-list__kind">ブランド映像</span><span class="work-list__year">2026年</span></a></li>
<li><a class="work-list__row" href="/work/tsuchi-to-kama/"><span class="work-list__name">土と窯の工房</span><span class="work-list__kind">ウェブサイト</span><span class="work-list__year">2025年</span></a></li>
<li><a class="work-list__row" href="/work/yoru-wo-oyogu/"><span class="work-list__name">夜を泳ぐ</span><span class="work-list__kind">ミュージックビデオ</span><span class="work-list__year">2025年</span></a></li>
<li><a class="work-list__row" href="/work/kami-no-niwa/"><span class="work-list__name">紙の庭</span><span class="work-list__kind">展示デザイン</span><span class="work-list__year">2024年</span></a></li>
<li><a class="work-list__row" href="/work/hoshimeguri-radio/"><span class="work-list__name">星めぐりラジオ</span><span class="work-list__kind">アプリ</span><span class="work-list__year">2023年</span></a></li>
</ul>
<!-- なぞった行の作品の画像。行と同じ順に縦に並べる。作品は行の文字で伝わるので、読み上げから外す -->
<div class="work-list__preview" aria-hidden="true">
<div class="work-list__strip">
<img src="/img/minato-akari.jpg" alt="" width="608" height="448">
<img src="/img/tsuchi-to-kama.jpg" alt="" width="608" height="448">
<img src="/img/yoru-wo-oyogu.jpg" alt="" width="608" height="448">
<img src="/img/kami-no-niwa.jpg" alt="" width="608" height="448">
<img src="/img/hoshimeguri-radio.jpg" alt="" width="608" height="448">
</div>
</div>
</section>
.work-list {
--bg: #111111;
--ink: #eeece6;
--dim: #585754; /* なぞっていない行の文字 */
--mute: #a8a6a0;
--line: rgba(238, 236, 230, 0.14);
--preview-w: 304px;
--preview-h: 224px;
position: relative;
isolation: isolate;
padding: 96px clamp(24px, 7.5vw, 96px);
background: var(--bg);
color: var(--ink);
}
.work-list__head {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
color: var(--mute);
font-size: 13px;
line-height: 1;
}
.work-list__title {
margin: 0;
font-size: inherit;
font-weight: 700;
letter-spacing: 0.06em;
}
.work-list__range { margin: 0; letter-spacing: 0.04em; }
.work-list__items {
margin: 0;
padding: 0;
list-style: none;
border-bottom: 1px solid var(--line);
}
/* 罫線は li に引く。位置を持たない li の線は画像の後ろ、位置を持つ行の文字は画像の手前に描かれる */
.work-list__items li { border-top: 1px solid var(--line); }
.work-list__row {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 24px;
min-height: 88px;
color: var(--ink);
text-decoration: none;
transition: color 0.3s;
}
/* なぞっている行(キーボードでは焦点のある行)のほかを淡くする */
.work-list__items:hover .work-list__row:not(:hover),
.work-list__items:has(:focus-visible) .work-list__row:not(:focus-visible) {
color: var(--dim);
}
.work-list__row:focus-visible { outline: 2px solid var(--ink); outline-offset: -2px; }
.work-list__name {
flex: 1;
font-size: clamp(28px, 5vw, 44px);
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.02em;
}
.work-list__kind { width: 200px; font-size: 14px; letter-spacing: 0.04em; }
.work-list__year { width: 64px; font-size: 14px; letter-spacing: 0.04em; text-align: right; font-variant-numeric: tabular-nums; }
@media (max-width: 767px) {
.work-list__kind { display: none; }
}
/* 画像は中心から開く。開き具合 --open(0〜1)は JS が動かす。閉じているあいだは見えない */
.work-list__preview {
--open: 0;
position: absolute;
left: 0;
top: 0;
width: var(--preview-w);
height: var(--preview-h);
overflow: hidden;
pointer-events: none;
clip-path: inset(calc(50% - 50% * var(--open)) round 4px);
}
.work-list__strip img { display: block; width: var(--preview-w); height: var(--preview-h); object-fit: cover; }
import { gsap } from "gsap";
// 指で触る端末にはポインタが無いので、画像は出さずにリンクの一覧のままにする
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".work-list").forEach((list) => {
const items = list.querySelector(".work-list__items");
const rows = list.querySelectorAll(".work-list__row");
const preview = list.querySelector(".work-list__preview");
const strip = list.querySelector(".work-list__strip");
if (!canHover || !items || !preview) return;
const state = { open: 0 }; // 画像の開き具合(0〜1)
let pointerX = 0;
gsap.set(preview, { xPercent: -50, yPercent: -50 }); // 画像の中心をポインタに合わせる
// 傾きは、ポインタと遅れて追う画像との横のずれから決める。止まって追いつけば 0 に戻る
const tilt = () => {
const gap = pointerX - gsap.getProperty(preview, "x");
gsap.set(preview, { rotation: gsap.utils.clamp(-12, 12, gap * 0.06) });
};
// 動きを減らす設定では遅れずに置き、傾けない(quickTo は duration が 0 だと値が動かない)
const follow = (prop) =>
reduceMotion ? (value) => gsap.set(preview, { [prop]: value }) : gsap.quickTo(preview, prop, { duration: 0.6, ease: "power3", onUpdate: tilt });
const moveX = follow("x");
const moveY = follow("y");
const aim = (x, y) => {
pointerX = x;
// 閉じきっていれば、画像を遠くから飛ばさず、その場で開く
if (state.open < 0.02) {
moveX(x, x);
moveY(y, y);
}
moveX(x);
moveY(y);
};
const openTo = (open, duration, ease) =>
gsap.to(state, { open, duration: reduceMotion ? 0 : duration, ease, overwrite: "auto", onUpdate: () => preview.style.setProperty("--open", state.open) });
// 行 i の作品の画像を出す。開いているあいだは帯が縦に滑って入れ替わる
const show = (i) => {
const yPercent = (-100 / rows.length) * i;
if (state.open < 0.02) gsap.set(strip, { yPercent });
else gsap.to(strip, { yPercent, duration: reduceMotion ? 0 : 0.5, ease: "power3.out", overwrite: "auto" });
openTo(1, 0.5, "power3.out");
};
const hide = () => openTo(0, 0.35, "power2.in");
items.addEventListener("pointermove", (event) => {
if (event.pointerType === "touch") return;
const box = list.getBoundingClientRect();
aim(event.clientX - box.left, event.clientY - box.top);
});
items.addEventListener("pointerleave", hide);
rows.forEach((row, i) => {
row.addEventListener("pointerenter", (event) => event.pointerType !== "touch" && show(i));
// キーボードで行へ移ったときは、行の中ほどの右寄りに画像を出す
row.addEventListener("focus", () => {
if (!row.matches(":focus-visible")) return;
const box = list.getBoundingClientRect();
const r = row.getBoundingClientRect();
show(i);
aim(r.left - box.left + r.width * 0.6, r.top - box.top + r.height / 2);
});
});
items.addEventListener("focusout", (event) => {
if (!items.contains(event.relatedTarget) && !items.matches(":hover")) hide();
});
});