Avatar Stack Collection
A versatile set of avatar stacks with various interaction models including expansion, glassmorphism, tooltips, and vertical layouts.
Installation
Add this component to your project using the CLI:
npx vui-registry-cli-v1 add avatar-stackSource Code
'use client'
import React from 'react'
import { motion } from 'framer-motion'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
interface User {
id: string
name: string
image: string
}
interface AvatarStackProps {
users: User[]
max?: number
}
export function AvatarStack({ users, max = 5 }: AvatarStackProps) {
const displayUsers = users.slice(0, max)
const remaining = users.length - max
return (
<div className="relative inline-flex flex-col items-center select-none">
{/* 1. THE RAIL: High-Density Recessed Track */}
<div className="relative flex items-center p-1 rounded-full
bg-zinc-950/40 border border-white/[0.05]
shadow-[inset_0_1px_2px_rgba(0,0,0,0.5)]">
{displayUsers.map((user, index) => (
<div
key={user.id}
className="relative -ml-3 first:ml-0 transition-transform duration-500"
style={{ zIndex: displayUsers.length - index }}
>
{/* THE AVATAR: Bezel-less with standard natural color */}
<div className="relative rounded-full p-[0.5px] bg-gradient-to-b from-white/10 to-transparent shadow-lg">
<Avatar className="h-9 w-9 border-[1.5px] border-zinc-950 ring-1 ring-white/5">
<AvatarImage
src={user.image}
className="object-cover" // Full natural color, no grayscale
/>
<AvatarFallback className="bg-zinc-900 text-[10px] font-black text-zinc-500 uppercase">
{user.name.charAt(0)}
</AvatarFallback>
</Avatar>
</div>
</div>
))}
{/* 2. PLUS COUNTER: Standardized density */}
{remaining > 0 && (
<div className="relative -ml-3 z-0">
<div className="flex h-9 w-9 items-center justify-center rounded-full
bg-zinc-900 border-[1.5px] border-zinc-950 ring-1 ring-white/5 shadow-xl">
<span className="text-[10px] font-black text-zinc-400">+{remaining}</span>
</div>
</div>
)}
</div>
{/* 3. SUB-METADATA: Static Technical Signature */}
<div className="mt-2 flex items-center gap-2 opacity-30">
<div className="w-1.5 h-[1px] bg-zinc-500" />
<div className="w-1.5 h-[1px] bg-zinc-500" />
</div>
</div>
)
}Source Code
'use client'
import React from 'react'
import { motion } from 'framer-motion'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
interface User {
id: string
name: string
image: string
}
interface AvatarStackGlassProps {
users: User[]
max?: number
}
export function AvatarStackGlass({ users, max = 5 }: AvatarStackGlassProps) {
const displayUsers = users.slice(0, max)
const beamColor = "#A855F7"
return (
<div className="relative inline-flex flex-col items-center group/container py-6">
{/* 1. THE RAIL: High-Density Glass Chassis */}
<motion.div
layout
className="relative flex items-center h-12 px-2 rounded-full
bg-white/5 dark:bg-black/40 backdrop-blur-2xl
border border-white/10 shadow-2xl transition-all duration-500"
>
{displayUsers.map((user, index) => (
<motion.div
key={user.id}
layout
// SPREAD LOGIC: Default negative margin, expands to wide gap on individual hover
className="relative -ml-3.5 first:ml-0"
style={{ zIndex: displayUsers.length - index }}
whileHover={{
marginLeft: "12px",
marginRight: "12px",
zIndex: 50
}}
transition={{
type: 'spring',
stiffness: 300,
damping: 25,
mass: 0.6
}}
>
<div className="relative group/avatar cursor-none">
{/* THE LENS: Optical Bezel */}
<div className="relative rounded-full p-[0.5px] bg-gradient-to-b from-white/30 to-transparent shadow-2xl">
<Avatar className="h-9 w-9 border-[0.5px] border-black/20 transition-all duration-500 ring-0 group-hover/avatar:ring-4 ring-purple-500/10">
<AvatarImage
src={user.image}
className="object-cover grayscale group-hover/avatar:grayscale-0 transition-all duration-700 ease-out"
/>
<AvatarFallback className="bg-zinc-950 text-[10px] font-black text-zinc-500 uppercase">
{user.name.charAt(0)}
</AvatarFallback>
</Avatar>
{/* Individual Filament (Only under the hovered icon) */}
<motion.div
className="absolute -bottom-[9px] left-1/2 -translate-x-1/2 h-[1px] bg-purple-500 opacity-0 group-hover/avatar:opacity-100"
initial={{ width: 0 }}
whileHover={{ width: "80%" }}
style={{ boxShadow: `0 0 8px ${beamColor}` }}
/>
</div>
{/* NAME REVEAL: Surgical Metadata */}
<div className="absolute -top-7 left-1/2 -translate-x-1/2 opacity-0 group-hover/avatar:opacity-100 transition-all duration-300 scale-90 group-hover/avatar:scale-100 pointer-events-none">
<span className="text-[7px] font-black uppercase tracking-[0.2em] text-white/70 whitespace-nowrap">
{user.name}
</span>
</div>
</div>
</motion.div>
))}
{/* 2. THE MAIN BEAM: A static tapered datum line */}
<div className="absolute bottom-0 left-6 right-6 h-[0.5px] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</motion.div>
{/* 3. BRANDING: Velocity UI Signature */}
<div className="mt-4 flex items-center gap-3 opacity-20 group-hover/container:opacity-50 transition-all duration-700">
<div className="w-4 h-[1px] bg-zinc-800" />
<span className="text-[6px] font-black uppercase tracking-[0.8em] text-zinc-400">
Iris_Spread_v2
</span>
<div className="w-4 h-[1px] bg-zinc-800" />
</div>
</div>
)
}Source Code
'use client'
import React, { useState, useRef } from 'react'
import { motion, AnimatePresence, useMotionValue, useSpring, useTransform } from 'framer-motion'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
interface User {
id: string
name: string
image: string
}
interface AvatarStackTooltipProps {
users: User[]
max?: number
}
export function AvatarStackTooltip({ users, max = 5 }: AvatarStackTooltipProps) {
const displayUsers = users.slice(0, max)
const [isHovered, setIsHovered] = useState(false)
return (
<motion.div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={`flex items-center transition-all duration-500 ease-in-out ${
isHovered ? 'gap-2' : '-space-x-4'
}`}
>
<AnimatePresence mode="popLayout">
{displayUsers.map((user, index) => (
<AvatarWithTooltip
key={user.id}
user={user}
index={index}
total={displayUsers.length}
/>
))}
</AnimatePresence>
</motion.div>
)
}
function AvatarWithTooltip({ user, index, total }: { user: User; index: number; total: number }) {
const [isHovered, setIsHovered] = useState(false)
const mouseX = useMotionValue(0)
const mouseY = useMotionValue(0)
// Smooth spring physics for the magnetic effect
const springConfig = { damping: 20, stiffness: 300 }
const x = useSpring(mouseX, springConfig)
const y = useSpring(mouseY, springConfig)
// Create a slight rotation based on horizontal mouse movement
const rotate = useTransform(x, [-15, 15], [-10, 10])
function handleMouseMove(event: React.MouseEvent<HTMLDivElement>) {
const rect = event.currentTarget.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
mouseX.set(event.clientX - centerX)
mouseY.set(event.clientY - centerY)
}
function handleMouseLeave() {
setIsHovered(false)
mouseX.set(0)
mouseY.set(0)
}
return (
<motion.div
layout
initial={{ opacity: 0, scale: 0.5, x: 20 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{
type: 'spring',
stiffness: 260,
damping: 20,
delay: index * 0.05
}}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onMouseEnter={() => setIsHovered(true)}
style={{
x,
y,
rotate,
zIndex: isHovered ? 100 : total - index
}}
className="relative flex-shrink-0"
>
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
className="rounded-full ring-[3px] ring-background bg-background shadow-md hover:shadow-xl cursor-pointer transition-shadow duration-300"
>
<Avatar className="h-12 w-12 border-none">
<AvatarImage src={user.image} alt={user.name} className="object-cover" />
<AvatarFallback className="bg-gradient-to-tr from-neutral-100 to-neutral-300 text-neutral-600 text-[10px] font-bold">
{user.name.split(' ').map(n => n[0]).join('')}
</AvatarFallback>
</Avatar>
</motion.div>
<AnimatePresence>
{isHovered && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.8, x: '-50%' }}
animate={{ opacity: 1, y: -14, scale: 1, x: '-50%' }}
exit={{ opacity: 0, y: 5, scale: 0.9 }}
className="absolute bottom-full left-1/2 px-3 py-1.5 bg-black/80 backdrop-blur-xl text-white text-[11px] font-medium rounded-full shadow-2xl whitespace-nowrap z-[110] border border-white/20 pointer-events-none"
>
{user.name}
{/* Minimalist Arrow */}
<div className="absolute top-[95%] left-1/2 -translate-x-1/2 w-2 h-2 bg-black/80 rotate-45 border-b border-r border-white/10" />
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
}Source Code
'use client'
import React, { useState } from 'react'
import { motion, LayoutGroup } from 'framer-motion'
import { Plus } from 'lucide-react'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
// Bespoke physics: High damping to eliminate jitter, high stiffness for "expensive" feel
const SPRING_UI = { type: "spring", stiffness: 500, damping: 40, mass: 1 }
export function AvatarStackAdd({ users = [], onAdd }: { users: any[], onAdd?: () => void }) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
const displayUsers = users.slice(0, 4)
return (
<LayoutGroup>
<motion.div
layout
// py-2.5 provides the "headroom" for 1.25x scaling without leaking
className="flex items-center px-3 py-2.5 bg-white dark:bg-neutral-950 rounded-full border border-neutral-200 dark:border-neutral-800 shadow-[0_2px_8px_rgba(0,0,0,0.04)] w-fit"
>
<div className="flex items-center">
{displayUsers.map((user, i) => {
const isHovered = hoveredIndex === i
return (
<motion.div
key={user.id}
layout
onMouseEnter={() => setHoveredIndex(i)}
onMouseLeave={() => setHoveredIndex(null)}
transition={SPRING_UI}
animate={{
scale: isHovered ? 1.25 : 1,
// The "Precision Gap": Negative margin handles overlap,
// positive margin on hover creates the expansion
marginLeft: i === 0 ? 0 : isHovered ? 16 : -12,
marginRight: isHovered ? 16 : 0,
}}
style={{ zIndex: isHovered ? 50 : 10 + i }}
className="relative"
>
<div className="relative rounded-full bg-background p-[1.5px] ring-1 ring-black/[0.06] dark:ring-white/[0.1] shadow-sm">
<Avatar className="h-10 w-10 border-none select-none pointer-events-none">
<AvatarImage src={user.image} className="object-cover" />
<AvatarFallback className="bg-neutral-100 dark:bg-neutral-900 text-[9px] font-black uppercase">
{user.name.slice(0, 2)}
</AvatarFallback>
</Avatar>
</div>
</motion.div>
)
})}
</div>
{/* This separator expands and contracts with the stack */}
<motion.div
layout
transition={SPRING_UI}
className="h-4 w-[1px] bg-neutral-200 dark:bg-neutral-800 mx-4 flex-shrink-0"
/>
<motion.button
layout
onClick={onAdd}
whileHover={{
scale: 1.1,
rotate: 90,
backgroundColor: "rgb(0,0,0)",
color: "rgb(255,255,255)"
}}
whileTap={{ scale: 0.9 }}
transition={SPRING_UI}
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-neutral-100 dark:bg-neutral-900 text-neutral-500 hover:shadow-md outline-none"
>
<Plus className="h-4 w-4 stroke-[3px]" />
</motion.button>
</motion.div>
</LayoutGroup>
)
}Source Code
'use client'
import React, { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
export function AvatarStackVertical({ users = [] }: { users: any[] }) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
const displayUsers = users.slice(0, 6)
// Pure "Linear" easing for the scale, heavy spring for the layout
const springConfig = { type: "spring", stiffness: 500, damping: 40, mass: 1 }
return (
<div className="flex flex-col items-center py-20">
<div className="flex flex-col -space-y-6">
{displayUsers.map((user, i) => {
const isHovered = hoveredIndex === i
// Calculate distance from hover to create a "wave" effect
const distance = hoveredIndex !== null ? Math.abs(i - hoveredIndex) : null
return (
<motion.div
key={user.id}
onMouseEnter={() => setHoveredIndex(i)}
onMouseLeave={() => setHoveredIndex(null)}
style={{
zIndex: isHovered ? 50 : displayUsers.length - i,
}}
animate={{
// Smoothly adjust vertical spacing based on distance from hover
y: distance === 0 ? 0 : distance === 1 ? (i < hoveredIndex ? -15 : 15) : 0,
scale: distance === 0 ? 1.3 : distance === 1 ? 1.05 : 1,
}}
transition={springConfig}
className="relative cursor-pointer"
>
<div className="relative group">
{/* Outer Glow Ring: Only visible on high-focus */}
<motion.div
animate={{
opacity: isHovered ? 1 : 0,
scale: isHovered ? 1.15 : 0.8
}}
className="absolute inset-0 rounded-full bg-gradient-to-tr from-blue-500/20 to-purple-500/20 blur-lg"
/>
<div className="relative rounded-full bg-background p-[2px] ring-1 ring-black/[0.08] dark:ring-white/[0.12] shadow-2xl">
<Avatar className="h-12 w-12 border-2 border-background">
<AvatarImage src={user.image} className="object-cover" />
<AvatarFallback className="bg-neutral-50 text-[10px] font-bold text-neutral-400">
{user.name[0]}
</AvatarFallback>
</Avatar>
</div>
{/* Floating Name Label: Professional and Minimal */}
<AnimatePresence>
{isHovered && (
<motion.div
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 20 }}
exit={{ opacity: 0, x: 10 }}
className="absolute left-full top-1/2 -translate-y-1/2 ml-2 whitespace-nowrap"
>
<div className="bg-black/90 dark:bg-white text-white dark:text-black px-2.5 py-1 rounded-md text-[11px] font-medium shadow-xl">
{user.name}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)
})}
</div>
</div>
)
}Source Code
'use client'
import React from 'react'
import { motion } from 'framer-motion'
import { cn } from '@/lib/utils'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
interface User {
id: string
name: string
image?: string
}
export interface AvatarStackProps {
users: User[]
max?: number
className?: string
size?: number
}
export function AvatarStackRing({ users, max = 5, className, size = 48 }: AvatarStackProps) {
return (
<div className={cn('flex items-center -space-x-4', className)}>
{users.slice(0, max).map((user, i) => (
<motion.div
key={user.id}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.06 }}
className="relative z-10 hover:z-20"
>
<Avatar
className={cn('ring-2 ring-background shadow-sm hover:ring-primary/50 transition-all')}
style={{ width: size, height: size }}
>
<AvatarImage src={user.image} alt={user.name} className="object-cover" />
<AvatarFallback className="bg-muted text-[10px] font-bold uppercase">
{user.name.slice(0, 2)}
</AvatarFallback>
</Avatar>
</motion.div>
))}
</div>
)
}
Source Code
'use client'
import React from 'react'
import { motion } from 'framer-motion'
import { cn } from '@/lib/utils'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
interface User {
id: string
name: string
image?: string
}
export interface AvatarStackProps {
users: User[]
max?: number
className?: string
size?: number
}
export function AvatarStackCascade({ users, max = 6, className, size = 44 }: AvatarStackProps) {
return (
<div className={cn('flex items-end gap-2', className)}>
{users.slice(0, max).map((user, i) => (
<motion.div
key={user.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className="relative"
style={{ transform: `translateY(${(i % 3) * 6}px)` }}
>
<Avatar className="ring-2 ring-background" style={{ width: size, height: size }}>
<AvatarImage src={user.image} alt={user.name} className="object-cover" />
<AvatarFallback className="bg-muted text-[10px] font-bold uppercase">
{user.name.slice(0, 2)}
</AvatarFallback>
</Avatar>
</motion.div>
))}
</div>
)
}
Dependencies
framer-motion: latestclsx: latesttailwind-merge: latestlucide-react: latest
Props
Component property reference.
| Name | Type | Default | Description |
|---|---|---|---|
| users | User[] | [] | Array of user objects with id, name, and image. |
| max | number | 4 | Maximum number of avatars to show before truncation. |
| size | number | 40 | Size of each avatar in pixels. |
Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.
I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.
I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.

