GodUIGodUI
129Follow on X

Anatomy of the Presence Facepile

How a live avatar stack overlaps with ordinary flex flow, springs people in and out with popLayout, and turns status into color instead of a label.

01/04Overlap is flex flow, not absolute position
Avatar
flex row, "-space-x-2": DOM order, no z-index
Status dot
ring-2 ring-background, bottom-right corner
+N chip
same row, same -space-x-2, opens on click
tsx
<div className="flex items-center -space-x-2">{visible.map((user) => (  <motion.div key={user.id} layout /* ... */>    <Avatar user={user} sizeClass={sizeClass} showStatus={showStatus} />  </motion.div>))}{overflow.length > 0 ? (  <motion.button layout /* +N chip, same row */ />) : null}</div>
01

Overlap is flex flow, not absolute position

A facepile has to answer "who's here, and what are they doing" in the width of a few avatars. Presence Facepile gets there with plain flex overlap, one shared spring for every avatar entering or leaving, and status encoded as color and motion rather than text.

-space-x-2 is Tailwind's negative-margin utility applied to every child but the first. The row is still ordinary flex flow, so tab order and DOM order match what's on screen, and later avatars paint over earlier ones because they come later in the DOM, with no explicit z-index. The +N chip has no special positioning: it's the same flex row's last child, sharing the identical overlap and ring-2 ring-background treatment that separates every avatar from its neighbor.

02/04One spring for entering, leaving, and shifting

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

Leave
popLayout removes it immediately, opacity/scale down
Reflow
siblings shift on the same spring with a transform, not a resize
Join
enters from opacity 0, scale 0.5, x -8
tsx
<AnimatePresence initial={false} mode="popLayout">{visible.map((user) => (  <motion.div    key={user.id}    layout    initial={{ opacity: 0, scale: 0.5, x: -8 }}    animate={{ opacity: 1, scale: 1, x: 0 }}    exit={{ opacity: 0, scale: 0.5 }}    transition={{ type: "spring", stiffness: 520, damping: 32 }}  >    <Avatar user={user} sizeClass={sizeClass} showStatus={showStatus} />  </motion.div>))}</AnimatePresence>
02

One spring for entering, leaving, and shifting

mode="popLayout" makes a departure feel instant: the leaving avatar is pulled out of layout the moment it starts exiting, so its siblings begin sliding into the gap immediately rather than waiting for the exit animation to finish. Every avatar (the one leaving, the ones reflowing to close its gap, and a new one joining at the end) resolves on the same { stiffness: 520, damping: 32 } spring, so everything in the row moves at the same speed.

tsx
 
className="transition-transform hover:z-raised hover:-translate-y-0.5"

The hover lift sits outside that spring system. It's a plain CSS transition on hover:-translate-y-0.5, paired with hover:z-raised so the lifted avatar clears its neighbors' overlap instead of disappearing behind them.

03/04Status is color, not a label
Active
oklch(0.72 0.16 145)
Idle
oklch(0.75 0.15 75)
Typing
3 dots, 120ms stagger
Offline
oklch(0.6 0 0)
tsx
{status === "typing" ? (<span className="absolute -bottom-0.5 -right-0.5 flex items-center gap-px rounded-full bg-background px-0.5 py-1 shadow-sm">  {[0, 1, 2].map((i) => (    <span      key={i}      className="size-1 rounded-full bg-primary motion-safe:animate-bounce"      style={{ animationDelay: `${i * 120}ms` }}    />  ))}</span>) : (<span  className="absolute bottom-0 right-0 size-2.5 rounded-full ring-2 ring-background"  style={{ backgroundColor: STATUS_COLOR[status] }}/>)}
03

Status is color, not a label

Every avatar can carry a status ring, resolved from one lookup table:

tsx
 
const STATUS_COLOR: Record<PresenceStatus, string> = {
  active: "oklch(0.72 0.16 145)",
  idle: "oklch(0.75 0.15 75)",
  typing: "var(--primary)",
  offline: "oklch(0.6 0 0)",
};

typing is the one status without a static ring. It swaps in three 1px dots instead, each bouncing on motion-safe:animate-bounce with a 120ms stagger per dot.

motion-safe: pauses the bounce for anyone who's asked for reduced motion. The dots still render (their presence and position still say "typing"); they stop moving. Every other status is a ring in a fixed color that never fades or pulses, because "idle" or "offline" doesn't need motion drawing your eye to it every few seconds.

Beyond max, the rest collapse into a +N button that shares the row's layout animation (so it slides as the row's contents change) but carries none of Avatar's status logic. Clicking it opens a spring-in popover listing every collapsed user with their name and status spelled out in text, since a ring around a +N chip can't represent seven different statuses at once.

tsx
 
<motion.div
  initial={{ opacity: 0, y: 6, scale: 0.96 }}
  animate={{ opacity: 1, y: 0, scale: 1 }}
  exit={{ opacity: 0, y: 6, scale: 0.96 }}
  transition={{ type: "spring", stiffness: 320, damping: 32, mass: 0.9 }}
/>
04/04Result
AR
MB
PN
JK
SD
04

The result

Flex overlap builds the stack. One spring is shared by every avatar that enters, leaves, or shifts to fill a gap. Status is a color lookup, except typing, which gets its own motion because it's happening right now.

Accessibility

Every avatar carries a title combining the user's name and status ("Ana Reyes · active"), so a mouse user gets the same information a screen reader gets from the overflow popover's plain-text status column. The +N chip is a native <button> with aria-expanded reflecting the popover's open state, and useReducedMotion() swaps every avatar's layout and enter/exit springs to static positioning. The facepile still reflows correctly, without animating the transition.

Motion Score

Presence FacepileCC: Paint-triggering
StranslatePosition / lift via translate
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 →