{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"avatar-group","type":"registry:ui","title":"Avatar group","description":"Show a team or set of contributors in a small space.","categories":["data"],"dependencies":["motion"],"registryDependencies":["https://ui.teamofsilicons.com/r/silicon-motion-tokens.json","https://ui.teamofsilicons.com/r/avatar.json"],"files":[{"path":"registry/components/avatar-group/avatar-group.tsx","type":"registry:component","target":"@components/silicon-ui/avatar-group/avatar-group.tsx","content":"\"use client\";\n\nimport { useState, type CSSProperties } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion, type Variants } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { Avatar } from \"../avatar/avatar\";\nimport styles from \"./avatar-group.module.css\";\n\nexport interface AvatarGroupMember { name: string; src?: string; status?: \"online\" | \"offline\"; }\nexport interface AvatarGroupProps { members: AvatarGroupMember[]; max?: number; size?: \"sm\" | \"md\" | \"lg\"; label?: string; }\n\n/** The overflow count rolls the way it moved: more people rise in from below, fewer drop in from above. */\nconst rise: Variants = { hidden: (direction: number) => ({ opacity: 0, y: `${.4 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)` }), shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }, gone: (direction: number) => ({ opacity: 0, y: `${-.4 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }) };\n// Same keys as `rise` so the settled style is identical whichever branch renders on the server.\nconst fade: Variants = { hidden: { opacity: 0, y: 0, filter: \"blur(0px)\" }, shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } } };\n// A person joining or leaving opens or closes their slot, so the rest of the stack slides instead of jumping.\nconst slot = { initial: { width: 0, opacity: 0, scale: .9 }, animate: { width: \"auto\", opacity: 1, scale: 1 }, exit: { width: 0, opacity: 0, scale: .9 } };\n\nexport function AvatarGroup({ members, max = 4, size = \"md\", label = \"Team members\" }: AvatarGroupProps) {\n  const reduceMotion = !!useReducedMotion();\n  const visible = members.slice(0, Math.max(0, max));\n  const overflow = Math.max(0, members.length - visible.length);\n  const transition = reduceMotion ? { duration: 0 } : motionTokens.spring.morph;\n  const [count, setCount] = useState({ overflow, direction: 1 });\n  if (count.overflow !== overflow) setCount({ overflow, direction: overflow < count.overflow ? -1 : 1 });\n  return <div className={[styles.group, styles[size]].join(\" \")} role=\"group\" aria-label={label} style={{ \"--count\": visible.length + (overflow > 0 ? 1 : 0) } as CSSProperties}>\n    <AnimatePresence initial={false}>\n      {visible.map((member, index) => <motion.span key={member.name} className={styles.slot} style={{ \"--index\": index } as CSSProperties} {...slot} transition={transition}><span className={styles.lift}><Avatar className={styles.avatar} name={member.name} src={member.src} status={member.status} size={size} /><span className={styles.tip} aria-hidden=\"true\">{member.name}</span></span></motion.span>)}\n      {overflow > 0 ? <motion.span key=\"overflow\" className={styles.slot} style={{ \"--index\": visible.length } as CSSProperties} {...slot} transition={transition}><span className={[styles.lift, styles.overflow, styles[size]].join(\" \")} role=\"img\" aria-label={`${overflow} more ${label.toLowerCase()}`}><AnimatePresence mode=\"popLayout\" initial={false} custom={count.direction}><motion.span key={overflow} className={styles.count} custom={count.direction} variants={reduceMotion ? fade : rise} initial=\"hidden\" animate=\"shown\" exit=\"gone\" aria-hidden=\"true\">+{overflow}</motion.span></AnimatePresence></span></motion.span> : null}\n    </AnimatePresence>\n  </div>;\n}\n\nexport default AvatarGroup;\n"},{"path":"registry/components/avatar-group/avatar-group.module.css","type":"registry:component","target":"@components/silicon-ui/avatar-group/avatar-group.module.css","content":".group { --avatar-size: 36px; display: inline-flex; height: var(--avatar-size); align-items: center; padding-left: var(--space-related); }\n.group.sm { --avatar-size: 28px; }\n.group.lg { --avatar-size: 48px; }\n/* Each slot is the avatar's visible width, so a slot that opens or closes moves the rest of the stack smoothly. */\n.slot { position: relative; display: inline-flex; flex: 0 0 auto; height: var(--avatar-size); align-items: center; }\n/* One wrapper per person carries every hover move as a single translate, so the stack never changes size. */\n.lift { --fan: 0px; --aside: 0px; --rise: 0px; position: relative; display: inline-flex; margin-left: calc(var(--space-related) * -1); border-radius: var(--avatar-radius); corner-shape: var(--avatar-corner-shape); transform: translate(calc(var(--fan) + var(--aside)), var(--rise)); transition: transform var(--duration-standard) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }\n.avatar, .overflow { border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--border); transition: box-shadow var(--duration-fast) var(--ease-standard); }\n.overflow { position: relative; display: inline-grid; place-items: center; overflow: hidden; border-radius: var(--avatar-radius); corner-shape: var(--avatar-corner-shape); background: var(--surface-muted); color: var(--text-secondary); font-size: var(--text-xs); font-weight: 500; font-variant-numeric: tabular-nums; }\n.count { display: inline-block; }\n.overflow.sm { width: 28px; height: 28px; font-size: var(--text-xs); }\n.overflow.md { width: 36px; height: 36px; }\n.overflow.lg { width: 48px; height: 48px; font-size: var(--text-sm); }\n/* The name floats above the stack, outside the layout, so showing it never moves anything. */\n.tip { position: absolute; z-index: 2; bottom: calc(100% + var(--space-related)); left: 50%; padding: var(--space-tight) var(--space-related); border-radius: var(--radius-full); corner-shape: round; background: var(--foreground); color: var(--background); font-size: var(--text-xs); font-weight: 500; line-height: var(--leading-body); white-space: nowrap; pointer-events: none; opacity: 0; transform: translate(-50%, 3px); transition: opacity var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard); }\n/* Pointing at the stack loosens its overlap from the center; the person under the pointer lifts with a crisp ring and names themselves, and their neighbors ease aside. */\n@media (hover: hover) and (pointer: fine) {\n  .group:hover .lift { --fan: calc((var(--index) - (var(--count) - 1) / 2) * 4px); }\n  .slot:hover { z-index: 1; }\n  .slot:hover .lift { --rise: -2px; }\n  .slot:hover ~ .slot .lift { --aside: 3px; }\n  .slot:has(~ .slot:hover) .lift { --aside: -3px; }\n  .slot:hover .avatar, .slot:hover .overflow { box-shadow: 0 0 0 1px var(--border-strong); }\n  .slot:hover .tip { opacity: 1; transform: translate(-50%, 0); transition-delay: 60ms; }\n}\n/* Reduced motion keeps the stack still; the ring and name still answer the pointer. */\n@media (prefers-reduced-motion: reduce) { .lift { transform: none !important; transition: none; } .tip { transform: translate(-50%, 0); transition: none; } }\n"},{"path":"registry/components/avatar/avatar.tsx","type":"registry:component","target":"@components/silicon-ui/avatar/avatar.tsx","content":"\"use client\";\n\nimport { useLayoutEffect, useRef, useState, type HTMLAttributes } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { SiliconImage } from \"../lib/silicon-provider\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./avatar.module.css\";\nexport interface AvatarProps extends HTMLAttributes<HTMLSpanElement> { name: string; src?: string; /** `xs` (20px) is for dense lists such as space icons in a tree; `sm` 28, `md` 36, `lg` 48, `xl` 88. */ size?: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"; status?: \"online\" | \"offline\" }\nexport function Avatar({ name, src, size = \"md\", status, className, ...props }: AvatarProps) {\n  const initials = name.trim().split(/\\s+/).slice(0, 2).map(part => part[0]?.toUpperCase()).join(\"\");\n  const reduceMotion = !!useReducedMotion();\n  const image = useRef<HTMLImageElement>(null);\n  const [failedSrc, setFailedSrc] = useState<string>();\n  // A photo that is already decoded shows at once. One that is still loading waits, then fades in from a soft blur.\n  useLayoutEffect(() => { const node = image.current; if (node && !node.complete) node.dataset.loading = \"\"; }, [src]);\n  const showImage = src && failedSrc !== src;\n  return <span {...props} className={[styles.avatar, styles[size], className].filter(Boolean).join(\" \")} role=\"img\" aria-label={`${name}${status ? `, ${status}` : \"\"}`}>\n    {showImage ? <SiliconImage key={src} ref={image} src={src} alt=\"\" fill sizes={size === \"xl\" ? \"88px\" : size === \"lg\" ? \"48px\" : size === \"md\" ? \"36px\" : size === \"sm\" ? \"28px\" : \"20px\"} onLoad={event => { delete event.currentTarget.dataset.loading; }} onError={() => setFailedSrc(src)} /> : <span className={src ? styles.fallback : undefined} aria-hidden=\"true\">{initials}</span>}\n    <AnimatePresence initial={false}>{status && <motion.i key={status} className={[styles.status, styles[status]].join(\" \")} aria-hidden=\"true\" initial={{ opacity: 0, scale: .6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: .6, transition: { duration: reduceMotion ? 0 : motionTokens.duration.fast } }} transition={reduceMotion ? { duration: 0 } : motionTokens.spring.snappy} />}</AnimatePresence>\n  </span>;\n}\n"},{"path":"registry/components/avatar/avatar.module.css","type":"registry:component","target":"@components/silicon-ui/avatar/avatar.module.css","content":".avatar {\n  position: relative;\n  display: inline-grid;\n  flex: 0 0 auto;\n  place-items: center;\n  overflow: visible;\n  border: 1px solid var(--border);\n  border-radius: var(--avatar-radius); corner-shape: var(--avatar-corner-shape);\n  background: var(--surface-muted);\n  color: var(--foreground);\n  font-size: var(--text-xs);\n  font-weight: 500;\n}\n\n.avatar img {\n  pointer-events: none;\n  border-radius: inherit; corner-shape: inherit;\n  object-fit: cover;\n  transition: opacity var(--duration-standard) var(--ease-enter), filter var(--duration-standard) var(--ease-enter);\n}\n\n/* Set only while a photo is still downloading after mount, so cached photos never fade. */\n.avatar img[data-loading] {\n  opacity: 0;\n  filter: blur(4px);\n  transition: none;\n}\n\n.fallback { animation: avatar-fallback-in var(--duration-standard) var(--ease-enter) both; }\n\n/* Initials below the smallest type step, so two letters fit 20px. */\n.xs { width: 20px; height: 20px; font-size: calc(var(--text-xs) * .8); }\n.sm { width: 28px; height: 28px; font-size: var(--text-xs); }\n.md { width: 36px; height: 36px; }\n.lg { width: 48px; height: 48px; font-size: var(--text-sm); }\n.xl { width: 88px; height: 88px; font-size: var(--text-xl); }\n\n.status {\n  position: absolute;\n  right: 0;\n  bottom: 0;\n  z-index: 2;\n  width: 10px;\n  height: 10px;\n  border: 2px solid var(--surface);\n  border-radius: var(--radius-full); corner-shape: round;\n  box-shadow: 0 0 0 1px color-mix(in oklab, var(--border) 70%, transparent);\n}\n\n.xs .status { width: 6px; height: 6px; border-width: 1px; }\n.sm .status { width: 8px; height: 8px; border-width: 1.5px; }\n.lg .status { width: 12px; height: 12px; }\n.xl .status { width: 14px; height: 14px; right: var(--space-tight); bottom: var(--space-tight); }\n.online { background: var(--success); }\n.offline { background: var(--text-muted); }\n\n@keyframes avatar-fallback-in { from { opacity: 0; } to { opacity: 1; } }\n\n@media (prefers-reduced-motion: reduce) {\n  .avatar img { transition: none; }\n  .avatar img[data-loading] { filter: none; }\n  .fallback { animation: none; }\n}\n"},{"path":"lib/silicon-provider.tsx","type":"registry:lib","target":"@components/silicon-ui/lib/silicon-provider.tsx","content":"\"use client\";\n\n/**\n * Lets an app swap the link and image elements Silicon UI components render.\n *\n * Without a provider, Silicon UI renders a plain `<a>` and `<img>`, so components work in any React app (Vite, Remix, Astro,\n * plain React). Wrap the app once to use your framework's or your own components instead:\n *\n *   import Link from \"next/link\";\n *   import Image from \"next/image\";\n *   import { SiliconProvider } from \"@/components/silicon-ui/lib/silicon-provider\";\n *\n *   <SiliconProvider link={Link} image={Image}>{children}</SiliconProvider>\n *\n * Any component that accepts these props works, such as a locale-aware Link from next-intl or a React Router Link\n * wrapped to map `href` to `to`.\n */\nimport { createContext, createElement, useContext, useMemo, type AnchorHTMLAttributes, type ComponentType, type ImgHTMLAttributes, type ReactNode, type Ref } from \"react\";\n\n/** The props Silicon UI passes to a link: an href plus ordinary anchor attributes. */\nexport interface SiliconLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {\n  href: string;\n  ref?: Ref<HTMLAnchorElement>;\n}\n\n/**\n * The props Silicon UI passes to an image. A subset of next/image's props, so next/image can be passed as is: either `fill`\n * (cover the positioned parent) or `width` and `height`, plus `sizes` and `priority` as hints.\n */\nexport interface SiliconImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, \"src\" | \"alt\" | \"width\" | \"height\" | \"loading\" | \"srcSet\"> {\n  src: string;\n  alt: string;\n  width?: number;\n  height?: number;\n  /** Fills the nearest positioned parent, like next/image's `fill`. */\n  fill?: boolean;\n  /** Loads the image eagerly with high priority, like next/image's `priority`. */\n  priority?: boolean;\n  loading?: \"eager\" | \"lazy\";\n  ref?: Ref<HTMLImageElement>;\n}\n\nexport type SiliconLinkComponent = ComponentType<SiliconLinkProps>;\nexport type SiliconImageComponent = ComponentType<SiliconImageProps>;\n\n/** The default link: a plain anchor. */\nexport function SiliconAnchor(props: SiliconLinkProps) {\n  return <a {...props} />;\n}\n\nconst fillStyle = { position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\" } as const;\n\n/** The default image: a plain, lazily decoded `<img>` that understands `fill` and `priority`. */\nexport function SiliconImg({ alt, fill, priority, loading, style, ...props }: SiliconImageProps) {\n  // eslint-disable-next-line @next/next/no-img-element -- the framework-free default; pass next/image through SiliconProvider instead.\n  return <img alt={alt} decoding=\"async\" {...props} loading={loading ?? (priority ? \"eager\" : \"lazy\")} fetchPriority={priority ? \"high\" : props.fetchPriority} style={fill ? { ...fillStyle, ...style } : style} />;\n}\n\ninterface SiliconComponents {\n  link: SiliconLinkComponent;\n  image: SiliconImageComponent;\n}\n\nconst SiliconContext = createContext<SiliconComponents>({ link: SiliconAnchor, image: SiliconImg });\n\nexport interface SiliconProviderProps {\n  /** The link component Silicon UI renders for navigation, such as next/link or your own wrapped Link. Defaults to `<a>`. */\n  link?: SiliconLinkComponent;\n  /** The image component Silicon UI renders for photos, such as next/image. Defaults to `<img>`. */\n  image?: SiliconImageComponent;\n  children: ReactNode;\n}\n\n/** Sets the link and image components for every Silicon UI component inside it. Nested providers inherit what they do not set. */\nexport function SiliconProvider({ link, image, children }: SiliconProviderProps) {\n  const parent = useContext(SiliconContext);\n  const value = useMemo(() => ({ link: link ?? parent.link, image: image ?? parent.image }), [link, image, parent]);\n  return <SiliconContext.Provider value={value}>{children}</SiliconContext.Provider>;\n}\n\n/** The link component from the nearest SiliconProvider, or a plain anchor. */\nexport const useArcLink = () => useContext(SiliconContext).link;\n/** The image component from the nearest SiliconProvider, or a plain img. */\nexport const useArcImage = () => useContext(SiliconContext).image;\n\n/** What Silicon UI components render for a link: the provider's link component, or a plain anchor. */\nexport function SiliconLink(props: SiliconLinkProps) {\n  return createElement(useArcLink(), props);\n}\n\n/** What Silicon UI components render for an image: the provider's image component, or a plain img. */\nexport function SiliconImage(props: SiliconImageProps) {\n  return createElement(useArcImage(), props);\n}\n"}],"docs":"Docs and live preview: https://ui.teamofsilicons.com/avatar-group","meta":{"tier":"free","kind":"component","docs":"https://ui.teamofsilicons.com/avatar-group","markdown":"https://ui.teamofsilicons.com/avatar-group.md","tags":["identity","people"],"status":"stable"}}