{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "avatar-showcase",
  "type": "registry:ui",
  "title": "Avatar Showcase",
  "description": "A recording-friendly avatar strip with count-aware pacing, deterministic sampling, staggered lanes, and reduced-motion support.",
  "files": [
    {
      "path": "registry/sonaui/avatar-showcase/avatar-showcase.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\nimport { cn } from \"@/lib/sona-utils\";\n\nexport interface AvatarShowcaseItem {\n  /** Stable identifier used to preserve the supplied item order. */\n  id: string;\n  /** Accessible name for the person or profile. */\n  name: string;\n  /** Avatar image URL. */\n  imageUrl: string;\n}\n\nexport interface AvatarShowcaseProps extends React.HTMLAttributes<HTMLElement> {\n  /** Ordered people to present. The first item is treated as the most recent. */\n  items: AvatarShowcaseItem[];\n  /** Number of vertically staggered lanes used by the moving strip.\n   * @default 1\n   */\n  lanes?: 1 | 2 | 3;\n  /** Total community size when `items` contains only a recent subset.\n   * @default items.length\n   */\n  totalCount?: number;\n  /** Optional message revealed with the final count. Omit it for a count-only ending.\n   * @default undefined\n   */\n  message?: string;\n  /** Maximum number of avatars rendered before deterministic sampling is applied.\n   * @default 80\n   */\n  maxItems?: number;\n  /** Fixed playback duration in seconds. When omitted, duration is derived from the total count.\n   * @default undefined\n   */\n  duration?: number;\n  /** Avatar diameter in pixels.\n   * @default 56\n   */\n  avatarSize?: number;\n  /** Called after the strip finishes and the ending is revealed.\n   * @default undefined\n   */\n  onComplete?: () => void;\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, value));\n}\n\nfunction sampleItems(items: AvatarShowcaseItem[], limit: number) {\n  if (items.length <= limit) return items;\n\n  const recentCount = Math.min(12, Math.ceil(limit / 3));\n  const recentItems = items.slice(0, recentCount);\n  const remainingSlots = limit - recentCount;\n  const remainingItems = items.slice(recentCount);\n\n  const sampledItems = Array.from({ length: remainingSlots }, (_, index) => {\n    const sampleIndex = Math.floor(\n      (index * (remainingItems.length - 1)) / Math.max(1, remainingSlots - 1),\n    );\n    return remainingItems[sampleIndex];\n  });\n\n  return [...recentItems, ...sampledItems];\n}\n\nfunction getInitials(name: string) {\n  return (\n    name\n      .trim()\n      .split(/\\s+/)\n      .slice(0, 2)\n      .map((part) => part[0])\n      .join(\"\")\n      .toUpperCase() || \"?\"\n  );\n}\n\nfunction ShowcaseAvatar({\n  item,\n  size,\n}: {\n  item: AvatarShowcaseItem;\n  size: number;\n}) {\n  const [hasImageError, setHasImageError] = useState(false);\n\n  return (\n    <div\n      className=\"relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-muted font-medium text-muted-foreground shadow-[0_0_0_1px_color-mix(in_oklab,var(--border)_80%,transparent),0_8px_24px_-12px_color-mix(in_oklab,var(--foreground)_24%,transparent)]\"\n      style={{ width: size, height: size }}\n      title={item.name}\n    >\n      {hasImageError ? (\n        <span\n          aria-hidden=\"true\"\n          style={{ fontSize: Math.max(11, size * 0.28) }}\n        >\n          {getInitials(item.name)}\n        </span>\n      ) : (\n        // biome-ignore lint/performance/noImgElement: registry consumers may provide any remote image source\n        <img\n          alt=\"\"\n          className=\"size-full object-cover\"\n          draggable={false}\n          loading=\"eager\"\n          onError={() => setHasImageError(true)}\n          src={item.imageUrl}\n        />\n      )}\n    </div>\n  );\n}\n\nexport default function AvatarShowcase({\n  items,\n  lanes = 1,\n  totalCount = items.length,\n  message,\n  maxItems = 80,\n  duration,\n  avatarSize = 56,\n  className,\n  onComplete,\n  ...props\n}: AvatarShowcaseProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const containerRef = useRef<HTMLElement>(null);\n  const trackRef = useRef<HTMLDivElement>(null);\n  const [measurement, setMeasurement] = useState({ container: 0, track: 0 });\n  const [isComplete, setIsComplete] = useState(false);\n\n  const safeLaneCount = clamp(Math.round(lanes), 1, 3);\n  const safeAvatarSize = clamp(avatarSize, 32, 96);\n  const safeLimit = Math.max(1, Math.floor(maxItems));\n  const visibleItems = useMemo(\n    () => sampleItems(items, safeLimit),\n    [items, safeLimit],\n  );\n  const effectiveLanes = Math.min(safeLaneCount, visibleItems.length || 1);\n  const laneOffset = safeAvatarSize * 0.58;\n  const containerHeight = safeAvatarSize + laneOffset * (effectiveLanes - 1);\n  const measurementKey = `${visibleItems.map((item) => item.id).join(\":\")}-${effectiveLanes}-${safeAvatarSize}`;\n  const resolvedCount = Math.max(totalCount, items.length);\n  const resolvedDuration =\n    duration ?? clamp(8 + Math.log10(Math.max(1, resolvedCount)) * 1.2, 8, 12);\n\n  useEffect(() => {\n    // Reconnect the observer when the rendered strip geometry changes.\n    void measurementKey;\n    const container = containerRef.current;\n    const track = trackRef.current;\n    if (!container || !track) return;\n\n    const measure = () => {\n      const nextMeasurement = {\n        container: container.clientWidth,\n        track: track.scrollWidth,\n      };\n      // Removing the completed track reports a transient zero width. Ignoring\n      // that observation keeps the final count visible instead of restarting.\n      if (nextMeasurement.container === 0 || nextMeasurement.track === 0)\n        return;\n      setMeasurement(nextMeasurement);\n      setIsComplete(false);\n    };\n\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(container);\n    observer.observe(track);\n    return () => observer.disconnect();\n  }, [measurementKey]);\n\n  if (visibleItems.length === 0) {\n    return (\n      <section\n        aria-label=\"No people to showcase yet\"\n        className={cn(\n          \"grid min-h-28 place-items-center overflow-hidden rounded-xl border border-border bg-background px-6 text-center text-muted-foreground text-sm\",\n          className,\n        )}\n        {...props}\n      >\n        No people to showcase yet.\n      </section>\n    );\n  }\n\n  const summary = `Showing ${visibleItems.length.toLocaleString()} of ${resolvedCount.toLocaleString()} people.`;\n  const staticItems = visibleItems.slice(0, Math.min(8, visibleItems.length));\n  const canAnimate =\n    !shouldReduceMotion && measurement.container > 0 && measurement.track > 0;\n\n  return (\n    <section\n      ref={containerRef}\n      aria-label={summary}\n      className={cn(\n        \"relative w-full overflow-hidden rounded-xl border border-border bg-background px-4 py-8\",\n        className,\n      )}\n      {...props}\n    >\n      <span className=\"sr-only\">{summary}</span>\n\n      {shouldReduceMotion ? (\n        <div\n          className=\"flex items-center justify-center -space-x-3\"\n          aria-hidden=\"true\"\n        >\n          {staticItems.map((item) => (\n            <ShowcaseAvatar item={item} key={item.id} size={safeAvatarSize} />\n          ))}\n        </div>\n      ) : (\n        <AnimatePresence mode=\"wait\">\n          {!isComplete ? (\n            <motion.div\n              aria-hidden=\"true\"\n              animate={\n                canAnimate\n                  ? { x: -measurement.track - safeAvatarSize }\n                  : { x: 0 }\n              }\n              className=\"flex w-max items-start gap-3 will-change-transform\"\n              initial={{ x: measurement.container + safeAvatarSize }}\n              key={`${measurement.container}-${measurement.track}-${measurementKey}`}\n              onAnimationComplete={() => {\n                if (!canAnimate) return;\n                setIsComplete(true);\n                onComplete?.();\n              }}\n              ref={trackRef}\n              style={{ height: containerHeight }}\n              transition={{ duration: resolvedDuration, ease: \"linear\" }}\n            >\n              {visibleItems.map((item, index) => (\n                <div\n                  key={item.id}\n                  style={{\n                    transform: `translateY(${(index % effectiveLanes) * laneOffset}px)`,\n                  }}\n                >\n                  <ShowcaseAvatar item={item} size={safeAvatarSize} />\n                </div>\n              ))}\n            </motion.div>\n          ) : (\n            <motion.div\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              className=\"grid min-h-20 place-items-center text-center\"\n              initial={{ opacity: 0, y: 8, filter: \"blur(4px)\" }}\n              key=\"summary\"\n              transition={{ type: \"spring\", duration: 0.45, bounce: 0 }}\n            >\n              <div>\n                <p className=\"font-semibold tabular-nums text-2xl text-foreground\">\n                  {resolvedCount.toLocaleString()}\n                </p>\n                {message ? (\n                  <p className=\"mt-1 text-muted-foreground text-sm\">\n                    {message}\n                  </p>\n                ) : null}\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      )}\n    </section>\n  );\n}\n",
      "target": "components/ui/avatar-showcase/avatar-showcase.tsx"
    }
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "@sona-ui/sona-utils"
  ]
}