Aura Design System

Avatar Group

Overlapping avatars with truncation, orientation, and custom overflow.

Preview

Loading...
import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "@/components/ui/Avatar";
import { AvatarGroup } from "@/components/ui/AvatarGroup";

export function AvatarGroupDemo() {
  return (
  <AvatarGroup>
    {people.slice(0, 3).map((person) => (
      <PersonAvatar key={person.fallback} person={person} />
    ))}
  </AvatarGroup>
)

Installation

Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces

pnpm dlx shadcn@latest add @aura/avatar-group

Manual

Install the following dependencies:

pnpm install @radix-ui/react-slot class-variance-authority

Copy and paste the class names utility into your utils/class-names.ts file.

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Copy and paste the AvatarGroup component into your components/ui/AvatarGroup.tsx file.

"use client";

/**
 * @description Overlapping avatars with truncation, orientation, and custom overflow.
 */
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";

import { cn } from "@/utils/class-names";

type AvatarGroupOrientation = "horizontal" | "vertical";
type AvatarGroupDirection = "ltr" | "rtl";
type AvatarGroupShape = "round" | "square";

const avatarGroupVariants = cva("flex items-center", {
  variants: {
    orientation: {
      horizontal: "flex-row",
      vertical: "flex-col",
    },
    direction: {
      ltr: "",
      rtl: "",
    },
  },
  compoundVariants: [
    {
      orientation: "horizontal",
      direction: "ltr",
      className: "-space-x-1",
    },
    {
      orientation: "horizontal",
      direction: "rtl",
      className: "flex-row-reverse -space-x-1 space-x-reverse",
    },
    {
      orientation: "vertical",
      direction: "ltr",
      className: "-space-y-1",
    },
    {
      orientation: "vertical",
      direction: "rtl",
      className: "flex-col-reverse -space-y-1 space-y-reverse",
    },
  ],
  defaultVariants: {
    orientation: "horizontal",
    direction: "ltr",
  },
});

/** Non-circle radii. `rounded-full` is the round avatar, not a square shape. */
const SQUARE_RADIUS =
  /(?:^|\s)rounded(?:-(?:none|sm|md|lg|xl|2xl|3xl))?(?=\s|$)/;

function stripCircle(className: string | undefined): string {
  return (className ?? "")
    .replace(/\brounded-full\b/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}

function resolvesToSquare(
  shape: AvatarGroupShape,
  childClassName?: string,
): boolean {
  if (shape === "square") return true;
  return SQUARE_RADIUS.test(stripCircle(childClassName));
}

function radiusClass(
  isSquare: boolean,
  childClassName?: string,
): string | undefined {
  if (!isSquare) return "rounded-full";
  if (SQUARE_RADIUS.test(stripCircle(childClassName))) return undefined;
  return "rounded-none";
}

function cutoutMaskImage(options: {
  isSquare: boolean;
  orientation: AvatarGroupOrientation;
  direction: AvatarGroupDirection;
  reverse: boolean;
  size: number;
  shouldMask: boolean;
}): string {
  if (!options.shouldMask) return "";

  const { isSquare, orientation, direction, reverse, size } = options;
  const maskRadius = size / 2;
  const maskOffset = size / 4 + size / 10;
  const cutsStart = reverse ? direction !== "ltr" : direction === "ltr";

  if (isSquare) {
    // Same midline depth as the circular bite, plus 2px, so squares overlap
    // instead of sitting edge to edge. A straight edge follows `rounded-none`
    // and the flat side of a `rounded-md` stack; outer corners stay on the avatar.
    const cut = `${Number((Math.max(maskRadius - maskOffset, 0) + 2).toFixed(2))}px`;
    if (orientation === "vertical") {
      return cutsStart
        ? `linear-gradient(to bottom, transparent ${cut}, white ${cut})`
        : `linear-gradient(to top, transparent ${cut}, white ${cut})`;
    }
    return cutsStart
      ? `linear-gradient(to right, transparent ${cut}, white ${cut})`
      : `linear-gradient(to left, transparent ${cut}, white ${cut})`;
  }

  if (orientation === "vertical") {
    if (direction === "ltr") {
      return reverse
        ? `radial-gradient(circle ${maskRadius}px at 50% ${size + maskOffset}px, transparent 99%, white 100%)`
        : `radial-gradient(circle ${maskRadius}px at 50% -${maskOffset}px, transparent 99%, white 100%)`;
    }
    return reverse
      ? `radial-gradient(circle ${maskRadius}px at 50% -${maskOffset}px, transparent 99%, white 100%)`
      : `radial-gradient(circle ${maskRadius}px at 50% ${size + maskOffset}px, transparent 99%, white 100%)`;
  }

  if (direction === "ltr") {
    return reverse
      ? `radial-gradient(circle ${maskRadius}px at ${size + maskOffset}px 50%, transparent 99%, white 100%)`
      : `radial-gradient(circle ${maskRadius}px at -${maskOffset}px 50%, transparent 99%, white 100%)`;
  }

  return reverse
    ? `radial-gradient(circle ${maskRadius}px at -${maskOffset}px 50%, transparent 99%, white 100%)`
    : `radial-gradient(circle ${maskRadius}px at ${size + maskOffset}px 50%, transparent 99%, white 100%)`;
}

interface AvatarGroupProps
  extends React.ComponentProps<"div">,
    VariantProps<typeof avatarGroupVariants> {
  /** Pixel size applied to each item (default matches Aura `size-3` = 39px). */
  size?: number;
  /** Max visible slots including the overflow indicator when truncating. */
  max?: number;
  asChild?: boolean;
  reverse?: boolean;
  /**
   * Cutout shape. `round` (default) uses a circular mask. `square` uses a
   * straight mask and drops `rounded-full`, so `rounded-none` and `rounded-md`
   * avatars keep a gap. Square radii on a child are detected either way.
   */
  shape?: AvatarGroupShape;
  renderOverflow?: (count: number) => React.ReactNode;
}

function AvatarGroup(props: AvatarGroupProps) {
  const {
    orientation = "horizontal",
    direction = "ltr",
    size = 39,
    max,
    asChild,
    reverse = false,
    shape = "round",
    renderOverflow,
    className,
    children,
    ...rootProps
  } = props;

  const childrenArray = React.Children.toArray(children).filter(
    React.isValidElement,
  );
  const itemCount = childrenArray.length;
  const shouldTruncate = Boolean(max && itemCount > max);
  const visibleItems = shouldTruncate
    ? childrenArray.slice(0, (max as number) - 1)
    : childrenArray;
  const overflowCount = shouldTruncate ? itemCount - ((max as number) - 1) : 0;
  const totalRenderedItems = shouldTruncate ? (max as number) : itemCount;
  const overflowIsSquare = shape === "square";

  const RootPrimitive = asChild ? Slot : "div";

  return (
    <RootPrimitive
      data-orientation={orientation}
      data-direction={direction}
      data-shape={shape}
      data-slot="avatar-group"
      {...rootProps}
      className={cn(
        avatarGroupVariants({ orientation, direction }),
        className,
      )}
    >
      {visibleItems.map((child, index) => (
        <AvatarGroupItem
          key={index}
          child={child}
          index={index}
          itemCount={totalRenderedItems}
          orientation={orientation}
          direction={direction}
          size={size}
          reverse={reverse}
          shape={shape}
        />
      ))}
      {shouldTruncate ? (
        <AvatarGroupItem
          key="overflow"
          child={
            renderOverflow ? (
              renderOverflow(overflowCount)
            ) : (
              <div
                className={cn(
                  "small inline-flex size-full items-center justify-center bg-gray-3 text-gray-11",
                  overflowIsSquare ? "rounded-none" : "rounded-full",
                )}
              >
                +{overflowCount}
              </div>
            )
          }
          index={visibleItems.length}
          itemCount={totalRenderedItems}
          orientation={orientation}
          direction={direction}
          size={size}
          reverse={reverse}
          shape={shape}
        />
      ) : null}
    </RootPrimitive>
  );
}

interface AvatarGroupItemProps {
  child: React.ReactNode;
  index: number;
  itemCount: number;
  size: number;
  reverse: boolean;
  orientation: AvatarGroupOrientation;
  direction: AvatarGroupDirection;
  shape: AvatarGroupShape;
  className?: string;
  style?: React.CSSProperties;
}

function AvatarGroupItem(props: AvatarGroupItemProps) {
  const {
    child,
    index,
    size,
    orientation,
    direction = "ltr",
    reverse = false,
    shape = "round",
    itemCount,
    className,
    style,
  } = props;

  if (!React.isValidElement<{ className?: string; style?: React.CSSProperties; "data-slot"?: string }>(child)) {
    return child;
  }

  const isSquare = resolvesToSquare(shape, child.props.className);
  const shouldMask =
    orientation === "vertical" && direction === "rtl" && reverse
      ? index !== itemCount - 1
      : reverse
        ? index < itemCount - 1
        : index > 0;

  const maskImage = cutoutMaskImage({
    isSquare,
    orientation,
    direction,
    reverse,
    size,
    shouldMask,
  });

  const maskStyle: React.CSSProperties = {
    width: size,
    height: size,
    maskImage,
    WebkitMaskImage: maskImage,
  };

  return React.cloneElement(child, {
    ...(child.props["data-slot"]
      ? {}
      : { "data-slot": "avatar-group-item" }),
    className: cn(
      "size-full shrink-0 overflow-hidden [&_img]:size-full",
      radiusClass(isSquare, child.props.className),
      isSquare ? stripCircle(child.props.className) : child.props.className,
      className,
    ),
    style: {
      ...maskStyle,
      ...style,
      ...child.props.style,
    },
  });
}

export { AvatarGroup, avatarGroupVariants };
export type { AvatarGroupProps };

Usage

WithTruncation

export const WithTruncation = () => (
  <AvatarGroup max={4}>
    {people.map((person) => (
      <PersonAvatar key={person.fallback} person={person} />
    ))}
  </AvatarGroup>
)

Vertical

export const Vertical = () => (
  <AvatarGroup orientation="vertical">
    {people.slice(0, 3).map((person) => (
      <PersonAvatar key={person.fallback} person={person} />
    ))}
  </AvatarGroup>
)

Rtl

export const Rtl = () => (
  <div className="flex flex-col gap-4">
    <AvatarGroup direction="rtl">
      {people.slice(0, 4).map((person) => (
        <PersonAvatar key={person.fallback} person={person} />
      ))}
    </AvatarGroup>
    <AvatarGroup orientation="vertical" direction="rtl">
      {people.slice(0, 3).map((person) => (
        <PersonAvatar key={person.fallback} person={person} />
      ))}
    </AvatarGroup>
  </div>
)

CustomOverflow

export const CustomOverflow = () => (
  <AvatarGroup
    max={3}
    renderOverflow={(count) => (
      <div className="small inline-flex size-full items-center justify-center rounded-full bg-accent-9 text-accent-contrast">
        +{count}
      </div>
    )}
  >
    {people.map((person) => (
      <PersonAvatar key={person.fallback} person={person} />
    ))}
  </AvatarGroup>
)

Reverse

export const Reverse = () => (
  <div className="flex flex-col gap-4">
    <AvatarGroup reverse>
      {people.slice(0, 4).map((person) => (
        <PersonAvatar key={person.fallback} person={person} />
      ))}
    </AvatarGroup>
    <AvatarGroup orientation="vertical" reverse>
      {people.slice(0, 3).map((person) => (
        <PersonAvatar key={person.fallback} person={person} />
      ))}
    </AvatarGroup>
  </div>
)

Square

The circular mask only follows round avatars. shape="square" uses a straight cutout, so rounded-none and rounded-md avatars keep a gap. A child with one of those radii is detected even when shape stays round.

export const Square = () => (
  <div className="flex flex-col gap-4">
    <AvatarGroup shape="square" max={4}>
      {people.map((person) => (
        <PersonAvatar
          key={person.fallback}
          person={person}
          className="rounded-none"
        />
      ))}
    </AvatarGroup>
    <AvatarGroup shape="square">
      {people.slice(0, 3).map((person) => (
        <PersonAvatar
          key={person.fallback}
          person={person}
          className="rounded-md"
        />
      ))}
    </AvatarGroup>
  </div>
)

WithIcons

export const WithIcons = () => (
  <AvatarGroup size={39}>
    <Avatar>
      <AvatarFallback className="bg-accent-3 text-accent-11">A</AvatarFallback>
    </Avatar>
    <Avatar>
      <AvatarFallback className="bg-gray-4 text-gray-12">B</AvatarFallback>
    </Avatar>
    <Avatar>
      <AvatarFallback className="bg-info text-info-contrast">C</AvatarFallback>
    </Avatar>
  </AvatarGroup>
)