Avatar stack bump
Overlapping avatars bump one after another in a wave, then spread apart with name tooltips on hover, focus or tap.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useRef, useState, type FocusEvent } from "react";
import { useInView } from "@/lib/motion";
import styles from "./demo.module.css";
const PEOPLE = [
{ initials: "PR", name: "Priya Raman", role: "Design", tone: "teal" },
{ initials: "MD", name: "Marco Diaz", role: "Engineering", tone: "sky" },
{ initials: "LK", name: "Lea Kraus", role: "Product", tone: "sand" },
{ initials: "TB", name: "Tom Becker", role: "Engineering", tone: "ink" },
{ initials: "AN", name: "Ana Nunes", role: "Marketing", tone: "pale" },
];
const MORE = ["Sam", "Mia", "Raj", "Zoe", "Leo"];
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const inView = useInView(root);
const [pinned, setPinned] = useState(false); // touch: a tap toggles the spread
const [active, setActive] = useState<number | null>(null);
const pointer = useRef("mouse");
const tap = (i: number) => {
if (pointer.current === "mouse") return;
if (!pinned) { setPinned(true); setActive(i); }
else if (active === i) { setPinned(false); setActive(null); }
else setActive(i);
};
const onBlur = (e: FocusEvent<HTMLUListElement>) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setActive(null);
};
const items = [...PEOPLE.map((p) => ({ ...p, label: `${p.name}, ${p.role}` })), { initials: `+${MORE.length}`, name: MORE.join(", "), role: "", tone: "more", label: `${MORE.length} more: ${MORE.join(", ")}` }];
return (
<div ref={root} className={styles.root}>
<section className={styles.card} aria-label="Q4 launch plan">
<div className={styles.doc} aria-hidden="true">
<svg viewBox="0 0 16 16"><path d="M4 1.8h5.5L12.5 5v9.2H4zM9.5 1.8V5h3M6 8h4.5M6 10.5h4.5" /></svg>
</div>
<div className={styles.head}>
<p className={styles.title}>Q4 launch plan</p>
<p className={styles.sub}><span className={styles.live} aria-hidden="true" />10 people editing</p>
</div>
<ul
className={styles.stack}
data-open={pinned || undefined}
data-still={!inView || undefined}
onPointerDown={(e) => { pointer.current = e.pointerType; }}
onBlur={onBlur}
>
{items.map((p, i) => (
<li key={p.initials} style={{ "--i": i } as React.CSSProperties}>
<button
type="button"
className={styles.avatar}
data-tone={p.tone}
data-active={active === i || undefined}
data-edge={i === 0 ? "start" : i === items.length - 1 ? "end" : undefined}
aria-label={p.label}
onClick={() => tap(i)}
onFocus={(e) => e.currentTarget.matches(":focus-visible") && setActive(i)}
>
<span className={styles.face} aria-hidden="true">{p.initials}</span>
<span className={styles.tip} aria-hidden="true">
<b>{p.name}</b>
{p.role && <span>{p.role}</span>}
</span>
</button>
</li>
))}
</ul>
</section>
</div>
);
}
demo.module.css
.root {
container-type: size; display: grid; place-items: center; width: 100%; height: 100%; padding: 16px;
box-sizing: border-box; background: var(--surface);
}
.card {
width: min(380px, 100%); display: grid; grid-template-columns: auto 1fr; align-items: center; column-gap: 12px; padding: 18px 20px 20px;
box-sizing: border-box; border-radius: 16px; border: 1px solid var(--line); background: var(--white);
box-shadow: 0 14px 30px -22px color-mix(in srgb, var(--ink) 50%, transparent);
}
.doc { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 10px; background: var(--accent-pale); color: var(--accent-text); }
.doc svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
.head { min-width: 0; display: grid; gap: 2px; }
.head p { margin: 0; }
.title { font-size: 14px; font-weight: 650; }
.sub { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
.live { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 3px var(--accent-pale); }
/* The stack: negative margins overlap the avatars; spreading animates the margin so the row stays centred. */
.stack { --size: 40px; --overlap: -8px; --gap: 8px; grid-column: 1 / -1; display: flex; margin: 0; padding: 18px 0 0; list-style: none; }
.stack li { position: relative; z-index: calc(10 - var(--i)); transition: margin .45s var(--out); }
.stack li + li { margin-left: var(--overlap); }
@media (hover: hover) {
.stack:hover li + li { margin-left: var(--gap); }
.stack:hover .face { animation: none; }
}
.stack:has(:focus-visible) li + li, .stack[data-open] li + li { margin-left: var(--gap); }
.stack:has(:focus-visible) .face, .stack[data-open] .face { animation: none; }
.avatar { position: relative; display: block; width: var(--size); height: var(--size); padding: 0; border: 0; border-radius: 50%; background: none; font: inherit; cursor: pointer; }
.avatar:focus-visible { outline: none; }
.face {
display: grid; place-items: center; width: 100%; height: 100%; border-radius: 50%; box-sizing: border-box;
background: var(--accent-soft); color: var(--ink); font-size: 12px; font-weight: 700; letter-spacing: .01em;
box-shadow: 0 0 0 3px var(--white);
animation: bump 3.4s var(--out) calc(var(--i) * 110ms + .4s) infinite;
transition: transform .25s var(--out), box-shadow .25s;
}
.avatar[data-tone="teal"] .face { background: var(--accent-text); color: var(--white); }
.avatar[data-tone="sky"] .face { background: var(--sky); }
.avatar[data-tone="sand"] .face { background: var(--sand); }
.avatar[data-tone="ink"] .face { background: var(--ink); color: var(--white); }
.avatar[data-tone="pale"] .face { background: var(--accent-pale); color: var(--accent-text); }
.avatar[data-tone="more"] .face { background: var(--surface); color: var(--muted); font-size: 12px; box-shadow: 0 0 0 3px var(--white), 0 0 0 4px var(--line); }
.stack[data-still] .face { animation-play-state: paused; }
@keyframes bump {
0%, 12%, 100% { transform: translateY(0); }
5% { transform: translateY(-8px); }
}
@media (hover: hover) {
.avatar:hover .face { transform: translateY(-3px); }
}
.avatar:focus-visible .face { box-shadow: 0 0 0 3px var(--white), 0 0 0 5px var(--accent-text); }
/* Name tooltip above the avatar; the first and last anchor to their edge so they never clip. */
.tip {
position: absolute; left: 50%; bottom: calc(100% + 10px); display: grid; gap: 1px; width: max-content; max-width: 160px; padding: 6px 10px;
border-radius: 9px; background: var(--ink); color: var(--white); font-size: 12px; line-height: 1.3; text-align: left;
opacity: 0; translate: -50% 4px; pointer-events: none; transition: opacity .2s, translate .25s var(--out);
}
.tip b { font-weight: 650; }
.tip span { font-size: 11px; opacity: .7; }
.avatar[data-edge="start"] .tip { left: -4px; translate: 0 4px; }
.avatar[data-edge="end"] .tip { left: auto; right: -4px; translate: 0 4px; }
@media (hover: hover) {
.stack:hover .avatar:hover .tip { opacity: 1; translate: -50% 0; }
.stack:hover .avatar[data-edge]:hover .tip { translate: 0 0; }
}
.avatar[data-active] .tip { opacity: 1; translate: -50% 0; }
.avatar[data-active][data-edge] .tip { translate: 0 0; }
@container (max-width: 380px) {
.stack { --size: 36px; --overlap: -7px; --gap: 5px; }
.card { padding: 14px 14px 16px; }
.face { font-size: 12px; }
}
@container (max-height: 240px) {
.stack { padding-top: 14px; }
.card { padding-top: 12px; padding-bottom: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.face { animation: none; transition: none; }
.stack li, .tip { transition: none; }
}
.stack li:hover, .stack li:has([data-active]), .stack li:has(:focus-visible) { z-index: 20; }
Use it for
- Presence on shared documents, boards or calls
- Social proof rows on collaboration product pages
- Team or attendee lists that should stay compact until asked
Avoid
- Long member lists, where a full list or search is clearer
- Busy dashboards, where a looping wave pulls the eye
How it works
Each avatar's face runs the same bump keyframes with an animation delay of its index times 110ms, so the row ripples like a wave with no JavaScript timers. Spreading animates the negative left margin to a small gap, which keeps the row centred; the wave stops while spread and pauses off screen. Tooltips anchor to the left or right edge on the first and last avatar so they never clip.
Accessibility
Every avatar, including the more chip, is a button named after the person, so Tab spreads the stack and walks it with a visible ring and tooltip. Touch users tap to spread and tap avatars to read names. Reduced motion drops the wave and transitions; spreading still works.