liten

Avatar Group

Overlapping avatars that spread apart on hover, each lifting with a name tooltip.

Ada LovelaceAL
Alan TuringAT
Grace HopperGH
Linus TorvaldsLT
Margaret HamiltonMH
+3

Trusted by 12,000+ builders

Avatars stack with a slight overlap by default and springs apart just enough to reveal their edges on hover, while the hovered avatar itself lifts above its neighbors and shows a name tooltip. Images fall back to colored initials chips, hashed deterministically from the name so the palette is stable across renders. Built with Motion.

Installation

Complete the shared Setup first, then add Motion.

Terminal
bun add motion

Copy the component into components/ui/avatar-group.tsx.

components/ui/avatar-group.tsx
'use client';import * as React from 'react';import { motion } from 'motion/react';import { cn } from '@/lib/cn';const PALETTE = ['#f0883e', '#3ecf8e', '#22d3ee', '#b06bff', '#f0463a'];export type Avatar = {  src?: string;  name: string;};export type AvatarGroupProps = {  avatars: Avatar[];  max?: number;  size?: number;  className?: string;};function initials(name: string) {  return name    .split(' ')    .map((w) => w[0])    .slice(0, 2)    .join('')    .toUpperCase();}export function AvatarGroup({  avatars,  max = 5,  size = 40,  className,}: AvatarGroupProps) {  const [open, setOpen] = React.useState(false);  const shown = avatars.slice(0, max);  const extra = avatars.length - shown.length;  const overlap = size * 0.34;  const spread = size * 0.18;  return (    <motion.div      onHoverStart={() => setOpen(true)}      onHoverEnd={() => setOpen(false)}      className={cn('flex items-center', className)}    >      {shown.map((a, i) => (        <motion.div          key={a.name + i}          className="group/av relative"          style={{ zIndex: shown.length - i }}          initial={false}          animate={{ marginLeft: i === 0 ? 0 : open ? -overlap + spread : -overlap }}          transition={{ type: 'spring', stiffness: 320, damping: 26 }}          whileHover={{ y: -4, zIndex: 50, transition: { duration: 0.18 } }}        >          <span            className={cn(              'bloom-edge pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-lg px-2 py-1',              'text-[12px] font-medium tracking-[-0.01em]',              'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-900',              'dark:from-[#272727] dark:to-[#191919] dark:text-white',              'shadow-[0_4px_12px_-4px_rgba(0,0,0,0.2)] dark:shadow-[0_6px_16px_-8px_rgba(0,0,0,0.6)]',              'opacity-0 transition-opacity duration-150 group-hover/av:opacity-100',            )}          >            {a.name}          </span>          <span            className="bloom-edge block overflow-hidden rounded-full ring-2 ring-white dark:ring-[#0a0a0b]"            style={{ width: size, height: size }}          >            {a.src ? (              <img                src={a.src}                alt={a.name}                className="size-full object-cover"                draggable={false}              />            ) : (              <span                className="flex size-full items-center justify-center font-semibold"                style={{                  background: PALETTE[hashName(a.name) % PALETTE.length],                  color: '#141612',                  fontSize: size * 0.36,                }}              >                {initials(a.name)}              </span>            )}          </span>        </motion.div>      ))}      {extra > 0 && (        <motion.span          initial={false}          animate={{ marginLeft: open ? -overlap + spread : -overlap }}          transition={{ type: 'spring', stiffness: 320, damping: 26 }}          className={cn(            'bloom-edge grid place-items-center rounded-full font-semibold ring-2 ring-white dark:ring-[#0a0a0b]',            'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-600',            'dark:from-[#242424] dark:to-[#1a1a1a] dark:text-neutral-300',          )}          style={{ width: size, height: size, fontSize: size * 0.32, zIndex: 0 }}        >          +{extra}        </motion.span>      )}    </motion.div>  );}function hashName(name: string) {  let h = 0;  for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;  return Math.abs(h);}

Usage

Example.tsx
import { AvatarGroup } from '@/components/ui/avatar-group';

const PEOPLE = [
  { name: 'Ada Lovelace' },
  { name: 'Alan Turing' },
  { name: 'Grace Hopper' },
];

export default function Example() {
  return <AvatarGroup avatars={PEOPLE} max={5} />;
}

Examples

Two sizes side by side.

Ada LovelaceAL
Alan TuringAT
Grace HopperGH
Linus TorvaldsLT
Ada LovelaceAL
Alan TuringAT
Grace HopperGH
Linus TorvaldsLT
+4

Props

PropTypeDefaultDescription
avatarsAvatar[]-List of { src?, name } entries to render.
maxnumber5How many to show before collapsing into a "+N" chip.
sizenumber40Avatar diameter in px.
classNamestring-Forwarded to the root.
On this page0%