uniquel
componentavatar-stacksource: ok · hit
Avatar Stack
Layered portraits with grayscale-to-color hover, spring lift, and a glass +N counter.
npx shadcn add "https://uniquel.io/r/avatar-stack.json"free
Where it goes
stats
Visual style
glass-blur
Interaction
animated-counter
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationframer-motion
Files1 · source cached 10/14/2026, 1:21:41 PM
components/trust/AvatarStack.tsx
'use client';
import { motion } from 'framer-motion';
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
//
// avatars Portrait entries — image src, alt, optional name
// maxVisible How many faces show before the +N counter
// label Optional caption under the stack (e.g. "Trusted by 2.4k teams")
// size Avatar diameter: sm | md | lg
// showNames Reveal names on hover
// className Styles for the outer shell
//
type Avatar = {
src: string;
alt?: string;
name?: string;
};
type AvatarStackProps = {
avatars?: Avatar[];
maxVisible?: number;
label?: string;
size?: 'sm' | 'md' | 'lg';
showNames?: boolean;
className?: string;
};
const DEFAULT_AVATARS: Avatar[] = [
{
src: 'https://assets.uniquel.io/components/stock/photo-1494790108377-be9c29b29330.jpg',
alt: 'Maya Chen',
name: 'Maya Chen',
},
{
src: 'https://assets.uniquel.io/components/stock/photo-1507003211169-0a1dd7228f2d.jpg',
alt: 'James Okonkwo',
name: 'James Okonkwo',
},
{
src: 'https://assets.uniquel.io/components/stock/photo-1438761681033-6461ffad8d80.jpg',
alt: 'Sofia Reyes',
name: 'Sofia Reyes',
},
{
src: 'https://assets.uniquel.io/components/stock/photo-1472099645785-5658abf4ff4e.jpg',
alt: 'Noah Berg',
name: 'Noah Berg',
},
{
src: 'https://assets.uniquel.io/components/stock/photo-1544005313-94ddf0286df2.jpg',
alt: 'Aria Patel',
name: 'Aria Patel',
},
{
src: 'https://assets.uniquel.io/components/stock/photo-1500648767791-00dcc994a43e.jpg',
alt: 'Liam Frost',
name: 'Liam Frost',
},
];
const SIZE_MAP = {
sm: { px: 28, ring: 2, overlap: -10, text: 'text-[10px]' },
md: { px: 36, ring: 2, overlap: -12, text: 'text-xs' },
lg: { px: 44, ring: 3, overlap: -14, text: 'text-sm' },
};
export default function AvatarStack({
avatars = DEFAULT_AVATARS,
maxVisible = 4,
label = 'Joined by 2.4k builders',
size = 'md',
showNames = true,
className = '',
}: AvatarStackProps) {
const deck = avatars.length ? avatars : DEFAULT_AVATARS;
const visible = deck.slice(0, maxVisible);
const overflow = Math.max(0, deck.length - maxVisible);
const s = SIZE_MAP[size];
return (
<div
className={['inline-flex flex-col items-start gap-2.5', className]
.filter(Boolean)
.join(' ')}
>
<div className='flex items-center'>
{visible.map((avatar, index) => (
<motion.div
key={`${avatar.src}-${index}`}
className='group relative'
style={{
marginLeft: index === 0 ? 0 : s.overlap,
zIndex: index + 1,
}}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.45,
delay: index * 0.06,
ease: [0.22, 1, 0.36, 1],
}}
whileHover={{
y: -4,
zIndex: 20,
transition: { type: 'spring', stiffness: 400, damping: 22 },
}}
>
<div
className='overflow-hidden rounded-full bg-white shadow-[0_1px_2px_rgba(0,0,0,0.08),0_4px_12px_rgba(0,0,0,0.06)]'
style={{
width: s.px,
height: s.px,
outline: `${s.ring}px solid white`,
}}
>
<img
src={avatar.src}
alt={avatar.alt ?? avatar.name ?? ''}
className='h-full w-full object-cover grayscale transition-[filter,transform] duration-500 group-hover:scale-105 group-hover:grayscale-0'
/>
</div>
{showNames && avatar.name ? (
<span className='pointer-events-none absolute -bottom-7 left-1/2 z-30 -translate-x-1/2 whitespace-nowrap rounded-full bg-gray-900/90 px-2 py-0.5 text-[10px] font-medium tracking-wide text-white opacity-0 shadow-lg backdrop-blur-xs transition-opacity duration-200 group-hover:opacity-100'>
{avatar.name}
</span>
) : null}
</motion.div>
))}
{overflow > 0 ? (
<motion.div
className='relative flex items-center justify-center rounded-full border border-white/60 bg-white/70 font-medium tracking-wide text-gray-700 shadow-[0_1px_2px_rgba(0,0,0,0.06)] backdrop-blur-md'
style={{
width: s.px,
height: s.px,
marginLeft: s.overlap,
zIndex: visible.length + 1,
fontSize: size === 'sm' ? 10 : size === 'md' ? 11 : 12,
}}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.4,
delay: visible.length * 0.06,
ease: [0.22, 1, 0.36, 1],
}}
whileHover={{
y: -3,
transition: { type: 'spring', stiffness: 400, damping: 22 },
}}
>
+{overflow}
</motion.div>
) : null}
</div>
{label ? (
<motion.p
className={['font-medium tracking-wide text-gray-500', s.text].join(
' '
)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.35 }}
>
{label}
</motion.p>
) : null}
</div>
);
}