Portraits with an initials fallback, and a group that fans out on hover to name everyone, with the rest in a +N chip.
pnpm dlx shadcn@latest add @latheui/avatarFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add motion radix-uiAdd the Lathe items this component imports.
lib/transition.tsCopy the source into your project.
"use client"
import * as React from "react"
import { motion } from "motion/react"
import { Avatar as Primitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { useCurveTransition, type Curve } from "@/lib/transition"
import { Tooltip, TooltipProvider } from "@/components/ui/tooltip"
export type AvatarProps = React.ComponentProps<typeof Primitive.Root> & {
name: string
src?: string
size?: "sm" | "md" | "lg"
}
export function Avatar({
name,
src,
size = "md",
className,
...props
}: AvatarProps) {
const initials =
name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((p) => p[0])
.join("")
.toUpperCase() || "?"
return (
<Primitive.Root
className={cn(
"relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full border border-border bg-secondary font-medium text-secondary-foreground",
size === "sm"
? "size-7 text-[10px]"
: size === "lg"
? "size-12 text-base"
: "size-9 text-xs",
className
)}
{...props}
>
{src && (
<Primitive.Image
src={src}
alt={name}
draggable={false}
// Pressing and holding on a phone would otherwise open the system image preview.
className="pointer-events-none size-full object-cover select-none [-webkit-touch-callout:none]"
/>
)}
<Primitive.Fallback
role="img"
aria-label={name}
className="grid size-full place-items-center"
>
{initials}
</Primitive.Fallback>
</Primitive.Root>
)
}
export type AvatarGroupProps = Omit<React.ComponentProps<"div">, "children"> & {
people: { name: string; src?: string }[]
/** How many avatars show before the rest fold into a "+N" chip. */
max?: number
size?: AvatarProps["size"]
/** How far each avatar tucks under the one before, in px. Defaults to about a third of its size. */
overlap?: number
/** Fan the avatars apart while the pointer or focus is on the group. */
spread?: boolean
/** Space between avatars once spread, in px. */
spreadGap?: number
/** Show each person's name in a tooltip, and make the avatars focusable to reach it. */
names?: boolean
/** Accessible name for the group. */
label?: string
/** How avatars move apart: any curve from the Animation Lab. A snappy spring when not set. */
curve?: Curve
/** Milliseconds, for curves that are not springs. */
duration?: number
}
const pixels = { sm: 28, md: 36, lg: 48 }
const fan = { type: "spring", stiffness: 520, damping: 34, mass: 0.7 } as const
/** Overlapping avatars that fan out to show who is there, with the rest counted in a "+N" chip. */
export function AvatarGroup({
people,
max = 4,
size = "md",
overlap,
spread = true,
spreadGap = 4,
names = true,
label = "People",
curve,
duration,
className,
...props
}: AvatarGroupProps) {
const [open, setOpen] = React.useState(false)
// The avatar the pointer is over. Set on pointer movement only, so avatars sliding under a
// still pointer while the group fans out do not each pop up in turn.
const [lifted, setLifted] = React.useState<number | null>(null)
const transition = useCurveTransition(curve, duration, fan)
const visible = people.slice(0, Math.max(0, Math.floor(max)))
const hidden = people.slice(visible.length)
const tuck = overlap ?? Math.round(pixels[size] * 0.32)
const fanned = spread && open
const chip = (
<span
role="img"
tabIndex={names ? 0 : undefined}
aria-label={hidden.length + " more: " + hidden.map((person) => person.name).join(", ")}
className={cn(
"relative grid shrink-0 place-items-center rounded-full border border-border bg-secondary font-medium text-secondary-foreground tabular-nums ring-2 ring-background outline-none focus-visible:ring-ring",
size === "sm" ? "size-7 text-[10px]" : size === "lg" ? "size-12 text-sm" : "size-9 text-xs"
)}
>
+{hidden.length}
</span>
)
return (
<TooltipProvider delayDuration={80}>
<div
role="group"
aria-label={label}
data-slot="avatar-group"
data-spread={fanned || undefined}
onPointerEnter={() => setOpen(true)}
onPointerLeave={() => {
setOpen(false)
setLifted(null)
}}
onFocus={() => setOpen(true)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node)) setOpen(false)
}}
className={cn("flex items-center", className)}
{...props}
>
{[...visible.map((person, i) => ({ person, i })), ...(hidden.length ? [null] : [])].map((entry, k) => {
const avatar = entry ? (
<Avatar
name={entry.person.name}
src={entry.person.src}
size={size}
tabIndex={names ? 0 : undefined}
className="ring-2 ring-background outline-none focus-visible:ring-ring"
/>
) : (
chip
)
return (
<motion.div
key={entry ? entry.i : "more"}
initial={false}
animate={{ marginLeft: k === 0 ? 0 : fanned ? spreadGap : -tuck, y: lifted === k ? -3 : 0 }}
onPointerMove={(event) => {
if (event.pointerType === "mouse" && lifted !== k) setLifted(k)
}}
onPointerLeave={() => setLifted((current) => (current === k ? null : current))}
transition={transition}
// Earlier avatars sit on top, so each one tucks under the one before it.
style={{ zIndex: visible.length + 1 - k }}
className="relative select-none [-webkit-touch-callout:none]"
>
{names ? (
<Tooltip content={entry ? entry.person.name : hidden.map((person) => person.name).join(", ")}>
{avatar}
</Tooltip>
) : (
avatar
)}
</motion.div>
)
})}
</div>
</TooltipProvider>
)
}Depends on
Also accepts the Radix Avatar root's attributes.
| Prop | Type | Default | Description |
|---|---|---|---|
| name* (required) | string | — | Accessible name, and the source of the initials. |
| src | string | — | Image URL. The initials show while it loads, or if it fails. |
| size | "sm" | "md" | "lg" | "md" | 28, 36 or 48px. |
Also accepts native div attributes.
| Prop | Type | Default | Description |
|---|---|---|---|
| people* (required) | { name: string; src?: string }[] | — | Everyone in the group, first on top. |
| max | number | 4 | Avatars shown before the rest fold into a +N chip. |
| size | "sm" | "md" | "lg" | "md" | Size of every avatar. |
| overlap | number | about a third of the size | How far each avatar tucks under the one before, in px. |
| spread | boolean | true | Fan apart while the pointer or focus is on the group. |
| spreadGap | number | 4 | Space between avatars once spread, in px. |
| names | boolean | true | A tooltip with each person's name; makes avatars focusable to reach it. |
| label | string | "People" | Accessible name for the group. |
| curve | Curve | snappy spring | How avatars move apart. Any curve from the Animation Lab. |
| duration | number | — | Milliseconds, for curves that are not springs. |
<AvatarGroup people={people} size="sm" overlap={12} spreadGap={8} curve={{ type: "spring", stiffness: 300, damping: 14 }} /><AvatarGroup people={people} spread={false} names={false} />