デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- 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 近くまで詰めると、人数の多さが強く見えます。共同編集の参加者の表示、チームの紹介、レビューした人の一覧に向いています。
コード
<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();
});
});