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

カード

カードの上で重なったアバターがホバーで広がる

デモ

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

仕様

用途
カード
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

少しずつ重なって中央に固まっていた顔のアイコンが、カーソルを乗せると左右へほどけて一列になり、それぞれの下に名前が浮かびます。広がる動きは0.6秒の power3.out で、中央のアイコンから外側へ、わずかずつ遅れて動き始めます。

位置は「中央からの番号 × 間隔 × 直径」というひとつの式で決めていて、重なりと広がりの違いは間隔の値だけです。重なっているときは直径の0.62倍、広がると1.2倍。この間隔を一時停止したタイムラインの中で補間し、カーソルの出入りで play() と reverse() を切り替えています。重なり順は左ほど手前にし、カードの背景と同じ白の太い縁取りで、重なった境目が見えるようにしました。

厄介なのは、広がったあとのすき間です。transform で動いたアイコンの上は包む要素の内側として扱われても、アイコン同士の間や顔と名前の間は外側になり、そこをカーソルが通ると pointerleave が起きて列が閉じかけ、ちらつきます。開いている間は包む要素を列の幅まで広げ、名前の位置まで透明な要素で覆いました。反対に重なっている間は、透明な名前の位置で広がり始めないよう、名前をカーソルに反応させていません。

名前は透明にしているだけなので、スクリーンリーダーにはいつでも届きます。アイコンはメンバーごとのリンクで、Tab でフォーカスが入ったときにも列が広がり、マウスでクリックしたときのフォーカスでは広げません。タッチ端末では、1回目のタップで列を広げ、広がってからタップしたアイコンのリンクへ移ります。

重なりの間隔を0.5近くまで詰めると、アイコン同士が深く重なって、人数の多さが強く伝わります。共同編集の参加者やレビューした人の一覧のように、ふだんは顔ぶれだけを小さくまとめておき、気になったときに名前を確かめてもらう場所で使える形です。

コード

HTML
<section class="team" aria-labelledby="team-title">
  <div class="team__card">
    <h2 class="team__title" id="team-title">デザインチーム</h2>
    <p class="team__meta">メンバー12人・3人がオンライン中</p>

    <!-- 重ねたアバター。--i は左からの番号、--from と --to はアバターの背景色(顔写真なら span の代わりに img を置く)。
         カーソルを重ねるか、Tab でフォーカスが入ると広がって名前が表示される。名の span は、狭い画面で 2 行目へ送る -->
    <ul class="avatars" aria-label="メンバー">
      <li style="--i: 0; --from: #b27771; --to: #a28767">
        <a class="avatar" href="/members/sato/"><span class="avatar__face" aria-hidden="true">佐</span><span class="avatar__name">佐藤 <span>美咲</span></span></a>
      </li>
      <li style="--i: 1; --from: #a28767; --to: #928b53">
        <a class="avatar" href="/members/takahashi/"><span class="avatar__face" aria-hidden="true">高</span><span class="avatar__name">高橋 <span>健</span></span></a>
      </li>
      <li style="--i: 2; --from: #739374; --to: #688297">
        <a class="avatar" href="/members/mori/"><span class="avatar__face" aria-hidden="true">森</span><span class="avatar__name">森 <span>奈々</span></span></a>
      </li>
      <li style="--i: 3; --from: #688297; --to: #4a5b66">
        <a class="avatar" href="/members/ishii/"><span class="avatar__face" aria-hidden="true">石</span><span class="avatar__name">石井 <span>翔太</span></span></a>
      </li>
      <li style="--i: 4; --from: #877fa1; --to: #b27771">
        <a class="avatar" href="/members/fujita/"><span class="avatar__face" aria-hidden="true">藤</span><span class="avatar__name">藤田 <span>陽菜</span></span></a>
      </li>
      <li style="--i: 5">
        <a class="avatar avatar--more" href="/members/"><span class="avatar__face" aria-hidden="true">+7</span><span class="avatar__name">ほか7人</span></a>
      </li>
    </ul>

    <div class="team__foot">
      <p class="team__updated">2分前に更新</p>
      <button class="team__invite" type="button">招待する</button>
    </div>
  </div>
</section>

CSS
.team {
  --ground: #f2f1ed;  /* 背景色 */
  --surface: #ffffff; /* カードの背景色。アバターの縁もこの色にして、境目を見せる */
  --ink: #262626;     /* 文字・プライマリボタン・「ほか何人」の丸 */
  --mute: #6b6b6b;    /* 補足の文字 */
  --on-ink: #ffffff;  /* 塗りの上の文字 */
  --size: min(104px, 11vw); /* アバターの直径 */
  --count: 6;         /* アバターの数 */
  --overlap: 0.62;    /* 重なっているときの間隔(直径に対する比) */
  --spread: 1.2;      /* 広がったときの間隔 */
  padding: clamp(24px, 8vw, 96px) clamp(16px, 5vw, 96px);
  background: var(--ground);
  color: var(--ink);
}

.team__card { max-width: 816px; margin: 0 auto; padding: clamp(24px, 5vw, 40px); border-radius: 4px; background: var(--surface); }

.team__title { margin: 0; font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.team__meta,
.team__updated { margin: 8px 0 0; color: var(--mute); font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; }

/* 重なった列の幅。広がっているあいだは広がった列の幅と名前の段まで覆い、すき間でホバーが切れないようにする */
.avatars {
  position: relative;
  width: calc(var(--size) * (1 + (var(--count) - 1) * var(--overlap)));
  height: var(--size);
  margin: 48px auto 56px;
  padding: 0;
  list-style: none;
}

.avatars.is-open { width: calc(var(--size) * (1 + (var(--count) - 1) * var(--spread))); }
.avatars.is-open::after { content: ""; position: absolute; inset: 100% 0 auto; height: 48px; }

/* 中央から重なりの間隔で並べ、左のものほど上に重ねる。広げる量は JS が xPercent で足す */
.avatars li {
  position: absolute;
  top: 0;
  left: calc(50% + ((var(--i) - (var(--count) - 1) / 2) * var(--overlap) - 0.5) * var(--size));
  z-index: calc(var(--count) - var(--i));
  width: var(--size);
}

.avatar { display: block; border-radius: 50%; color: inherit; text-decoration: none; }
.avatar:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }

.avatar__face {
  display: grid;
  place-items: center;
  box-sizing: border-box;
  height: var(--size);
  border: calc(var(--size) * 0.04) solid var(--surface);
  border-radius: 50%;
  background: linear-gradient(135deg, var(--from), var(--to));
  color: var(--on-ink);
  font-size: calc(var(--size) * 0.29);
  font-weight: 700;
}

.avatar--more .avatar__face { background: var(--ink); }

/* 名前は透明にして隠す(読み上げには届く)。重なっているあいだは、名前の所でホバーが始まらないようにする */
.avatar__name {
  position: absolute;
  top: calc(100% + 12px);
  left: -50%;
  width: 200%;
  font-size: clamp(12px, 3.2vw, 15px);
  font-weight: 600;
  line-height: 1.4;
  letter-spacing: 0.04em;
  text-align: center;
  white-space: nowrap;
  opacity: 0;
}

.avatars:not(.is-open) .avatar__name { pointer-events: none; }
/* 狭い画面では広がっても間が狭いので、名前を名字と名の 2 行にする */
@media (max-width: 599px) { .avatar__name span { display: block; } .avatars { margin-bottom: 72px; } }

.team__foot { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.team__updated { margin: 0; }

/* プライマリボタン(ブラックのピル型) */
.team__invite {
  height: 40px;
  padding: 0 24px;
  border: 0;
  border-radius: 999px;
  background: var(--ink);
  color: var(--on-ink);
  font-family: inherit;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.04em;
  cursor: pointer;
}

.team__invite:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }

JavaScript
import { gsap } from "gsap";

// 動きを減らす設定では、ほぼ一瞬で切り替える
const speed = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 20 : 1;

document.querySelectorAll(".avatars").forEach((stack) => {
  const items = [...stack.children];
  const names = stack.querySelectorAll(".avatar__name");
  const style = getComputedStyle(stack);
  // 重なった位置は CSS が決めているので、広がった間隔との差(直径に対する比)だけ動かす
  const extra = parseFloat(style.getPropertyValue("--spread")) - parseFloat(style.getPropertyValue("--overlap"));
  const center = (items.length - 1) / 2;
  const fromCenter = { each: 0.03, from: "center" }; // 中央から外へずらす

  const spread = gsap
    .timeline({ paused: true })
    .fromTo(items, { xPercent: 0 }, { xPercent: (i) => (i - center) * extra * 100, duration: 0.6, ease: "power3.out", stagger: fromCenter })
    .fromTo(names, { opacity: 0, y: 8 }, { opacity: 1, y: 0, duration: 0.35, ease: "power2.out", stagger: fromCenter }, 0.2)
    .timeScale(speed);

  const open = () => {
    stack.classList.add("is-open");
    spread.play();
  };
  const shut = () => {
    stack.classList.remove("is-open");
    spread.reverse();
  };
  // カーソルもキーボードのフォーカスも列の外へ出たときだけ閉じる
  const close = () => !stack.matches(":hover") && !stack.querySelector(":focus-visible") && shut();

  // 列を包む ul でイベントを受け取る。広がったアバターの上も ul の中として扱われる
  stack.addEventListener("pointerenter", (event) => event.pointerType !== "touch" && open());
  stack.addEventListener("pointerleave", (event) => event.pointerType !== "touch" && close());

  // Tab でアバターのリンクへ移ったら広げ、フォーカスが列の外へ出たら閉じる(タップしたときのフォーカスでは広げない)
  stack.addEventListener("focusin", () => stack.querySelector(":focus-visible") && open());
  stack.addEventListener("focusout", (event) => !stack.contains(event.relatedTarget) && close());

  // タッチ端末にはホバーが無いので、1 回目のタップで広げ、広がってから押したアバターへ移る
  let touch = false;
  stack.addEventListener("pointerdown", (event) => (touch = event.pointerType === "touch"));
  stack.addEventListener("click", (event) => {
    if (!touch || stack.classList.contains("is-open")) return;
    event.preventDefault();
    open();
  });
  // 列の外をタップしたら閉じる
  document.addEventListener("pointerdown", (event) => {
    if (event.pointerType === "touch" && !stack.contains(event.target)) shut();
  });
});

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

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