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";

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

interface AvatarGroupProps
  extends Omit<React.ComponentProps<"div">, "dir">,
    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;
  renderOverflow?: (count: number) => React.ReactNode;
}

function AvatarGroup(props: AvatarGroupProps) {
  const {
    orientation = "horizontal",
    dir = "ltr",
    size = 39,
    max,
    asChild,
    reverse = false,
    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 RootPrimitive = asChild ? Slot : "div";

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

interface AvatarGroupItemProps
  extends Omit<React.ComponentProps<typeof Slot>, "dir">,
    VariantProps<typeof avatarGroupVariants> {
  child: React.ReactNode;
  index: number;
  itemCount: number;
  size: number;
  reverse: boolean;
}

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

  const maskStyle = React.useMemo<React.CSSProperties>(() => {
    let maskImage = "";
    let shouldMask = false;

    if (orientation === "vertical" && dir === "rtl" && reverse) {
      shouldMask = index !== itemCount - 1;
    } else {
      shouldMask = reverse ? index < itemCount - 1 : index > 0;
    }

    if (shouldMask) {
      const maskRadius = size / 2;
      const maskOffset = size / 4 + size / 10;

      if (orientation === "vertical") {
        if (dir === "ltr") {
          maskImage = 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%)`;
        } else {
          maskImage = 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%)`;
        }
      } else if (dir === "ltr") {
        maskImage = 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%)`;
      } else {
        maskImage = 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%)`;
      }
    }

    return {
      width: size,
      height: size,
      maskImage,
    };
  }, [size, index, orientation, dir, reverse, itemCount]);

  return (
    <Slot
      data-slot="avatar-group-item"
      className={cn(
        "size-full shrink-0 overflow-hidden rounded-full [&_img]:size-full",
        className,
      )}
      style={{
        ...maskStyle,
        ...style,
      }}
      {...itemProps}
    >
      {child}
    </Slot>
  );
}

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 dir="rtl">
      {people.slice(0, 4).map((person) => (
        <PersonAvatar key={person.fallback} person={person} />
      ))}
    </AvatarGroup>
    <AvatarGroup orientation="vertical" dir="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="inline-flex size-full items-center justify-center rounded-full bg-accent-9 text-xs font-medium text-accent-contrast">
        +{count}
      </div>
    )}
  >
    {people.map((person) => (
      <PersonAvatar key={person.fallback} person={person} />
    ))}
  </AvatarGroup>
)

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