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-plus

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

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 click

a11y

each Avatar keeps its own alt · the +N chip and caption are plain textjest-axe
live demo · try it out
ALBPMCED+2
+128 students
customize
maxvisible faces before +N
4
sizeoverlap and +N chip size
tonecolours the +N chip and faces
labelthe caption after the stack
usagecopy
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

proptypedefaultnotes
maxnumbercaps 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.
labelReactNodecaption 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.jsoncopy
source · components/ui/avatar.tsxexactly what this command copiescopy
"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 copiescopy
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";