shadimage

Avatar Stack

Overlapping gradient-orb coins with a surface ring. Hovering lifts and names a coin, and the overflow count floats the hidden names without widening the stack.

components/ui/avatar-stack.tsx

"use client"

import { useState } from "react"
import { motion, useReducedMotion } from "motion/react"

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

/* Avatar Stack, rebuilt through the ssych-component skill (2026-09-29).
   What changed against the version before it (in git history), and why:
   · no rings: each coin is cut away where its neighbour overlaps it, so the gap between two
     coins is the page itself and no shadow is drawn
   · no floating box: the name being pointed at is plain text under the stack, as a status.
     It hangs from the stack, so the row still never widens
   · initial coins each take their own chart colour (2026-09-30), so people with no photo
     can still be told apart
   · pointing at a coin lifts it and dims the others
   · the "+N" count is tabular, and the stack names itself to a screen reader
   Props are the same.
   2026-10-02 audit: every coin and the "+N" count are real buttons with their names as
   labels, so the keyboard reaches them: focus lifts a coin and writes its name exactly as
   the pointer does, and a tap does the same on touch, where there is no hover. The names
   the count folds away are joined with commas.
   2026-10-02 round 2: his note was "weird overlapping icons". The cut-out masks are gone
   (each coin was cut by a circle sized for a 24px coin, and lifting one swapped its cut for a
   whole coin in one frame, which read as a jump). Now every coin is 28px with one 8px
   overlap, and each wears a 2px ring in the page's own colour, so the gap between two coins
   is drawn by the coin in front whatever the size. The "+N" count is a coin of the same
   size and ring, last in the row. Pointing lifts a coin 3px on a 200ms ease-out of its
   transform only (no spring, no size change), raises it above its neighbours and dims the
   rest. The people are on topic: the default is a trading desk with roles, and the line
   under the row reads "Priya Raman, head of rates". `names` is optional now (the desk), and
   `roles` is new (same order as names). */

const EASE = [0.16, 1, 0.3, 1] as const
/** the lift: transform only, 200ms ease-out (his rule 6) */
const LIFT = "transform 200ms cubic-bezier(0.16, 1, 0.3, 1), opacity 160ms cubic-bezier(0.16, 1, 0.3, 1)"
/** a coin's size and how far each one tucks under the one before it */
const SIZE = 28
const OVERLAP = 8

export interface AvatarStackProps {
  /** the people, in order; a trading desk by default */
  names?: string[]
  /** each person's role, same order as names, read after the name under the row */
  roles?: (string | undefined)[]
  /** Avatar image per name (same order); a missing entry falls back to initials. */
  images?: (string | undefined)[]
  /** Coins shown before folding the rest into a "+N" count. */
  max?: number
  className?: string
}

/** Initial coins: people are told apart, so each takes the next chart colour in the house
 * order, mixed over the page so a lifted coin stays solid. */
const COIN_INK = ["--chart-1", "--chart-5", "--chart-3", "--chart-amber", "--chart-2", "--chart-4"].map(
  (v) => `color-mix(in srgb, var(${v}) 26%, var(--background))`,
)

/** Bundled gradient profile orbs, the same set the Inbox uses. */
const DEFAULT_IMAGES = [1, 2, 3, 4, 5, 6].map((i) => `/lab-icons/avatars/abs-${i}.svg`)

const DESK = ["Priya Raman", "Marcus Hale", "Lena Vogt", "Tomás Ruiz", "Aiko Sato", "Dev Malhotra"]
const DESK_ROLES = ["head of rates", "crypto derivatives", "quant research", "execution", "risk", "model ops"]

/** the ring in the page's colour that parts a coin from the one it covers; keyboard focus
 *  brightens that same ring (the coin's own edge), never an outline with a gap */
const RING =
  "shadow-[0_0_0_2px_var(--background)] focus-visible:shadow-[0_0_0_2px_color-mix(in_srgb,var(--foreground)_45%,var(--background))]"

const initials = (n: string) =>
  n
    .split(" ")
    .map((p) => p[0])
    .slice(0, 2)
    .join("")
    .toUpperCase()

/**
 * Overlapping portrait coins. Pointing at one lifts it out of the stack and writes its name
 * under the row; pointing at the "+N" count writes the names it folds away.
 */
export function AvatarStack({ names = DESK, roles, images = DEFAULT_IMAGES, max = 4, className }: AvatarStackProps) {
  const reduced = useReducedMotion()
  const [hot, setHot] = useState<string | null>(null)
  const [peek, setPeek] = useState(false)
  /** the last thing said stays in place while it fades */
  const [said, setSaid] = useState("")

  const roleList = roles ?? (names === DESK ? DESK_ROLES : [])
  const shown = names.slice(0, max)
  const hidden = names.slice(max)
  const on = hot !== null || peek
  const line = (n: string) => {
    const role = roleList[names.indexOf(n)]
    return role ? `${n}, ${role}` : n
  }

  /* one coin's look: its size, its ring, its place in the overlap and its lift */
  const coinStyle = (i: number, lifted: boolean, dim: boolean, z: number) => ({
    width: SIZE,
    height: SIZE,
    marginLeft: i === 0 ? 0 : -OVERLAP,
    zIndex: lifted ? 50 : z,
    transform: lifted && !reduced ? "translateY(-3px)" : "translateY(0)",
    opacity: dim ? 0.5 : 1,
    transition: reduced ? "none" : LIFT,
  })

  return (
    <span
      className={cn("relative inline-flex items-center tabular-nums", className)}
      role="group"
      aria-label={`${names.length} people: ${names.map(line).join("; ")}`}
      onPointerLeave={() => {
        setPeek(false)
        setHot(null)
      }}
    >
      {/* the coins settle in once on mount, 35ms apart; lift and dim are on the coin itself */}
      <motion.span
        className="flex items-center"
        initial={reduced ? false : { opacity: 0, y: 4 }}
        animate={{ opacity: 1, y: 0 }}
        transition={reduced ? { duration: 0 } : { duration: 0.3, ease: EASE }}
      >
        {shown.map((n, i) => {
          const img = images[names.indexOf(n)]
          const lifted = hot === n
          return (
            <button
              key={n}
              type="button"
              aria-label={line(n)}
              className={cn("relative block shrink-0 rounded-full outline-none", RING)}
              style={coinStyle(i, lifted, (hot !== null && !lifted) || peek, shown.length + 1 - i)}
              onPointerEnter={(e) => {
                if (e.pointerType === "touch") return
                setHot(n)
                setSaid(line(n))
              }}
              onPointerLeave={(e) => e.pointerType !== "touch" && setHot(null)}
              onFocus={() => {
                setHot(n)
                setSaid(line(n))
              }}
              onBlur={() => setHot(null)}
              onClick={() => {
                /* a tap on touch, where there is no hover: the same lift and name, toggled */
                setPeek(false)
                setHot((h) => (h === n ? null : n))
                setSaid(line(n))
              }}
            >
              <span
                className="grid h-full w-full place-items-center overflow-hidden rounded-full text-[10px] font-medium text-foreground/90"
                style={{ background: img ? undefined : COIN_INK[i % COIN_INK.length] }}
              >
                {img ? <img src={img} alt="" className="h-full w-full object-cover" draggable={false} /> : initials(n)}
              </span>
            </button>
          )
        })}
        {hidden.length > 0 && (
          <button
            type="button"
            className={cn(
              "relative grid shrink-0 place-items-center rounded-full bg-[color-mix(in_srgb,var(--foreground)_12%,var(--background))] text-[10.5px] font-medium outline-none",
              RING,
              peek ? "text-foreground/90" : "text-foreground/70",
            )}
            style={{ ...coinStyle(shown.length, peek, hot !== null, 0), transition: reduced ? "none" : `${LIFT}, color 150ms` }}
            aria-label={`and ${hidden.length} more: ${hidden.map(line).join("; ")}`}
            onPointerEnter={(e) => {
              if (e.pointerType === "touch") return
              setPeek(true)
              setSaid(hidden.join(", "))
            }}
            onPointerLeave={(e) => e.pointerType !== "touch" && setPeek(false)}
            onFocus={() => {
              setPeek(true)
              setSaid(hidden.join(", "))
            }}
            onBlur={() => setPeek(false)}
            onClick={() => {
              setHot(null)
              setPeek((p) => !p)
              setSaid(hidden.join(", "))
            }}
          >
            +{hidden.length}
          </button>
        )}
      </motion.span>
      <span
        role="status"
        className="pointer-events-none absolute left-0 top-full mt-2 whitespace-nowrap text-[11px] text-foreground/90 transition-opacity duration-150 motion-reduce:transition-none"
        style={{ opacity: on ? 1 : 0 }}
      >
        {/* the name swaps in place: 4px and a 2px blur, 150ms */}
        <motion.span
          key={said}
          className="inline-block"
          initial={reduced ? { opacity: 0 } : { opacity: 0, y: 4, filter: "blur(2px)" }}
          animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
          transition={{ duration: 0.15, ease: EASE }}
        >
          {on ? said : ""}
        </motion.span>
      </span>
    </span>
  )
}
Visual style
gradient

DependenciesBundle weight · medium

animation
motion