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

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

デモ

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

仕様

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

しくみと調整

重ねたアバター(顔のアイコン)を中央に集めて置き、カーソルが重なったら間を空けて横に広げ、下に名前を出します。各アバターの位置は「中央からの番号 × 間隔 × 直径」で決まり、間隔を重なり(直径の 0.62 倍)から広がり(1.2 倍)へ補間します。止めたタイムライン 1 本にまとめ、重なったら play()、離れたら reverse() します。

広がる動きは 0.6 秒の power3.out で、中央から外へ 0.03 秒ずつずらして開きます(stagger の from を center にします)。名前は 0.2 秒遅れて、8px 下から出します。重なりの順は左ほど手前にし、境目はカードの面と同じ白い太い縁で見せます。

カーソルは、アバターを包む要素で拾います。transform で広がったアバターの上も包む要素の中として扱われますが、広がったアバターの間のすき間や、顔と名前の間は外になり、そこを通ると pointerleave が起きて閉じかけ、ちらつきます。開いているあいだは包む要素の幅を広がった列の幅にし、名前の段まで透明な面で覆います。重なっているあいだの名前は押せないようにして、名前の所でホバーが始まらないようにします。

名前は透明にして隠すだけにし、読み上げには届くようにします。アバターを人ごとのリンクにしておくと、Tab で焦点が入ったときにも広げられます(マウスで押したときの焦点では広げません)。指で触る端末にはホバーが無いので、1 回目のタップで広げ、広がってから押したアバターへ移ります。

つまみは、重なりと広がりの 2 つの間隔、ずらす間隔、名前の遅れです。重なりを 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 サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。