DocumentationMasked Avatars
Masked Avatars
A unique avatar group with masking effects and text ring reveal on hover.
- Tyler

- Dora

- Johan

- Vegeta

- Robin
Install using CLI
npx shadcn@latest add "https://vengeance-ui.vercel.app/r/masked-avatars.json"Install Manually
1
Install dependencies
npm install framer-motion clsx tailwind-merge
2
Add util file
lib/utils.ts
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";export function cn(...inputs: ClassValue[]) {return twMerge(clsx(inputs));}
3
Copy the source code
Copy the code below and paste it into components/ui/masked-avatars.tsx
"use client"import React, { useState } from "react"import { cn } from "@/lib/utils"import { motion } from "framer-motion"interface Avatar {avatar: stringname: string}export interface MaskedAvatarsProps {avatars?: Avatar[]size?: number // avatar size (responsive base)border?: numbercolumn?: numbermovement?: number // hover lift sensitivitytransition?: number // animation durationltr?: booleanringed?: booleanoffset?: number // text ring rotation offsetblurOnRest?: boolean // NEW: only blur when not hoveredclassName?: string}const defaultAvatars: Avatar[] = [{ avatar: "https://i.pravatar.cc/150?u=a", name: "Garou" },{ avatar: "https://i.pravatar.cc/150?u=b", name: "Johan" },{ avatar: "https://i.pravatar.cc/150?u=c", name: "Light Yagami" },{ avatar: "https://i.pravatar.cc/150?u=d", name: "Vegeta" },{ avatar: "https://i.pravatar.cc/150?u=e", name: "Light Yagami" },]export function MaskedAvatars({avatars = defaultAvatars,size = 70,border = 8,column = 40,movement = 0.72,transition = 0.18,ringed = true,offset = -3,blurOnRest = true,className}: MaskedAvatarsProps) {const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)// Auto-responsive size (scales with viewport but keeps props)const dynamicSize = `clamp(${size - 20}px, ${size}px, ${size + 30}px)`const circle = (border * 2 + size) / 2const radX = circle - column - borderconst maskImage = `radial-gradient(${circle}px ${circle}px at ${radX}px 50%, transparent ${circle - 0.5}px, white ${circle}px)`const transitionConfig = {type: "spring" as const,stiffness: 260,damping: 20,}return (<divclassName={cn("relative flex items-center", className)}style={{gap: `min(6vw, ${size * 0.5}px)`,"--size": dynamicSize,} as React.CSSProperties}role="group"aria-label="Animated avatar group"><div className="relative flex items-center"><ulclassName="m-0 p-0 list-none grid grid-flow-col content-end"style={{height: column,gridAutoColumns: column,transform: `translateX(${(size - column) * 0.5}px)`,}}role="list">{avatars.map((person, index) => {const isHovered = hoveredIndex === indexconst isPrevHovered = hoveredIndex === index - 1const baseOffset = -size * 1.5const moveOffset = size * movementconst maskPosition = isPrevHovered? `0 ${baseOffset - moveOffset}px`: isHovered? `0 ${baseOffset + moveOffset}px`: `0 ${baseOffset}px`return (<motion.likey={index}className="relative grid content-end outline-none pointer-events-none"role="listitem"style={{width: dynamicSize,aspectRatio: "1/3",transform: `translate(${(size - column) * -0.5}px,${(size - column) * 0.5}px)`,zIndex: avatars.length - index,}}tabIndex={0}aria-label={`Avatar of ${person.name}`}onMouseEnter={() => setHoveredIndex(index)}onMouseLeave={() => setHoveredIndex(null)}onFocus={() => setHoveredIndex(index)}onBlur={() => setHoveredIndex(null)}onTouchStart={() => setHoveredIndex(index)}>{ringed && (<divclassName="name absolute left-1/2 text-center uppercase font-mono font-normal pointer-events-none"aria-hidden="true"style={{width: size,height: size,borderRadius: "50%",bottom: 0,transform: `translate(-50%, ${isHovered ? -movement * 100 : 0}%)`,transition: `transform ${transition}s ease-out`,}}>{person.name.split("").map((char, i) => (<spankey={i}className="absolute"style={{offsetPath: "border-box",offsetDistance: `${(offset + i) * 0.75}ch`,offsetAnchor: "50% 130%",transform: isHovered? "translate(0, 0)": "translate(0, 100%)",filter: isHovered? "blur(0px)": blurOnRest? "blur(4px)": "blur(0px)",opacity: isHovered ? 1 : 0,transition: `transform ${transition}s ease-out, opacity ${transition}s ease-out, filter ${transition}s ease-out`,}}>{char}</span>))}</div>)}<div className="avatar-holder absolute inset-0 grid content-end"><motion.spanclassName={cn("avatar inline-block w-full aspect-square rounded-full relative overflow-hidden pointer-events-auto border-[3px] border-background","focus:ring-2 focus:ring-offset-2 focus:ring-foreground")}role="img"aria-label={person.name}style={{maskImage: index === 0 ? "none" : maskImage,WebkitMaskImage: index === 0 ? "none" : maskImage,maskSize: "100% 400%",WebkitMaskSize: "100% 400%",maskRepeat: "no-repeat",}}animate={{maskPosition: index === 0 ? "0 0" : maskPosition,y: isHovered ? -movement * 100 + "%" : "0%",scale: isHovered ? 1.05 : 1,opacity:hoveredIndex !== null && hoveredIndex !== index? 0.7: 1,}}transition={transitionConfig}><imgsrc={person.avatar}alt={person.name}className="absolute inset-0 w-full h-full object-cover bg-muted"/></motion.span></div><div className="absolute bottom-0 w-full aspect-square pointer-events-auto" /></motion.li>)})}</ul></div></div>)}
Props
| Prop Name | Type | Default | Description |
|---|---|---|---|
| avatars | Avatar[] | - | Array of avatar objects with 'name' and 'avatar' (URL) properties. |
| size | number | 70 | Base size of the avatars in pixels. |
| border | number | 8 | Border thickness used for spacing calculations. |
| column | number | 40 | Width and grid-auto-columns value for layout. |
| movement | number | 0.72 | Sensitivity multiplier for the hover lift effect. |
| ringed | boolean | true | Whether to show the text ring on hover. |
| blurOnRest | boolean | true | Whether the text ring should be blurred when not hovered. |