Components
Animated avatar stack: a hover fan-out in CSS and React
Overlapping avatars that fan out and lift on hover with a staggered delay, plus a +3 counter. Live demo, CSS-only avatars, React code and timing values.
An avatar stack is the row of overlapping profile circles you see on shared documents, team pages and pricing cards: four or five faces, then a "+3" for everyone else. Static, it does its job. With a small hover animation, where the circles spread apart and the one under the cursor lifts up, it becomes something people want to touch, and it also solves a real problem: overlapped faces are hard to tell apart.
This guide builds that hover fan-out with CSS custom properties and one transition rule, a few lines of JavaScript for touch, and a React version. The avatars are pure CSS (gradients and initials), so there are no images to load.
How the animation is put together
Every avatar runs the same transition. The only thing that differs between them is an index, --i, which the CSS uses for both the distance each one travels and how long it waits before moving.
| Part | Property | Duration | Delay | Easing |
|---|---|---|---|---|
| Fan-out (open) | transform: translateX() | 320 ms | --i × 35 ms (0 to 175 ms) | cubic-bezier(.34, 1.56, .64, 1) |
| Fan-in (close) | transform: translateX() | 320 ms | reversed, (5 − --i) × 25 ms | same curve |
| Lift on hover | translateY(-8px) scale(1.1) | 320 ms | 0 ms | same curve |
| Name tooltip | opacity, translateY(4px → 0) | 150 ms | 0 ms | ease-out |
The avatars overlap by 14 px in the resting state. On hover each one moves by (i − 2.5) × 18px, so the left half moves left and the right half moves right. The group spreads symmetrically around its center instead of growing off to one side, and the gap between neighbours goes from −14 px to +4 px.
The HTML
<div class="avs" role="group" aria-label="Project members">
<button class="avs-item" type="button" style="--i:0; --c1:#fb9f3d; --c2:#f0603d"
data-name="Ana Reyes" aria-label="Ana Reyes">AR</button>
<button class="avs-item" type="button" style="--i:1; --c1:#7c8cff; --c2:#4b4fd6"
data-name="Ben Okafor" aria-label="Ben Okafor">BO</button>
<button class="avs-item" type="button" style="--i:2; --c1:#3ddc97; --c2:#1a9e6a"
data-name="Chen Wei" aria-label="Chen Wei">CW</button>
<button class="avs-item" type="button" style="--i:3; --c1:#ff7eb6; --c2:#c2407f"
data-name="Dana Kim" aria-label="Dana Kim">DK</button>
<button class="avs-item" type="button" style="--i:4; --c1:#4cc9f0; --c2:#2a7fb8"
data-name="Eli Novak" aria-label="Eli Novak">EN</button>
<span class="avs-item avs-more" style="--i:5" aria-label="and 3 more">+3</span>
</div>
Each avatar is a button because in a real product it usually opens a profile. That also makes it focusable, which is what lets keyboard users trigger the fan-out. To use photos, put an img inside the button with width: 100%, border-radius: 50% and object-fit: cover; the animation does not change.
The CSS
.avs { --size: 48px; --overlap: 14px; --spread: 18px; --n: 6; display: flex; }
.avs-item {
--fan: 0px; --lift: 0px; --s: 1;
position: relative; width: var(--size); height: var(--size);
margin-left: calc(-1 * var(--overlap));
border: 3px solid #0b0b0d; border-radius: 50%;
display: grid; place-items: center;
font: 600 14px Inter, sans-serif; color: #fff;
background: radial-gradient(circle at 30% 25%, var(--c1), var(--c2));
transform: translateX(var(--fan)) translateY(var(--lift)) scale(var(--s));
/* closing: the last avatar moves first */
transition: transform .32s cubic-bezier(.34, 1.56, .64, 1)
calc((var(--n) - 1 - var(--i)) * 25ms);
}
.avs-item:first-child { margin-left: 0; }
.avs-more { background: #23252d; color: #d4d4d8; }
/* opening: fan out from the first avatar */
.avs:hover .avs-item,
.avs:focus-within .avs-item,
.avs.is-open .avs-item {
--fan: calc((var(--i) - (var(--n) - 1) / 2) * var(--spread));
transition-delay: calc(var(--i) * 35ms);
}
/* the one under the pointer lifts, with no delay */
.avs .avs-item:not(.avs-more):hover,
.avs .avs-item:focus-visible {
--lift: -8px; --s: 1.1; z-index: 2; transition-delay: 0ms;
}
.avs-item[data-name]::after {
content: attr(data-name);
position: absolute; bottom: calc(100% + 8px); left: 50%;
padding: 4px 8px; border-radius: 6px; background: #f4f4f5; color: #111;
font: 500 12px Inter, sans-serif; white-space: nowrap; pointer-events: none;
opacity: 0; transform: translate(-50%, 4px);
transition: opacity .15s ease-out, transform .15s ease-out;
}
.avs-item[data-name]:hover::after,
.avs-item[data-name]:focus-visible::after { opacity: 1; transform: translate(-50%, 0); }
@media (prefers-reduced-motion: reduce) {
.avs-item, .avs-item::after { transition: none; }
.avs .avs-item:hover, .avs .avs-item:focus-visible { --lift: 0px; --s: 1; }
}
Two details carry most of the effect. First, the transform is built from three custom properties, so the fan-out (--fan) and the hover lift (--lift, --s) can change independently without one rule overwriting the other's transform. Second, the delay is defined twice: the resting rule counts down from the last avatar and the hover rule counts up from the first. A transition uses the delay of the state it is moving to, so the stack opens left to right and closes right to left, which reads as one motion folding back on itself.
The JavaScript
const root = document.querySelector('.avs');
root.addEventListener('pointerdown', e => {
if (e.pointerType !== 'mouse') root.classList.toggle('is-open');
});
document.addEventListener('pointerdown', e => {
if (!root.contains(e.target)) root.classList.remove('is-open');
});
Mouse and keyboard need no script at all; :hover and :focus-within handle them. The script only exists because a tap on a phone does not produce a lasting hover state. Checking pointerType keeps a mouse click from toggling the class on top of the hover.
The React version
import { useEffect, useRef, useState } from 'react';
export function AvatarStack({ people, extra = 0 }) {
const ref = useRef(null);
const [open, setOpen] = useState(false);
const n = people.length + (extra ? 1 : 0);
useEffect(() => {
const close = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('pointerdown', close);
return () => document.removeEventListener('pointerdown', close);
}, []);
return (
<div ref={ref} className={'avs' + (open ? ' is-open' : '')} role="group"
aria-label="Project members" style={{ '--n': n }}
onPointerDown={e => { if (e.pointerType !== 'mouse') setOpen(o => !o); }}>
{people.map((p, i) => (
<button key={p.name} className="avs-item" type="button" data-name={p.name}
aria-label={p.name} style={{ '--i': i, '--c1': p.c1, '--c2': p.c2 }}>
{p.name.split(' ').map(w => w[0]).join('')}
</button>
))}
{extra > 0 && (
<span className="avs-item avs-more" style={{ '--i': people.length }}
aria-label={`and ${extra} more`}>+{extra}</span>
)}
</div>
);
}
It reuses the CSS above unchanged. React passes the index through the style prop, and --n is set from the data so the fan stays centered for any group size. If your project already uses Framer Motion, the same pattern maps to a parent with staggerChildren: 0.035 and children with an x variant; the Framer Motion stagger guide covers that setup.
Timing that feels right
- Stagger: 30 to 40 ms per avatar. At 35 ms, six avatars finish their delays in 175 ms, so the whole fan-out is done in about half a second. Above 60 ms per item the last avatar starts moving after the pointer has already left.
- Close faster than you open. 25 ms per item on the way back. Opening is a reveal people watch; closing is cleanup.
- Duration: 280 to 350 ms with a small overshoot.
cubic-bezier(.34, 1.56, .64, 1)pushes each avatar a few pixels past its target and settles it, which makes the group feel springy. For a calmer brand, usecubic-bezier(.2, .8, .2, 1), a plain ease-out with no overshoot. - Lift: no delay, 6 to 10 px. The hovered avatar has to respond instantly, so it drops its stagger delay. More than about 10 px and it covers the tooltip.
- Tooltip: 150 ms ease-out. Text should never bounce.
Not sure what an overshoot curve is doing? The CSS easing functions guide plots each of these curves.
Accessibility notes
- Each avatar has an
aria-labelwith the full name, since initials alone mean nothing to a screen reader. - The group has
role="group"and a label, so the avatars are announced as a set. :focus-withinopens the stack when any avatar gets keyboard focus, and:focus-visibleshows the tooltip and an outline on the focused one.- The "+3" is a plain
spanwith anaria-label. If it opens a full member list in your product, make it a button instead. - Under
prefers-reduced-motion: reducethe avatars still spread, but instantly, and the lift is removed. The information (who is in the group) stays the same.
Copy one you already like
Avatar groups on design tools and team dashboards each have their own spacing, delay and spring. If you have seen one that feels right, you can measure it instead of guessing: open the page, start the Capture Motion extension and hover the stack. It records the real hover state, with the transform, duration, delay and easing of every avatar, and exports HTML and CSS, React, GSAP, JSON, or a Figma Motion timeline. You can also start from the ready-made components in the Capture Motion gallery, customize them and export the code.
Questions
How do I stagger the avatars without writing a rule per item?
Give each avatar an index in a custom property, such as style="--i: 2", and set transition-delay: calc(var(--i) * 35ms). One rule covers any number of avatars.
Should the avatars move with margin or transform?
Use transform. Changing margin forces layout on every frame and pushes the content next to the stack around. A translateX keeps the layout fixed and runs on the compositor.
How does the fan-out work on touch screens?
There is no hover on touch, so a tap on the stack toggles an open class and a tap outside closes it. Keyboard users get the same effect through :focus-within.
Can I copy an avatar group animation from another site?
Yes. The Capture Motion Chrome extension records the real hover state, so it picks up each avatar's transform, duration, delay and easing, and exports them as CSS, React or JSON.
