デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- 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近くまで詰めると、アイコン同士が深く重なって、人数の多さが強く伝わります。共同編集の参加者やレビューした人の一覧のように、ふだんは顔ぶれだけを小さくまとめておき、気になったときに名前を確かめてもらう場所で使える形です。
コード
<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>
.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; }
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();
});
});