Velocity UI
Loading…
Menu

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:

terminal
npx vui-registry-cli-v1 add avatar-stack

Source Code

avatar-stack/avatar-stack-standard.tsx
'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

avatar-stack/avatar-stack-glass.tsx
'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

avatar-stack/avatar-stack-tooltip.tsx
'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

avatar-stack/avatar-stack-add.tsx
'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

avatar-stack/avatar-stack-vertical.tsx
'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

avatar-stack/avatar-stack-ring.tsx
'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

avatar-stack/avatar-stack-cascade.tsx
'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: latest
  • clsx: latest
  • tailwind-merge: latest
  • lucide-react: latest

Props

Component property reference.

NameTypeDefaultDescription
usersUser[][]Array of user objects with id, name, and image.
maxnumber4Maximum number of avatars to show before truncation.
sizenumber40Size of each avatar in pixels.
Context Worth Keeping In Orbit

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.