Skip to content

Overlapping faces with true cutouts that fan apart and open the full list.

Identity & display@base-ui/reactmotion@number-flow/react

01Preview

q3-forecast.xlsx

Finance · Edited just now

#482

Move billing webhooks to the queue

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install @base-ui/react motion @number-flow/react

03Usage

import { AvatarGroup } from "@/components/ui/avatar-group";

<AvatarGroup
  label="Viewing now"
  people={[
    { name: "Maya Okafor", src: "/people/maya.jpg", status: "online", meta: "Editing" },
    { name: "Jon Park", src: "/people/jon.jpg", meta: "Viewing" },
    { name: "Theo Nakamura", meta: "Idle 4m" },
  ]}
/>

04Source

"use client";
import NumberFlow from "@number-flow/react";
import { Popover } from "@base-ui/react/popover";
import { Tooltip } from "@base-ui/react/tooltip";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { Avatar, type Presence } from "@/components/ui/avatar";
import { cn } from "@/lib/cn";
import { dur, ease, spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";

export type AvatarGroupPerson = {
  /** Stable key. Falls back to the name. */
  id?: string;
  name: string;
  src?: string;
  status?: Presence;
  /** A short second line: a role, an email, "Editing". Shown in the tooltip and the list. */
  meta?: string;
};

type Size = "xs" | "sm" | "md" | "lg";
const px: Record<Size, number> = { xs: 20, sm: 24, md: 32, lg: 40 };

type Tip = { title: string; meta?: string };

// One registered custom property drives the hover spread. Registering it lets CSS interpolate it,
// so the faces and the cutouts that follow them move together, and reverse mid-way when the
// pointer leaves. React hoists and dedupes this, however many groups are on the page.
const spreadRule = "@property --avatar-spread{syntax:'<length>';inherits:true;initial-value:0px}";

export type AvatarGroupProps = Omit<React.ComponentProps<"div">, "children"> & {
  people: AvatarGroupPerson[];
  /** Most circles drawn, the +N chip included. One extra person is shown rather than a "+1". */
  max?: number;
  size?: Size;
  /** What the group is: "Viewing now", "Assignees". Titles the list and names the button. */
  label?: string;
  /** Draws placeholder faces while the people load. */
  loading?: boolean;
  /** Rendered when there is nobody. Nothing by default. */
  empty?: React.ReactNode;
  /** Where the list popover and tooltips portal. Defaults to document.body. */
  container?: HTMLElement | React.RefObject<HTMLElement | null> | null;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
};

/**
 * A stack of faces that opens the full list. The overlaps are real cutouts, so it sits on any
 * surface. On hover the stack eases apart and one tooltip glides from face to face.
 */
export function AvatarGroup({
  people,
  max = 4,
  size = "sm",
  label = "People",
  loading = false,
  empty = null,
  container,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  className,
  style,
  ...rest
}: AvatarGroupProps) {
  const reduce = useReducedMotion();
  const [tip] = useState(() => Tooltip.createHandle<Tip>());
  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });

  const s = px[size];
  const overlap = Math.round(s * 0.3);
  const gap = s >= 32 ? 2 : 1.5;
  const cap = Math.max(2, max);
  const fits = people.length <= cap;
  const shown = fits ? people : people.slice(0, cap - 1);
  const hidden = fits ? [] : people.slice(cap - 1);

  if (!loading && people.length === 0) return <>{empty}</>;

  // Each face after the first has a circle punched out where the one before it sits, plus a gap,
  // so the stack reads the same on a card, a hover wash or a photo. Opaque stops only lend alpha.
  const cut = `radial-gradient(circle at calc(${overlap - s / 2}px - var(--avatar-spread)) 50%, transparent ${s / 2 + gap}px, var(--fg) ${s / 2 + gap + 0.5}px)`;
  const count = shown.length + (hidden.length > 0 ? 1 : 0);
  // The spread fans out from the middle, so the stack grows evenly into both sides of its slot.
  const item = (i: number, n = count): React.CSSProperties => ({
    zIndex: 20 - i,
    marginLeft: i === 0 ? 0 : -overlap,
    translate: `calc(var(--avatar-spread) * ${i - (n - 1) / 2}) 0`,
    maskImage: i === 0 ? undefined : cut,
    WebkitMaskImage: i === 0 ? undefined : cut,
  });

  const names = people.map((p) => p.name);
  const spoken =
    names.length <= 3
      ? `${label}: ${new Intl.ListFormat("en", { type: "conjunction" }).format(names)}`
      : `${label}: ${names.slice(0, 2).join(", ")} and ${names.length - 2} others`;

  const enter = reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 };
  const transition = reduce ? { duration: 0.15 } : spring.pop;

  if (loading)
    return (
      <div aria-busy aria-label={`Loading ${label.toLowerCase()}`} role="img" className={cn("inline-flex", className)} style={style} {...rest}>
        {Array.from({ length: Math.min(3, cap) }, (_, i) => (
          <span key={i} className="relative flex" style={item(i, 3)}>
            <Avatar loading size={s} alt="" />
          </span>
        ))}
      </div>
    );

  return (
    <div className={cn("inline-flex", className)} style={style} {...rest}>
      <style href="stealth-avatar-spread" precedence="default">
        {spreadRule}
      </style>
      <Popover.Root open={open} onOpenChange={(next) => setOpen(next)}>
        <Popover.Trigger
          aria-label={spoken}
          className={cn(
            "group/stack relative isolate inline-flex items-center rounded-full outline-none",
            "transition-[--avatar-spread,scale] duration-200 ease-out-quart active:scale-[0.97] active:duration-75",
            "hover:[--avatar-spread:3px] focus-visible:[--avatar-spread:3px] data-popup-open:[--avatar-spread:3px]",
            "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
            // The stack draws at 20–40px tall; on touch the target grows to 44px without moving it.
            "before:absolute before:-inset-y-2.5 before:inset-x-0 before:content-[''] pointer-fine:before:hidden",
          )}
        >
          <AnimatePresence initial={false} mode="popLayout">
            {shown.map((p, i) => (
              <motion.span
                key={p.id ?? p.name}
                layout={!reduce}
                initial={enter}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ ...enter, transition: { duration: dur.menu * 0.7, ease: ease.out } }}
                transition={transition}
                className="relative flex"
                style={item(i)}
              >
                <Tooltip.Trigger handle={tip} payload={{ title: p.name, meta: p.meta }} delay={400} render={<span className="flex rounded-full" />}>
                  <Avatar name={p.name} src={p.src} size={s} alt="" />
                </Tooltip.Trigger>
              </motion.span>
            ))}
            {hidden.length > 0 && (
              <motion.span
                key="overflow"
                layout={!reduce}
                initial={enter}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ ...enter, transition: { duration: dur.menu * 0.7, ease: ease.out } }}
                transition={transition}
                className="relative flex"
                style={item(shown.length)}
              >
                <Tooltip.Trigger
                  handle={tip}
                  payload={{ title: hiddenNames(hidden) }}
                  delay={400}
                  render={
                    <span
                      className={cn(
                        "grid place-items-center rounded-full bg-fg/[0.08] font-medium text-fg-2 tabular",
                        "transition-colors duration-150 group-hover/stack:bg-fg/[0.12] group-hover/stack:text-fg group-data-popup-open/stack:bg-fg/[0.12] group-data-popup-open/stack:text-fg",
                      )}
                      style={{ width: s, height: s, fontSize: Math.max(9.5, Math.round(s * 0.34 * 2) / 2) }}
                    />
                  }
                >
                  <NumberFlow value={hidden.length} prefix="+" className="leading-none" />
                </Tooltip.Trigger>
              </motion.span>
            )}
          </AnimatePresence>
        </Popover.Trigger>

        <Popover.Portal container={container}>
          <Popover.Positioner side="bottom" align="end" sideOffset={8} collisionPadding={8} className="z-(--z-popover)">
            <Popover.Popup
              className={cn(
                "flex w-64 max-w-(--available-width) flex-col rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none",
                "origin-(--transform-origin) transition-[opacity,scale,translate,filter] duration-200 ease-out-expo",
                "data-starting-style:-translate-y-1 data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-starting-style:blur-[2px]",
                "data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-150 data-ending-style:ease-out-quart",
                "data-instant:transition-none",
              )}
            >
              <div className="flex h-9 shrink-0 items-center justify-between gap-3 border-b border-line px-3">
                <Popover.Title className="truncate text-[12.5px] font-medium text-fg">{label}</Popover.Title>
                <span className="font-mono text-2xs text-fg-3 tabular">{people.length}</span>
              </div>
              <ul
                aria-label={label}
                className="max-h-[228px] overflow-y-auto overscroll-contain p-1 [mask-image:linear-gradient(to_bottom,transparent,var(--fg)_6px,var(--fg)_calc(100%-6px),transparent)]"
              >
                {people.map((p) => (
                  <li key={p.id ?? p.name} className="flex h-10 items-center gap-2.5 rounded-lg px-2">
                    <Avatar name={p.name} src={p.src} size="sm" status={p.status} alt="" />
                    <span className="min-w-0 flex-1 truncate text-[13px] text-fg">{p.name}</span>
                    {p.meta && <span className="max-w-[45%] shrink-0 truncate text-[12px] text-fg-3">{p.meta}</span>}
                  </li>
                ))}
              </ul>
            </Popover.Popup>
          </Popover.Positioner>
        </Popover.Portal>
      </Popover.Root>

      {/* One tooltip for the whole stack. It glides between faces instead of closing and reopening. */}
      <Tooltip.Root handle={tip} disabled={open}>
        {({ payload }) => (
          <Tooltip.Portal container={container}>
            <Tooltip.Positioner
              side="top"
              sideOffset={8}
              collisionPadding={8}
              className="z-(--z-tooltip) h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-200 ease-out-quart data-instant:transition-none"
            >
              <Tooltip.Popup
                className={cn(
                  "h-(--popup-height,auto) w-(--popup-width,auto) overflow-clip rounded-lg border border-line-2 bg-raised text-[12px] leading-4 text-fg shadow-pop outline-none",
                  "origin-(--transform-origin) transition-[opacity,scale,width,height] [transition-duration:150ms,150ms,200ms,200ms] ease-out-expo",
                  "data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:[transition-duration:100ms]",
                  "data-instant:transition-none",
                )}
              >
                <Tooltip.Viewport
                  className={cn(
                    "relative h-full w-full",
                    "[&>*]:flex [&>*]:w-max [&>*]:max-w-[16rem] [&>*]:items-baseline [&>*]:gap-1.5 [&>*]:px-2 [&>*]:py-[5px]",
                    "[&>[data-previous]]:absolute [&>[data-previous]]:left-0 [&>[data-previous]]:top-0",
                    "[&>*]:transition-[opacity,filter] [&>*]:duration-150",
                    "[&>[data-current][data-starting-style]]:opacity-0 [&>[data-current][data-starting-style]]:blur-[2px]",
                    "[&>[data-previous][data-ending-style]]:opacity-0 [&>[data-previous]]:duration-100",
                  )}
                >
                  {payload && (
                    <>
                      <span className="truncate">{payload.title}</span>
                      {payload.meta && <span className="shrink-0 text-fg-3">{payload.meta}</span>}
                    </>
                  )}
                </Tooltip.Viewport>
              </Tooltip.Popup>
            </Tooltip.Positioner>
          </Tooltip.Portal>
        )}
      </Tooltip.Root>
    </div>
  );
}

function hiddenNames(hidden: AvatarGroupPerson[]) {
  const first = hidden.slice(0, 2).map((p) => p.name.split(/\s+/)[0]);
  const rest = hidden.length - first.length;
  return rest > 0 ? `${first.join(", ")} and ${rest} more` : first.join(" and ");
}

05Props

PropTypeDefaultDescription
people*{ id?, name, src?, status?, meta? }[]Everyone in the group, in order. The first face sits on top.
maxnumber4Most circles drawn, the +N chip included. One extra person is shown instead of a "+1".
size"xs" | "sm" | "md" | "lg""sm"20, 24, 32 or 40px faces. The overlap is 30% of the size.
labelstring"People"What the group is. Titles the list and starts the button's accessible name.
loadingbooleanfalseThree placeholder faces, overlapped the same way, while people load.
emptyReactNodenullRendered when the list is empty.
openbooleanControls the list popover.
defaultOpenbooleanfalseInitial state when uncontrolled.
onOpenChange(open: boolean) => voidCalled when the list opens or closes.
containerHTMLElement | RefObject<HTMLElement>Where the popover and tooltips portal. Defaults to document.body.

06Notes

Behavior

  • Each overlap is a hole punched through the face beneath, not a ring in the page color, so the stack looks right on cards, hover washes and photos.
  • The whole stack is one button that opens everyone, with each person's meta beside them, so touch and keyboard users get the names the tooltips give on hover.
  • The +N chip lists who it hides in its tooltip ("Theo, Priya and 2 more") and its count rolls as people come and go.
  • People joining slot in with the others sliding over; people leaving fold away and the gap closes. Nothing jumps.

Motion

  • On hover or focus the faces fan 3px apart from the middle over 200ms (ease-out-quart), and the cutouts travel with them, driven by one registered CSS property so it reverses cleanly.
  • One tooltip for the whole stack glides from face to face in 200ms instead of closing and reopening; the label cross-fades with a 2px blur.
  • Arrivals pop from 0.5 scale on the pop spring; departures fade in 125ms; the rest move on layout. Reduced motion keeps short fades only.

Accessibility

  • A native button named "Viewing now: Maya Okafor, Jon Park and 5 others" that opens a popover titled with the label.
  • Faces inside are decorative; the popover list carries each name, meta and presence.
  • Escape closes the list and returns focus to the stack. Tooltips are disabled while the list is open.