GodUIGodUI
129Follow on X

Anatomy of the Avatar Group

An overlapping stack built from ordinary flex flow, and two hover-triggered springs (one on the whole row, one on a single avatar) that layer on top of each other.

01/03Negative margin, not absolute position
Avatar
marginLeft: -12px (except the first), zIndex: i
+N chip
same row, same negative margin, one extra item
tsx
const overlap = { sm: 10, md: 12, lg: 14 };const margin = -overlap[size];<motion.divvariants={{ rest: { marginLeft: i === 0 ? 0 : margin, y: 0 } }}style={{ zIndex: i }}className="relative size-10 ring-2 ring-background rounded-full overflow-hidden bg-background"><Initials avatar={avatar} /></motion.div>
01

Negative margin, not absolute position

Overlapping avatar stacks are usually faked with position: absolute and manually offset left values. AvatarGroup doesn't reach for absolute positioning. It overlaps avatars with plain flex flow and negative margin, which lets the "fan out on hover" motion be a single marginLeft spring instead of a layout rewrite.

Every avatar past the first pulls itself left into its neighbor by a fixed overlap, and zIndex: i stacks later avatars on top.

Because it's real flow layout, the +N overflow chip is one more item in the same row with the same negative-margin treatment, instead of a specially-positioned badge bolted onto the end. ring-2 ring-background around each circle makes the overlap read as stacked coins instead of clipped rectangles. The ring is the same color as the page background, so it fakes a gap between avatars that have none in the DOM.

02/03Two springs, two independent triggers

{ type: "spring", stiffness: 520, damping: 32 }

Group spread
marginLeft -12 → 4, stiffness 520 · damping 32
Avatar lift
y: -6, scale: 1.06 on the hovered avatar only
tsx
<motion.divinitial="rest"whileHover={spreadOnHover && !reduceMotion ? "spread" : undefined}animate="rest">{visible.map((avatar, i) => (  <motion.div    variants={{      rest:   { marginLeft: i === 0 ? 0 : margin, y: 0 },      spread: { marginLeft: i === 0 ? 0 : 4, y: -2 },    }}    transition={{ type: "spring", stiffness: 520, damping: 32 }}    whileHover={reduceMotion ? undefined : { y: -6, scale: 1.06 }}  >    <Initials avatar={avatar} />  </motion.div>))}</motion.div>
02

Two springs, two independent triggers

Hovering anywhere over the outer motion.div flips every avatar's variants from rest to spread: marginLeft relaxes from a tight overlap to a small positive gap, on { stiffness: 520, damping: 32 }. Hovering one specific avatar fires a second, independent animation on that same element: whileHover={{ y: -6, scale: 1.06 }}, using Framer's default spring since no transition is passed for it. Nothing coordinates these two. Different pointer targets trigger them (the container vs. the individual avatar), and Framer resolves both onto the same node. Move the pointer across the row and you get the fan-out; stop over one avatar and it additionally pops up above its neighbors.

The +N chip shares the row's spread variant (hover the group and it slides right along with everything else), but it has no whileHover of its own, so it never gets the individual y/scale lift. Skipping that one animation marks it as a count of more people instead of a person.

03/03Result
Ada
Carl
Eve
Gus
+2
03

The result

Ordinary flex flow with negative margins for the overlap, one spring on the group's hover, and a second, independent spring on each avatar's own hover. Sweep the row, then rest on one avatar: both springs settle without touching each other's state.

Reduced motion removes the hover trigger

tsx
 
const reduceMotion = useReducedMotion();
whileHover={spreadOnHover && !reduceMotion ? "spread" : undefined}
whileHover={reduceMotion ? undefined : { y: -6, scale: 1.06 }}

Under reduced motion, both whileHover props become undefined outright instead of getting a zero-duration transition. Framer never registers a hover animation to skip; the avatars render their rest styles and stay there, regardless of pointer movement. Interactive behavior (the href link, focus-visible ring) is untouched. Only the two springs are removed.

Accessibility rides the same DOM

tsx
 
<a
  href={avatar.href}
  aria-label={avatar.alt}
  className="focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-full"
>
  {inner}
</a>

When an avatar has an href, the whole animated motion.div is wrapped in an anchor with its own aria-label and focus-visible ring, so you can tab through the group one avatar at a time. The spread and lift play on pointer hover only: whileHover doesn't fire on keyboard focus, so a focused avatar shows its ring without moving.

Motion Score

Avatar GroupDD: Layout-triggering
StransformCompositor transform (translate / scale / rotate)
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →