Component
Avatar group
Overlapping avatars that collapse extra people into a count.
- #arc
- #component
- #profile
- #user
- #team
- #stack
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/avatar-group.jsonFiles
components/arc/avatar-group/avatar-group.module.csscomponents/arc/avatar-group/avatar-group.tsx
npm packages
motion@^14.2.0
Registry items it pulls in
Source
View as Markdownavatar-group.module.cssavatar-group.module.cssavatar-group.module.csscss
.group { --avatar-size: 36px; display: inline-flex; height: var(--avatar-size); align-items: center; padding-left: 6px; }
.group.sm { --avatar-size: 28px; }
.group.lg { --avatar-size: 48px; }
/* Each slot is the avatar's visible width, so a slot that opens or closes moves the rest of the stack smoothly. */
.slot { position: relative; display: inline-flex; flex: 0 0 auto; height: var(--avatar-size); align-items: center; }
/* One wrapper per person carries every hover move as a single translate, so the stack never changes size. */
.lift { --fan: 0px; --aside: 0px; --rise: 0px; position: relative; display: inline-flex; margin-left: -6px; border-radius: var(--radius-pill); transform: translate(calc(var(--fan) + var(--aside)), var(--rise)); transition: transform var(--duration-standard) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }
.avatar, .overflow { border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--border); transition: box-shadow var(--duration-fast) var(--ease-standard); }
.overflow { position: relative; display: inline-grid; place-items: center; overflow: hidden; border-radius: var(--radius-pill); background: var(--surface-muted); color: var(--text-secondary); font-size: var(--text-xs); font-weight: 500; font-variant-numeric: tabular-nums; }
.count { display: inline-block; }
.overflow.sm { width: 28px; height: 28px; font-size: 10px; }
.overflow.md { width: 36px; height: 36px; }
.overflow.lg { width: 48px; height: 48px; font-size: var(--text-sm); }
/* The name floats above the stack, outside the layout, so showing it never moves anything. */
.tip { position: absolute; z-index: 2; bottom: calc(100% + 8px); left: 50%; padding: 4px 8px; border-radius: var(--radius-pill); background: var(--foreground); color: var(--background); font-size: var(--text-xs); font-weight: 500; line-height: var(--leading-body); white-space: nowrap; pointer-events: none; opacity: 0; transform: translate(-50%, 3px); transition: opacity var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard); }
/* Pointing at the stack loosens its overlap from the center; the person under the pointer lifts with a crisp ring and names themselves, and their neighbors ease aside. */
@media (hover: hover) and (pointer: fine) {
.group:hover .lift { --fan: calc((var(--index) - (var(--count) - 1) / 2) * 4px); }
.slot:hover { z-index: 1; }
.slot:hover .lift { --rise: -2px; }
.slot:hover ~ .slot .lift { --aside: 3px; }
.slot:has(~ .slot:hover) .lift { --aside: -3px; }
.slot:hover .avatar, .slot:hover .overflow { box-shadow: 0 0 0 1px var(--border-strong); }
.slot:hover .tip { opacity: 1; transform: translate(-50%, 0); transition-delay: 60ms; }
}
/* Reduced motion keeps the stack still; the ring and name still answer the pointer. */
@media (prefers-reduced-motion: reduce) { .lift { transform: none !important; transition: none; } .tip { transform: translate(-50%, 0); transition: none; } }
avatar-group.tsxavatar-group.tsxavatar-group.tsxtsx
"use client";
import { useState, type CSSProperties } from "react";
import { AnimatePresence, motion, type Variants } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import { Avatar } from "../avatar/avatar";
import styles from "./avatar-group.module.css";
export interface AvatarGroupMember { name: string; src?: string; status?: "online" | "offline"; }
export interface AvatarGroupProps { members: AvatarGroupMember[]; max?: number; size?: "sm" | "md" | "lg"; label?: string; }
/** The overflow count rolls the way it moved: more people rise in from below, fewer drop in from above. */
const rise: Variants = { hidden: (direction: number) => ({ opacity: 0, y: `${.4 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)` }), shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }, gone: (direction: number) => ({ opacity: 0, y: `${-.4 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }) };
// Same keys as `rise` so the settled style is identical whichever branch renders on the server.
const fade: Variants = { hidden: { opacity: 0, y: 0, filter: "blur(0px)" }, shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } } };
// A person joining or leaving opens or closes their slot, so the rest of the stack slides instead of jumping.
const slot = { initial: { width: 0, opacity: 0, scale: .9 }, animate: { width: "auto", opacity: 1, scale: 1 }, exit: { width: 0, opacity: 0, scale: .9 } };
export function AvatarGroup({ members, max = 4, size = "md", label = "팀원" }: AvatarGroupProps) {
const reduceMotion = !!useReducedMotion();
const visible = members.slice(0, Math.max(0, max));
const overflow = Math.max(0, members.length - visible.length);
const transition = reduceMotion ? { duration: 0 } : motionTokens.spring.morph;
const [count, setCount] = useState({ overflow, direction: 1 });
if (count.overflow !== overflow) setCount({ overflow, direction: overflow < count.overflow ? -1 : 1 });
return <div className={[styles.group, styles[size]].join(" ")} role="group" aria-label={label} style={{ "--count": visible.length + (overflow > 0 ? 1 : 0) } as CSSProperties}>
<AnimatePresence initial={false}>
{visible.map((member, index) => <motion.span key={member.name} className={styles.slot} style={{ "--index": index } as CSSProperties} {...slot} transition={transition}><span className={styles.lift}><Avatar className={styles.avatar} name={member.name} src={member.src} status={member.status} size={size} /><span className={styles.tip} aria-hidden="true">{member.name}</span></span></motion.span>)}
{overflow > 0 ? <motion.span key="overflow" className={styles.slot} style={{ "--index": visible.length } as CSSProperties} {...slot} transition={transition}><span className={[styles.lift, styles.overflow, styles[size]].join(" ")} role="img" aria-label={`${label} ${overflow}명 더 있어요`}><AnimatePresence mode="popLayout" initial={false} custom={count.direction}><motion.span key={overflow} className={styles.count} custom={count.direction} variants={reduceMotion ? fade : rise} initial="hidden" animate="shown" exit="gone" aria-hidden="true">+{overflow}</motion.span></AnimatePresence></span></motion.span> : null}
</AnimatePresence>
</div>;
}
export default AvatarGroup;
Used items
- AvatarComponent
A person's photo or initials, with an optional status dot.
Used by
- Page headerBlock
A page header with breadcrumbs and actions that condenses on scroll and folds overflow.