← 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
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.