Skip to content
Uxelith
Sign in
Freev1.0.0Data

Avatar Stack

Overlapping avatars that spread on hover, with a name tooltip that swings with the pointer, status dots and a +N list of everyone else. Photos or initials.

pnpm dlx shadcn add …/r/avatar-stack.json
Licence
MIT
Packages
3
Updated
9 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/avatar-stack.json
  2. It adds these packages for you:

    clsxmotiontailwind-merge
  3. The source lands in your components folder as avatar-stack.tsx. It's yours: change anything.

Usage

example.tsx
import { AvatarStack } from "@/components/avatar-stack"

export function Team() {
  return (
    <AvatarStack
      label="People on this project"
      people={[{ name: "Aarav Mehta", src: "/aarav.jpg" }, { name: "Lena Fischer" }]}
    />
  )
}

Source

One file, 276 lines, nothing hidden. Read it before you install it.

avatar-stack.tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react";

import { cn } from "@/lib/utils";

export interface AvatarPerson {
  name: string;
  /** Photo URL. Without one (or if it fails to load), initials are shown on a colour picked from the name. */
  src?: string;
  /** A second line in the tooltip, e.g. "Design lead". */
  role?: string;
  /** Makes the avatar a link, e.g. to a profile. */
  href?: string;
  /** A small dot on the avatar. */
  status?: "online" | "away" | "busy";
}

export interface AvatarStackProps extends React.ComponentProps<"ul"> {
  people: AvatarPerson[];
  /** Show at most this many; "+N" lists the rest. */
  max?: number;
  /** Diameter in pixels. */
  size?: number;
  /** Accessible name of the group, e.g. "People on this project". */
  label?: string;
  /** Show a name tooltip that swings with the pointer. */
  tooltips?: boolean;
  /**
   * Renders photos. Defaults to a plain `<img>`; pass `Image` from
   * `next/image` (or your framework's) to get its optimisation.
   */
  imageComponent?: React.ElementType;
}

// Motion presets (ADR-010).
const SPRING_SNAPPY = { type: "spring", visualDuration: 0.2, bounce: 0 } as const;
const SPRING_BOUNCY = { type: "spring", visualDuration: 0.45, bounce: 0.35 } as const;
const FOLLOW = { mass: 0.1, stiffness: 150, damping: 12 };
const INSTANT = { duration: 0 } as const;

const statusColor = { online: "bg-emerald-500", away: "bg-amber-500", busy: "bg-red-500" };

function hue(name: string) {
  let h = 0;
  for (const ch of name) h = (h * 31 + ch.codePointAt(0)!) % 360;
  return h;
}

function initials(name: string) {
  const parts = name.trim().split(/\s+/);
  return ((parts[0]?.[0] ?? "") + (parts.length > 1 ? (parts.at(-1)?.[0] ?? "") : "")).toUpperCase();
}

const describe = (person: AvatarPerson) => [person.name, person.role, person.status].filter(Boolean).join(", ");

function Face({ person, size, Img }: { person: AvatarPerson; size: number; Img: React.ElementType }) {
  const [failed, setFailed] = React.useState(false);
  const h = hue(person.name);
  return person.src && !failed ? (
    <Img
      src={person.src}
      alt={describe(person)}
      width={size}
      height={size}
      loading="lazy"
      onError={() => setFailed(true)}
      className="size-full rounded-full object-cover"
    />
  ) : (
    <span
      role="img"
      aria-label={describe(person)}
      className="flex size-full items-center justify-center rounded-full font-medium"
      style={{
        background: `oklch(0.9 0.06 ${h})`,
        color: `oklch(0.35 0.09 ${h})`,
        fontSize: size * 0.38,
      }}
    >
      <span aria-hidden="true">{initials(person.name)}</span>
    </span>
  );
}

function Status({ status, size }: { status?: AvatarPerson["status"]; size: number }) {
  if (!status) return null;
  const dot = Math.max(8, Math.round(size * 0.26));
  return <span aria-hidden="true" className={cn("absolute bottom-0 right-0 rounded-full ring-2 ring-background", statusColor[status])} style={{ width: dot, height: dot }} />;
}

/**
 * Overlapping avatars that spread when the pointer arrives. The person under
 * it lifts, and a name tooltip pops above them, swinging with the pointer.
 * "+N" opens a list of everyone else.
 *
 * - A list, with every person named (photo `alt` or initials' label,
 *   including their role and status); tooltips repeat that and are hidden
 *   from assistive technology.
 * - People with an `href` are links, and get their tooltip on focus too.
 * - "+N" is a button with `aria-expanded`; Escape or clicking away closes
 *   its list and Escape returns focus.
 * - Broken photos fall back to initials on a colour derived from the name.
 * - Under `prefers-reduced-motion` nothing swings or springs.
 */
export function AvatarStack({
  people,
  max = 5,
  size = 36,
  label = "People",
  tooltips = true,
  imageComponent = "img",
  className,
  style,
  ...props
}: AvatarStackProps) {
  const id = React.useId();
  const reduce = useReducedMotion();
  const shown = people.slice(0, max);
  const rest = people.slice(max);
  const [active, setActive] = React.useState<number | null>(null);
  const [open, setOpen] = React.useState(false);
  const moreRef = React.useRef<HTMLLIElement>(null);
  const buttonRef = React.useRef<HTMLButtonElement>(null);

  // The tooltip leans toward the pointer: offset within the avatar → a few degrees.
  const pointer = useMotionValue(0);
  const rotate = useSpring(useTransform(pointer, [-size / 2, size / 2], [-14, 14]), FOLLOW);
  const shift = useSpring(useTransform(pointer, [-size / 2, size / 2], [-size * 0.2, size * 0.2]), FOLLOW);

  React.useEffect(() => {
    if (!open) return;
    const doc = moreRef.current?.ownerDocument;
    const onKey = (event: KeyboardEvent) => {
      if (event.key !== "Escape") return;
      setOpen(false);
      buttonRef.current?.focus();
    };
    const onPointer = (event: PointerEvent) => {
      if (!moreRef.current?.contains(event.target as Node)) setOpen(false);
    };
    doc?.addEventListener("keydown", onKey);
    doc?.addEventListener("pointerdown", onPointer);
    return () => {
      doc?.removeEventListener("keydown", onKey);
      doc?.removeEventListener("pointerdown", onPointer);
    };
  }, [open]);

  const ring = "rounded-full ring-2 ring-background";
  const overlap = "-ml-[calc(var(--avatar-size)*0.3)] group-hover/stack:-ml-[calc(var(--avatar-size)*0.12)]";

  return (
    <ul
      aria-label={label}
      className={cn("group/stack flex items-center", className)}
      style={{ "--avatar-size": `${size}px`, ...style } as React.CSSProperties}
      onPointerLeave={() => setActive(null)}
      {...props}
    >
      {shown.map((person, i) => {
        const face = (
          <>
            <Face person={person} size={size} Img={imageComponent} />
            <Status status={person.status} size={size} />
          </>
        );
        return (
          <li
            key={`${person.name}-${i}`}
            onPointerEnter={() => setActive(i)}
            onPointerMove={(event) => {
              if (reduce) return;
              const box = event.currentTarget.getBoundingClientRect();
              pointer.set(event.clientX - box.left - box.width / 2);
            }}
            onFocus={() => setActive(i)}
            onBlur={() => setActive(null)}
            className={cn(
              "relative size-[var(--avatar-size)] shrink-0 transition-[margin,translate,scale] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
              "animate-[ux-avatar-in_400ms_cubic-bezier(0.34,1.56,0.64,1)_both] motion-reduce:animate-none",
              i > 0 && overlap,
              active === i && "z-10 -translate-y-1 scale-110 motion-reduce:translate-y-0 motion-reduce:scale-100",
              ring,
            )}
            style={{ animationDelay: `${i * 50}ms` }}
          >
            {person.href ? (
              <a href={person.href} className="relative block size-full rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
                {face}
              </a>
            ) : (
              face
            )}
            <AnimatePresence>
              {tooltips && active === i && (
                <motion.span
                  aria-hidden="true"
                  initial={{ opacity: 0, y: 6, scale: 0.85 }}
                  animate={{ opacity: 1, y: 0, scale: 1 }}
                  exit={{ opacity: 0, y: 4, scale: 0.9 }}
                  transition={reduce ? INSTANT : SPRING_BOUNCY}
                  style={reduce ? undefined : { rotate, x: shift }}
                  className="pointer-events-none absolute bottom-[calc(100%+10px)] left-1/2 z-20 -ml-24 flex w-48 justify-center"
                >
                  <span className="whitespace-nowrap rounded-lg bg-foreground px-2.5 py-1.5 text-center text-xs text-background shadow-lg">
                    <span className="block font-medium">{person.name}</span>
                    {person.role && <span className="block opacity-70">{person.role}</span>}
                  </span>
                </motion.span>
              )}
            </AnimatePresence>
          </li>
        );
      })}
      {rest.length > 0 && (
        <li ref={moreRef} title={rest.map((p) => p.name).join(", ")} onPointerEnter={() => setActive(null)} className={cn("relative size-[var(--avatar-size)] shrink-0 transition-[margin] duration-300 motion-reduce:transition-none", overlap)}>
          <button
            ref={buttonRef}
            type="button"
            aria-expanded={open}
            aria-controls={`${id}-more`}
            onClick={() => setOpen((o) => !o)}
            className={cn(
              "flex size-full items-center justify-center bg-muted font-medium text-muted-foreground outline-none transition-colors duration-200 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
              ring,
            )}
            style={{ fontSize: size * 0.34 }}
          >
            <span aria-hidden="true">+{rest.length}</span>
            <span className="sr-only">and {rest.length} more</span>
          </button>
          <AnimatePresence>
            {open && (
              <motion.ul
                id={`${id}-more`}
                aria-label={`${rest.length} more`}
                initial={{ opacity: 0, y: -4, scale: 0.96 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={{ opacity: 0, y: -4, scale: 0.96 }}
                transition={reduce ? INSTANT : SPRING_SNAPPY}
                className="absolute left-1/2 top-[calc(100%+8px)] z-30 -ml-28 grid max-h-64 w-56 origin-top gap-0.5 overflow-y-auto rounded-xl border border-border bg-popover p-1.5 text-popover-foreground shadow-[0_20px_50px_-20px_rgb(0_0_0/0.45)]"
              >
                {rest.map((person, i) => (
                  <li key={`${person.name}-${i}`} className="flex items-center gap-2.5 rounded-lg px-2 py-1.5">
                    <span className="relative size-7 shrink-0">
                      <Face person={person} size={28} Img={imageComponent} />
                      <Status status={person.status} size={28} />
                    </span>
                    <span className="min-w-0 text-sm">
                      {person.href ? (
                        <a href={person.href} className="block truncate rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring">
                          {person.name}
                        </a>
                      ) : (
                        <span aria-hidden="true" className="block truncate font-medium">
                          {person.name}
                        </span>
                      )}
                      {person.role && (
                        <span aria-hidden="true" className="block truncate text-xs text-muted-foreground">
                          {person.role}
                        </span>
                      )}
                    </span>
                  </li>
                ))}
              </motion.ul>
            )}
          </AnimatePresence>
        </li>
      )}
    </ul>
  );
}

Props

PropTypeDefaultDescription
people{ name; src?; role?; href?; status? }[]—Each person; role shows in the tooltip, href makes a link, status adds a dot.
maxnumber5Show at most this many; the rest become +N.
sizenumber36Diameter in pixels.
labelstring"People"Accessible name of the list.
imageComponentElementType"img"Renders photos. Pass Image from next/image to get its optimisation.
tooltipsbooleantrueName tooltip that swings with the pointer.

Accessibility

  • A labelled list; every person is named by their photo alt or initials label, including role and status.
  • Tooltips repeat the name and are hidden from assistive technology; people with an href are links and show it on focus.
  • +N is a button with aria-expanded; Escape or clicking away closes its list, and Escape returns focus.
  • Under prefers-reduced-motion nothing swings or springs.

Changelog

  1. v1.0.0Latest

    First release.

More in Data

All 9 in Data
  • DataPro

    Area Chart

    Trends over time: lines draw themselves in, the crosshair glides between points, the area you've scrubbed stays bright, and the tooltip shows the change from the point before.

  • DataPro

    Bar Chart

    A responsive bar chart, grouped or stacked: bars rise in one after another, the hovered column lights up and the tooltip glides, with keyboard navigation and a hidden data table.

  • DataPro

    Data Table

    A real, accessible table with natural sorting where rows glide into place, highlighted search, pagination, drawn checkboxes and a bulk-action bar. Typed columns, custom cells, two densities.