本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
カーソル

作品名のリストをなぞると、カーソルの位置に作品の画像が出てついてくる

デモ

操作:カーソルを動かす・カーソルを重ねる。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
カーソル
動作
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 枚が追いかけて入れ替わるだけなので一覧が読みやすく、制作会社やデザイン事務所の作品一覧、ニュースや採用情報の一覧に向いています。

コード

HTML
<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>

CSS
.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; }

JavaScript
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();
  });
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。