avatar-group

PreviousNext
Docs
diceuiui

Preview

Loading preview…
ui/avatar-group.tsx
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";

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> {
  size?: number;
  max?: number;
  asChild?: boolean;
  reverse?: boolean;
  renderOverflow?: (count: number) => React.ReactNode;
}

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

  const childrenArray = React.Children.toArray(children).filter(
    React.isValidElement,
  );
  const itemCount = childrenArray.length;
  const shouldTruncate = max && itemCount > max;
  const visibleItems = shouldTruncate
    ? childrenArray.slice(0, max - 1)
    : childrenArray;
  const overflowCount = shouldTruncate ? itemCount - (max - 1) : 0;
  const totalRenderedItems = shouldTruncate ? max : 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-muted font-medium text-muted-foreground text-xs">
                +{overflowCount}
              </div>
            )
          }
          index={visibleItems.length}
          itemCount={totalRenderedItems}
          orientation={orientation}
          dir={dir}
          size={size}
          reverse={reverse}
        />
      )}
    </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") {
          if (reverse) {
            maskImage = `radial-gradient(circle ${maskRadius}px at 50% ${size + maskOffset}px, transparent 99%, white 100%)`;
          } else {
            maskImage = `radial-gradient(circle ${maskRadius}px at 50% -${maskOffset}px, transparent 99%, white 100%)`;
          }
        } else {
          if (reverse) {
            maskImage = `radial-gradient(circle ${maskRadius}px at 50% -${maskOffset}px, transparent 99%, white 100%)`;
          } else {
            maskImage = `radial-gradient(circle ${maskRadius}px at 50% ${size + maskOffset}px, transparent 99%, white 100%)`;
          }
        }
      } else {
        if (dir === "ltr") {
          if (reverse) {
            maskImage = `radial-gradient(circle ${maskRadius}px at ${size + maskOffset}px 50%, transparent 99%, white 100%)`;
          } else {
            maskImage = `radial-gradient(circle ${maskRadius}px at -${maskOffset}px 50%, transparent 99%, white 100%)`;
          }
        } else {
          if (reverse) {
            maskImage = `radial-gradient(circle ${maskRadius}px at -${maskOffset}px 50%, transparent 99%, white 100%)`;
          } else {
            maskImage = `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 };

Installation

npx shadcn@latest add @diceui/avatar-group

Usage

import { AvatarGroup } from "@/components/ui/avatar-group"
<AvatarGroup />