Avatar Group
Round photos overlapping in a row with a count bubble; the one you point at rises.
$npx shadcn@latest add https://framebrick.co/r/avatar-group.jsonFrom the Metuo template, see it in context.




- 65+
Trusted by 65+ clients
import { AvatarGroup } from "@/components/ui/avatar-group";
// Demo portraits from Unsplash (free under the Unsplash License), served by this site so no request leaves it. Placeholders:
// use your own clients' photos, since a stock portrait beside a count reads as that person's endorsement.
const portraits = ["/demo/photo-tabs-amara.webp", "/demo/photo-tabs-daniel.webp", "/demo/photo-tabs-lena.webp", "/demo/photo-tabs-mateo.webp"];
/* Point at a photo: it rises 20px, and the one raised before comes back down. The room above the group is the page's. */
export default function AvatarGroupDemo() {
return (
<div className="flex flex-col items-center gap-3 pt-8">
<AvatarGroup label="Trusted by more than 65 clients" more="65+">
{portraits.map((src) => (
<img key={src} src={src} alt="" draggable={false} className="object-[50%_18%]" />
))}
</AvatarGroup>
<p className="text-sm text-muted-foreground">Trusted by 65+ clients</p>
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/avatar-group.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/avatar-group.tsx.import * as React from "react"; import { cn } from "@/lib/utils"; /* * Avatar Group — round photos overlapping in a row, with a bubble that counts the others (from the Metuo "Client Ratings": * four 42px client photos overlapping in 17px slots, then a "65+" bubble). Point at a photo and it rises 20px; the one that * was raised comes back down at the same time, and leaving the group lowers it. The bubble does not move. * * It is plain CSS: no animation library, no JavaScript, so it works as it is on a server page. The rise is Framer's spring * (bounce 0, 0.4s) as a CSS linear() curve sampled from the spring itself (tools/ui/spring-curve.mjs in the Framebrick * workspace; within 1% of it); a browser without linear() gets cubic-bezier(0.22, 1, 0.36, 1). Hover only applies where the * device can hover, and keyboard focus raises a photo that is a link; a rise is a movement, so it is off with reduced motion. * * Each photo sits in a box the size of the photo and the next one overlaps it by `overlap`; it is the photo inside the box that * rises, not the box, so the pointer keeps its footing (raise the box under the pointer and its lower edge slips out from under * it, which flickers). The raised photo rises over what is above the group, so leave room there. * The photos are your children: an <img>, a next/image with `fill`, a link around one. Give each an alt text, or an empty one * when the group is decoration and `label` says what it is. */ const ease = "[--avatar-ease:cubic-bezier(0.22,1,0.36,1)] supports-[transition-timing-function:linear(0,1)]:[--avatar-ease:linear(0,0.041,0.134,0.248,0.364,0.472,0.569,0.652,0.722,0.78,0.827,0.864,0.894,0.918,0.937,0.951,0.963,0.971,0.978,0.983,0.987,0.99,0.993,0.995,0.996,0.997,0.998,0.998,0.999,1)]"; export type AvatarGroupProps = Omit<React.HTMLAttributes<HTMLUListElement>, "children"> & { /** Accessible name of the group, e.g. "Trusted by 65+ clients". */ label?: string; /** What the bubble after the photos says, e.g. "65+". Without it there is no bubble. */ more?: React.ReactNode; /** Diameter of a photo, in px. */ size?: number; /** How far each photo overlaps the one before it, in px. */ overlap?: number; /** How far the photo you point at rises, in px. */ rise?: number; /** The photos. */ children: React.ReactNode; }; export function AvatarGroup({ label, more, size = 42, overlap = 15, rise = 20, className, style, children, ...props }: AvatarGroupProps) { const photos = React.Children.toArray(children); return ( <ul aria-label={label} {...props} style={{ ["--size" as string]: `${size}px`, ["--overlap" as string]: `${overlap}px`, ["--rise-by" as string]: `${rise}px`, ...style }} className={cn(ease, "flex items-center [--rise:var(--rise-by)] motion-reduce:[--rise:0px]", className)} > {photos.map((photo, index) => ( <li key={index} className={cn( "relative size-(--size) shrink-0", index > 0 && "-ml-(--overlap)", "[&>*]:size-full [&>*]:rounded-full [&>*]:object-cover [&>*]:transition-[translate] [&>*]:duration-400 [&>*]:ease-(--avatar-ease)", "[@media(hover:hover)]:hover:[&>*]:-translate-y-(--rise) has-[:focus-visible]:[&>*]:-translate-y-(--rise)", )} > {photo} </li> ))} {more !== undefined && more !== null && ( <li className={cn("relative grid size-(--size) shrink-0 place-items-center rounded-full bg-primary text-sm font-medium text-primary-foreground", photos.length > 0 && "-ml-(--overlap)")}> {more} </li> )} </ul> ); }
Usage
import { AvatarGroup } from "@/components/ui/avatar-group";
<AvatarGroup label="Trusted by more than 65 clients" more="65+">
{clients.map((client) => (
<img key={client.photo} src={client.photo} alt="" />
))}
</AvatarGroup>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | The photos: an <img>, a next/image with fill, a link around one. Give each an alt text, or an empty one when the group is decoration and label says what it is. |
| label | string | — | Accessible name of the group, e.g. "Trusted by 65+ clients". |
| more | ReactNode | — | What the bubble after the photos says, e.g. "65+". Without it there is no bubble. |
| size | number | 42 | Diameter of a photo, in px. |
| overlap | number | 15 | How far each photo overlaps the one before it, in px. |
| rise | number | 20 | How far the photo you point at rises, in px. |
- It is the Metuo "Client Ratings": four 42 px client photos overlapping in 17 px slots 10 px apart, then a "65+" bubble. Point at a photo and it rises 20 px while the one raised before comes down, at the same time; leaving the group lowers it. The bubble does not move. The rise is Framer's spring, bounce 0, 0.4 s (measured: 400.2 → 380.2 over about 0.44 s).
- Plain CSS: no animation library and no JavaScript, so it works as it is on a server page. The spring is a CSS linear() curve sampled from the spring itself, within 1% of it; a browser without linear() gets cubic-bezier(0.22, 1, 0.36, 1).
- Each photo sits in a box the size of the photo, and it is the photo inside the box that rises, not the box: raise the box under the pointer and its lower edge slips out from under it, so it drops, comes back and flickers. The raised photo rises over what is above the group, so leave room there.
- Hover only applies where the device can hover, and keyboard focus raises a photo that is a link. A rise is a movement, so it is off with reduced motion.
- The demo's portraits are Unsplash photos used as placeholders. Use your own clients' photos: a stock portrait beside a count reads as that person's endorsement.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS component called AvatarGroup — plain CSS, no JavaScript and no animation library, so it works in a server component. - Props: children (the photos), label (aria-label of the list), more (the bubble's content), size (42), overlap (15), rise (20), className and the ul attributes. Set --size, --overlap and --rise-by inline. - A ul (flex items-center, [--rise:var(--rise-by)] and motion-reduce:[--rise:0px]) with one li per child: size-(--size) shrink-0 relative, -ml-(--overlap) except the first, its child size-full rounded-full object-cover. A last li, if more is given: the same size, rounded-full bg-primary text-primary-foreground text-sm font-medium, centred, with the same negative margin. - The photo you point at rises: [@media(hover:hover)]:hover:[&>*]:-translate-y-(--rise), and has-[:focus-visible]:[&>*]:-translate-y-(--rise) for a link. Only the li's child translates (transition-[translate] over 400ms), never the li, so the pointer's footing is kept. - The easing is a CSS custom property --avatar-ease: cubic-bezier(0.22,1,0.36,1), replaced by a 30-stop linear() list sampled from a spring with bounce 0 and duration 0.4s inside supports-[transition-timing-function:linear(0,1)].











