docs / core / avatar-group
AvatarGroup
avatars overlapped into one cluster, so a crowd reads at a glance. the caption carries the claim, the faces carry the proof.
intensity · recedesrsc · server
provenance
shaped from sisu-pluslayer
core / primitivesintensity
recedes · safe anywhere, including dense surfacescomposition
hero social proof, participant lists, shared ownership rowsthe tinted cluster pairs tone on the group with tone on each Avatarnever more faces than max can justify. five visible is the ceilingnot a picker. the stack does not clicka11y
each Avatar keeps its ownalt · the +N chip and caption are plain textjest-axelive demo · try it out
ALBPMCED+2
+128 studentscustomize
maxvisible faces before +N
sizeoverlap and +N chip size
tonecolours the +N chip and faces
labelthe caption after the stack
usage
import { Avatar, AvatarGroup } from "usva/primitives/avatar";
<AvatarGroup max={4} label="+128 students">
<Avatar alt="Ada" fallback="AL" />
<Avatar alt="Blaise" fallback="BP" />
<Avatar alt="Curie" fallback="MC" />
</AvatarGroup>props
| prop | type | default | notes |
|---|---|---|---|
| max | number | — | caps the visible avatars. the rest collapse into a +N chip. |
| size | "sm" | "md" | "lg" | "md" | overlap spacing and +N chip size. match the Avatars inside. |
| label | ReactNode | — | caption after the stack, e.g. "25+ active users". |
| tone | "solid" | "accent" | "neutral" | "neutral" | colors the +N chip. pair it with the Avatar tone. |
get it
npx shadcn add https://usva.build/r/avatar.jsonsource · components/ui/avatar.tsxexactly what this command copies
"use client";
import { Avatar as Base } from "@base-ui/react/avatar";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const rootVariants = cva(
"relative inline-flex shrink-0 select-none items-center justify-center rounded-full ring-1 ring-inset ring-border",
{
variants: {
size: {
sm: "h-8 w-8",
md: "h-10 w-10",
lg: "h-14 w-14",
},
},
defaultVariants: { size: "md" },
},
);
const fallbackVariants = cva(
"flex h-full w-full items-center justify-center font-semibold",
{
variants: {
size: {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
},
tone: {
solid: "bg-accent-2 text-on-accent",
accent: "bg-accent-tint font-bold text-on-tint",
neutral: "bg-surface-2 text-muted",
},
},
defaultVariants: { size: "md", tone: "solid" },
},
);
export type AvatarTone = "solid" | "accent" | "neutral";
const dotVariants = cva(
"absolute right-0 bottom-0 rounded-full ring-2 ring-surface",
{
variants: {
size: {
sm: "h-2 w-2",
md: "h-2.5 w-2.5",
lg: "h-3.5 w-3.5",
},
status: {
online: "bg-live animate-live-pulse",
away: "bg-warning",
busy: "bg-danger",
offline: "bg-border-strong",
},
},
defaultVariants: { size: "md", status: "offline" },
},
);
export type AvatarStatus = "online" | "away" | "busy" | "offline";
export interface AvatarProps
extends Omit<
React.ComponentPropsWithoutRef<typeof Base.Root>,
"render" | "className"
>,
VariantProps<typeof rootVariants> {
className?: string;
src?: string;
alt: string;
fallback?: string;
status?: AvatarStatus;
/** Fallback fill: solid accent (default), tinted accent, or neutral. */
tone?: AvatarTone;
}
export const Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(
({ className, src, alt, fallback, size, status, tone, ...props }, ref) => {
return (
<Base.Root
ref={ref}
className={cn(rootVariants({ size }), className)}
{...props}
>
<span className="flex h-full w-full items-center justify-center overflow-hidden rounded-full bg-surface-2">
{src ? (
<Base.Image
src={src}
alt={alt}
className="h-full w-full object-cover"
/>
) : null}
<Base.Fallback className={fallbackVariants({ size, tone })}>
{fallback}
</Base.Fallback>
</span>
{status ? (
<span aria-hidden="true" className={dotVariants({ size, status })} />
) : null}
</Base.Root>
);
},
);
Avatar.displayName = "Avatar";
source · components/ui/avatar-group.tsxexactly what this command copies
import * as React from "react";
import { cn } from "@/lib/utils";
export type AvatarGroupSize = "sm" | "md" | "lg";
export type AvatarGroupTone = "solid" | "accent" | "neutral";
export interface AvatarGroupProps extends React.HTMLAttributes<HTMLDivElement> {
/** Cap the visible avatars; the rest collapse into a "+N" chip. */
max?: number;
size?: AvatarGroupSize;
/** Caption after the stack, e.g. "25+ active users". */
label?: React.ReactNode;
/** Colors the overflow chip to match a tinted cluster. Defaults to neutral. */
tone?: AvatarGroupTone;
}
const overlap: Record<AvatarGroupSize, string> = {
sm: "-ml-1.5",
md: "-ml-2",
lg: "-ml-2.5",
};
const overflowChip: Record<AvatarGroupSize, string> = {
sm: "size-8 text-[0.7rem]",
md: "size-10 text-xs",
lg: "size-14 text-sm",
};
const chipTone: Record<AvatarGroupTone, string> = {
solid: "bg-accent-2 text-on-accent",
accent: "bg-accent-tint text-on-tint",
neutral: "bg-surface-2 text-muted",
};
export const AvatarGroup = React.forwardRef<HTMLDivElement, AvatarGroupProps>(
(
{
className,
max,
size = "md",
label,
tone = "neutral",
children,
...props
},
ref,
) => {
const all = React.Children.toArray(children);
const shown = max != null ? all.slice(0, max) : all;
const overflow = all.length - shown.length;
return (
<div ref={ref} className={cn("flex items-center", className)} {...props}>
<div className="flex items-center">
{shown.map((child, i) => (
<span
key={(child as React.ReactElement).key}
className={cn(
"rounded-full shadow-raised ring-2 ring-surface",
i > 0 && overlap[size],
)}
>
{child}
</span>
))}
{overflow > 0 && (
<span
className={cn(
"inline-flex items-center justify-center rounded-full font-bold tabular-nums shadow-raised ring-2 ring-surface",
overlap[size],
overflowChip[size],
chipTone[tone],
)}
>
+{overflow}
</span>
)}
</div>
{label != null && (
<span className="ml-3 text-sm font-medium text-muted">{label}</span>
)}
</div>
);
},
);
AvatarGroup.displayName = "AvatarGroup";