AppCrumble mascotAppCrumble
← Back to Crumbs
InterfacesReactTypeScriptTailwind

Honeycomb Avatars

Overlapping hexagon avatars staggered like honeycomb cells, with honey-toned initials for anyone who doesn't have a photo yet.

Live preview
PS
MR
Aisha Bello
JL
RW
+2

7 people, 5 shown - hover a cell

Code

honeycomb-avatars.tsx
"use client";

import * as React from "react";

export interface HoneycombAvatar {
  name: string;
  src?: string;
}

export interface HoneycombAvatarsProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  avatars: HoneycombAvatar[];
  max?: number;
  size?: number;
  ringClassName?: string;
}

const HONEY_SHADES = ["#f2b33d", "#eda828", "#e39a1c", "#d98b12"];
const HONEY_LABEL = "#3a2005";
const OVERFLOW_BG = "#6b4a1a";
const OVERFLOW_LABEL = "#fdf3e1";

const HEX_CLIP = "polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%)";

function hashString(value: string) {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (hash << 5) - hash + value.charCodeAt(i);
    hash |= 0;
  }
  return Math.abs(hash);
}

function initialsOf(name: string) {
  const [first = "", second = ""] = name.trim().split(/\s+/);
  return (first[0] ?? "") + (second[0] ?? "");
}

export const HoneycombAvatars = React.forwardRef<HTMLDivElement, HoneycombAvatarsProps>(
  (
    {
      avatars,
      max = 5,
      size = 46,
      ringClassName = "bg-white dark:bg-slate-950",
      className = "",
      ...props
    },
    ref,
  ) => {
    const visible = avatars.slice(0, max);
    const overflow = avatars.length - visible.length;
    const height = Math.round(size * 0.866);
    const step = Math.round(size * 0.74);
    const zigzag = Math.round(height * 0.22);

    const cells: { key: string; avatar: HoneycombAvatar | null; i: number }[] = visible.map(
      (avatar, i) => ({ key: `${avatar.name}-${i}`, avatar, i }),
    );
    if (overflow > 0) {
      cells.push({ key: "overflow", avatar: null, i: visible.length });
    }

    return (
      <div
        ref={ref}
        {...props}
        className={["relative", className].filter(Boolean).join(" ")}
        style={{
          height: height + zigzag,
          width: step * (cells.length - 1) + size,
        }}
      >
        {cells.map(({ key, avatar, i }) => {
          const isOverflow = avatar === null;
          const bg = avatar
            ? HONEY_SHADES[hashString(avatar.name) % HONEY_SHADES.length]
            : OVERFLOW_BG;
          const label = isOverflow ? OVERFLOW_LABEL : HONEY_LABEL;
          return (
            <div
              key={key}
              title={avatar?.name}
              className="absolute shrink-0 transition-transform duration-200 ease-out hover:z-20 hover:-translate-y-1.5"
              style={{
                width: size,
                height,
                left: i * step,
                top: i % 2 === 0 ? 0 : zigzag,
                zIndex: i,
              }}
            >
              <div
                aria-hidden="true"
                className={`absolute -inset-[3px] ${ringClassName}`}
                style={{ clipPath: HEX_CLIP }}
              />
              <div
                className="absolute inset-0 flex items-center justify-center overflow-hidden text-[13px] font-bold shadow-[inset_0_-6px_10px_rgba(0,0,0,0.22)]"
                style={{ clipPath: HEX_CLIP, backgroundColor: bg, color: label }}
              >
                {isOverflow ? (
                  `+${overflow}`
                ) : avatar.src ? (
                  <img
                    src={avatar.src}
                    alt={avatar.name}
                    className="h-full w-full object-cover"
                  />
                ) : (
                  initialsOf(avatar.name)
                )}
              </div>
            </div>
          );
        })}
      </div>
    );
  },
);

HoneycombAvatars.displayName = "HoneycombAvatars";

Usage

usage.tsx
import { HoneycombAvatars } from "@/components/honeycomb-avatars";

export function TeamStrip() {
  return (
    <HoneycombAvatars
      avatars={[
        { name: "Priya Shah" },
        { name: "Mateo Rossi" },
        { name: "Aisha Bello" },
      ]}
      max={5}
    />
  );
}
Dependencies
None - just React and Tailwind.
Responsive
Sized in pixels via the size prop rather than percentages, since the hexagon math needs a fixed reference - use a smaller size for a compact comment thread and a larger one for a team page header.
Dark mode
The ring around each cell switches from white to dark slate via the ringClassName prop's dark: variant, so cells stay visually separated on a dark card too. Point ringClassName at whatever surface color you're placing it on.