Avatar Stack

Next

A component that stacks and overlaps a list of avatars.

Docs
shadcraftui

Preview

Loading preview…
components/ui/avatar-stack.tsx
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import * as React from "react";

interface AvatarStackProps extends React.ComponentProps<"div"> {
  orientation?: "horizontal" | "vertical";
  max?: number;
  size?: number;
  overlapRatio?: number;
  gapRatio?: number;
  mask?: boolean;
}

export function AvatarStack({
  children,
  className,
  orientation = "horizontal",
  max, // Max visible avatars before "+N"
  size = 32, // Avatar size in pixels
  overlapRatio = 0.2, // 20% of avatar size
  gapRatio = 0.15, // 15% of avatar size
  mask = true,
  ...props
}: AvatarStackProps) {
  const isHorizontal = orientation === "horizontal";

  const avatarItems = React.Children.toArray(children).filter(
    (child) => React.isValidElement(child) && child.type === Avatar
  );

  const avatarsCount = avatarItems.length;
  const showOverflow = max && avatarsCount > max;
  const overflowCount = showOverflow ? avatarsCount - max : 0;
  const visibleAvatars = showOverflow ? avatarItems.slice(0, max) : avatarItems;

  return (
    <div
      data-slot="avatar-stack"
      data-orientation={orientation}
      data-size={size}
      data-overlap-ratio={overlapRatio}
      data-gap-ratio={gapRatio}
      data-mask={mask}
      data-max={max}
      className={cn(
        "group flex items-center",
        isHorizontal ? "flex-row" : "flex-col",
        isHorizontal ? cn(`-space-x-[var(--overlap)]`) : cn(`-space-y-[var(--overlap)]`),
        className
      )}
      style={
        {
          "--overlap": `${overlapRatio * size}px`,
        } as React.CSSProperties
      }
      {...props}
    >
      {visibleAvatars.map((child, index) => {
        return (
          <AvatarWrapper
            key={index}
            index={index}
            size={size}
            orientation={orientation}
            overlapRatio={overlapRatio}
            gapRatio={gapRatio}
            mask={mask}
          >
            {child}
          </AvatarWrapper>
        );
      })}

      {showOverflow && (
        <AvatarWrapper
          index={visibleAvatars.length}
          size={size}
          orientation={orientation}
          overlapRatio={overlapRatio}
          gapRatio={gapRatio}
          mask={mask}
        >
          <Avatar>
            <AvatarFallback className="text-muted-foreground text-xs font-medium">
              +{overflowCount}
            </AvatarFallback>
          </Avatar>
        </AvatarWrapper>
      )}
    </div>
  );
}

interface AvatarWrapperProps extends React.ComponentProps<"div"> {
  size: number;
  index: number;
  orientation: "horizontal" | "vertical";
  overlapRatio: number;
  gapRatio: number;
  mask: boolean;
}

function AvatarWrapper({
  children,
  size,
  index,
  orientation,
  overlapRatio,
  gapRatio,
  mask,
}: AvatarWrapperProps) {
  const isHorizontal = orientation === "horizontal";
  const isFirst = index === 0;

  // Only mask if there's actual overlap and not the first avatar
  const shouldMask = mask && overlapRatio > 0 && !isFirst;
  // The physical overlap distance in pixels
  const overlapPx = overlapRatio * size;

  // This creates a consistent visible gap regardless of overlap amount
  const gapWidth = size * gapRatio;

  // Geometric center of previous avatar
  const geometricCenter = overlapPx - size / 2;
  // Push mask inward by half the gap width to create the visible separation
  const maskOffset = Math.round(geometricCenter + gapWidth / 2);

  const maskImage = isHorizontal
    ? `radial-gradient(circle ${size / 2}px at ${maskOffset}px 50%, transparent 99%, black 100%)`
    : `radial-gradient(circle ${size / 2}px at 50% ${maskOffset}px, transparent 99%, black 100%)`;

  const style: React.CSSProperties = {
    width: size,
    height: size,
    ...(shouldMask && {
      maskImage,
      WebkitMaskImage: maskImage,
      maskSize: "cover",
      WebkitMaskSize: "cover",
      maskRepeat: "no-repeat",
      WebkitMaskRepeat: "no-repeat",
    }),
  };

  return (
    <div
      data-slot="avatar-wrapper"
      className={cn(
        "relative flex shrink-0 overflow-hidden rounded-full",
        "[&_[data-slot=avatar]]:size-full",
        "transform-gpu antialiased"
      )}
      style={style}
    >
      {children}
    </div>
  );
}

Installation

npx shadcn@latest add @shadcraft/avatar-stack

Usage

import { AvatarStack } from "@/components/ui/avatar-stack"
<AvatarStack />