{
  "name": "avatar-default",
  "type": "registry:block",
  "tier": "free",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.craftui.space/r/craftui-base.json"
  ],
  "cssVars": {
    "light": {
      "avatar-surface": "var(--background, oklch(1 0 0))",
      "avatar-fg": "var(--foreground, oklch(0.145 0 0))",
      "avatar-muted": "var(--muted, oklch(0.97 0 0))",
      "avatar-border": "var(--border, oklch(0.922 0 0))",
      "avatar-ring": "var(--ring, oklch(0.708 0 0))"
    },
    "dark": {
      "avatar-surface": "var(--background, oklch(0.145 0 0))",
      "avatar-fg": "var(--foreground, oklch(0.985 0 0))",
      "avatar-muted": "var(--muted, oklch(0.269 0 0))",
      "avatar-border": "var(--border, oklch(1 0 0 / 10%))",
      "avatar-ring": "var(--ring, oklch(0.556 0 0))"
    }
  },
  "files": [
    {
      "path": "components/craftui/blocks/avatar/default.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\n// ── Types ─────────────────────────────────────────────────────────────────────\n\nexport type AvatarMember = {\n  id: string;\n  name: string;\n  /** Role, read out as part of the tile's accessible name. */\n  role: string;\n  src: string;\n  /** 1-indexed [row, column] slot in the grid. */\n  cell: [number, number];\n};\n\nexport interface AvatarGridProps {\n  members?: AvatarMember[];\n  /** Node rendered in the centre tile — a brand mark by default. */\n  logo?: React.ReactNode;\n  /** 1-indexed [row, column] of the centre tile. */\n  logoCell?: [number, number];\n  /** Grid size, in cells. */\n  rows?: number;\n  columns?: number;\n  /**\n   * CSS mask applied to the placeholder layer — a circular fade by default.\n   * Pass `false` to keep every shell at full strength.\n   */\n  mask?: string | false;\n  /**\n   * Arbitrary tiles dropped into named cells — a \"we're hiring\" slot, say.\n   * They occupy their cell, so no placeholder shell is drawn underneath.\n   */\n  extraTiles?: { cell: [number, number]; content: React.ReactNode }[];\n  /**\n   * Renders the active member's role as a pointer badge beside their tile.\n   * Off by default: standalone, the cluster is a picture of a team and the\n   * roles live in each tile's accessible name.\n   */\n  roleBadges?: boolean;\n  /**\n   * Custom badge contents. Defaults to the member's role; pass this to put a\n   * name, a bio, anything the caller wants inside the same pill.\n   */\n  renderBadge?: (member: AvatarMember) => React.ReactNode;\n  /** Member selected before anyone interacts. Null leaves the cluster at rest. */\n  defaultSelectedId?: string | null;\n  /**\n   * Controlled selection. Pass it and the caller owns which member is picked —\n   * the grid stops keeping its own copy, so a list outside the grid and the\n   * grid itself can't drift apart. Hover still previews on top of it.\n   */\n  selectedId?: string | null;\n  /** Fires when a tile is clicked. Required to make `selectedId` do anything. */\n  onSelect?: (id: string) => void;\n  /**\n   * Fires whenever the active member changes — hover preview or committed\n   * click. Lets a caller render name, role and bio outside the grid without\n   * the grid having to know anything about how they are laid out.\n   */\n  onActiveChange?: (id: string | null) => void;\n  className?: string;\n}\n\n// ── Default content ──────────────────────────────────────────────────────────\n\nconst DEFAULT_MEMBERS: AvatarMember[] = [\n  {\n    id: \"maya\",\n    name: \"Maya Ellis\",\n    role: \"Admin\",\n    src: \"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=300&auto=format&fit=crop&q=80\",\n    cell: [2, 2],\n  },\n  {\n    id: \"noor\",\n    name: \"Noor Haddad\",\n    role: \"Designer\",\n    src: \"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=300&auto=format&fit=crop&q=80\",\n    cell: [2, 3],\n  },\n  {\n    id: \"tom\",\n    name: \"Tom Brandt\",\n    role: \"Engineer\",\n    src: \"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=300&auto=format&fit=crop&q=80\",\n    cell: [3, 2],\n  },\n  {\n    id: \"amir\",\n    name: \"Amir Rahal\",\n    role: \"Support Lead\",\n    src: \"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=300&auto=format&fit=crop&q=80\",\n    cell: [3, 4],\n  },\n  {\n    id: \"dev\",\n    name: \"Dev Sharma\",\n    role: \"Developer\",\n    src: \"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=300&auto=format&fit=crop&q=80\",\n    cell: [4, 3],\n  },\n  {\n    id: \"lena\",\n    name: \"Lena Fischer\",\n    role: \"Content Reviewer\",\n    src: \"https://images.unsplash.com/photo-1531123897727-8f129e1688ce?w=300&auto=format&fit=crop&q=80\",\n    cell: [4, 4],\n  },\n];\n\n/**\n * The mark's ink spans 12.5 units of the 24-unit viewBox, so the original\n * x-offsets left it optically shifted toward the left edge of its tile. Fixed\n * in the path itself rather than with a nudge in the component, so the icon\n * stays centred wherever it is used.\n */\nconst DEFAULT_LOGO = (\n  <svg viewBox=\"0 0 24 24\" className=\"size-1/2\" aria-hidden=\"true\">\n    <path\n      d=\"M5.75 4h5v5h-5zM5.75 15h5v5h-5zM13.25 9.5h5v5h-5z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\n\n/**\n * Shared so the placeholder layer and the avatar grid never drift apart. Steps\n * down on small screens: five columns of gutter is the one place where a phone\n * can win a few pixels per tile back without changing the layout.\n */\nconst GRID_GAP = \"gap-1.5 sm:gap-2 md:gap-3\";\n\n// ── Motion ────────────────────────────────────────────────────────────────────\n\n/** Hover lift and press. Bounce 0 — overshoot would read as toy-like here. */\nconst SPRING = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n\n/** ease-out-quart. Strong enough that 250ms still lands as \"instant\". */\nconst EASE_ENTRANCE = [0.165, 0.84, 0.44, 1] as const;\n/** ease-out-quad. Fades and small badge travel — a fade should read as a fade. */\nconst EASE_FADE = [0.25, 0.46, 0.45, 0.94] as const;\nconst ENTRANCE_DURATION = 0.25;\n/** Gap between rings. Below ~40ms the wave stops reading; above ~80ms it drags. */\nconst RING_STAGGER = 0.06;\n\n/**\n * How far the unselected faces step back once someone is active. 0.5 is enough\n * to make the active face the only thing the eye lands on; below ~0.4 the rest\n * of the team stops reading as present at all, which is the wrong message for\n * a group portrait.\n */\nconst DIM_OPACITY = 0.5;\n\n/**\n * Circular fade: the shells nearest the cluster read at full strength, then\n * fall away to nothing before the grid's corners — which is what keeps the\n * block from looking like it was cropped out of a larger board.\n */\nconst DEFAULT_MASK =\n  \"radial-gradient(circle at 50% 50%, oklch(0 0 0) 32%, oklch(0 0 0 / 0.55) 52%, oklch(0 0 0 / 0.15) 68%, transparent 80%)\";\n\n// ── Empty tiles ───────────────────────────────────────────────────────────────\n\n/**\n * The empty slots are the backdrop, not content — they are generated from the\n * grid dimensions rather than listed, so changing `rows`/`columns` fills the\n * board without touching any data.\n */\nfunction EmptyTile({ cell }: { cell: [number, number] }) {\n  return (\n    <div\n      // Placement lives on the tile itself, not on a wrapper — a wrapper\n      // stretches to the grid track but the tile inside it would keep auto\n      // height and collapse to a single dashed line.\n      // The hatch colour has to flip with the theme, and a gradient can't be\n      // written as a `dark:` utility — so the ink is a variable the `dark:`\n      // variant reassigns, and the gradient itself stays theme-agnostic. White\n      // at 4.5% would vanish on near-black, so dark mode gets more than a\n      // straight inversion. The outline is `--avatar-border`, which already\n      // carries its own per-theme value.\n      className=\"size-full rounded-[26%] border border-dashed border-[var(--avatar-border)] [--hatch-ink:oklch(0_0_0_/_0.045)] dark:[--hatch-ink:oklch(1_0_0_/_0.1)]\"\n      style={{\n        gridRow: cell[0],\n        gridColumn: cell[1],\n        backgroundImage:\n          \"repeating-linear-gradient(45deg, var(--hatch-ink) 0 1px, transparent 1px 6px)\",\n      }}\n    />\n  );\n}\n\n// ── Role badge ────────────────────────────────────────────────────────────────\n\n/**\n * The active member's role, on a pointer pill anchored to their tile. It sits\n * outside the tile and is `pointer-events-none`, so it can never intercept the\n * hover that summoned it — a badge that eats its own trigger flickers forever.\n *\n * Entering, so ease-out; the arrow and pill share one transform, because two\n * halves of one badge arriving on different timings reads as a glitch.\n */\nfunction RoleBadge({\n  member,\n  renderBadge,\n  side,\n  reduceMotion,\n}: {\n  member: AvatarMember;\n  renderBadge?: (member: AvatarMember) => React.ReactNode;\n  side: \"left\" | \"right\";\n  reduceMotion: boolean | null;\n}) {\n  const content = renderBadge ? renderBadge(member) : member.role;\n  const isLeft = side === \"left\";\n  const offset = isLeft ? 8 : -8;\n  return (\n    <motion.div\n      initial={\n        reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.9, x: offset }\n      }\n      animate={\n        reduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1, x: 0 }\n      }\n      exit={\n        reduceMotion\n          ? { opacity: 0 }\n          : { opacity: 0, scale: 0.92, x: offset * 0.75 }\n      }\n      transition={{ duration: 0.18, ease: EASE_FADE }}\n      // Hook for callers that want the badge at some breakpoints only — a\n      // section that renders roles in its own copy column can hide these with\n      // one CSS rule instead of a resize listener and a re-render.\n      data-role-badge=\"\"\n      className={`pointer-events-none absolute top-1/2 z-20 -translate-y-1/2 ${\n        isLeft\n          ? \"right-[88%] origin-right\"\n          : \"left-[88%] origin-left\"\n      }`}\n    >\n      <div className=\"relative\">\n        {/* Cursor arrow, tucked into the corner nearest the tile it points at */}\n        <svg\n          viewBox=\"0 0 12 14\"\n          className={`absolute -top-2 size-2.5 text-[var(--avatar-fg)] sm:size-3 ${\n            isLeft ? \"-right-1 scale-x-[-1]\" : \"-left-1\"\n          }`}\n          aria-hidden=\"true\"\n        >\n          <path d=\"M0 0l12 5.5-5 1.5L4.5 14z\" fill=\"currentColor\" />\n        </svg>\n        <span className=\"block rounded-full bg-[var(--avatar-fg)] px-2.5 py-1 text-[10px] leading-none font-medium whitespace-nowrap text-[var(--avatar-surface)] shadow-sm sm:px-3.5 sm:py-1.5 sm:text-xs\">\n          {content}\n        </span>\n      </div>\n    </motion.div>\n  );\n}\n\n// ── Root ──────────────────────────────────────────────────────────────────────\n\nexport default function AvatarGrid({\n  members = DEFAULT_MEMBERS,\n  logo = DEFAULT_LOGO,\n  logoCell = [3, 3],\n  rows = 5,\n  columns = 5,\n  mask = DEFAULT_MASK,\n  extraTiles = [],\n  roleBadges = false,\n  renderBadge,\n  defaultSelectedId = null,\n  selectedId: selectedIdProp,\n  onSelect,\n  onActiveChange,\n  className,\n}: AvatarGridProps = {}) {\n  const scopeId = React.useId().replace(/:/g, \"\");\n  const reduceMotion = useReducedMotion();\n  // Split state, the same way contact-reveal-card does it: hover is a preview,\n  // click commits. Without the committed half there is no way to pick a member\n  // on a touch device, where hover doesn't exist.\n  const [uncontrolledSelectedId, setUncontrolledSelectedId] = React.useState<\n    string | null\n  >(defaultSelectedId);\n  // Controlled when the prop is passed at all — including as null, which is a\n  // caller saying \"nothing is selected\", not \"you decide\".\n  const isControlled = selectedIdProp !== undefined;\n  const selectedId = isControlled ? selectedIdProp : uncontrolledSelectedId;\n  const [hoveredId, setHoveredId] = React.useState<string | null>(null);\n  const activeId = hoveredId ?? selectedId;\n\n  const select = (id: string) => {\n    if (!isControlled) setUncontrolledSelectedId(id);\n    onSelect?.(id);\n  };\n\n  // Held in a ref, and kept current in its own effect rather than during\n  // render, so a caller passing an inline arrow function doesn't re-fire the\n  // callback on every render.\n  const onActiveChangeRef = React.useRef(onActiveChange);\n  React.useEffect(() => {\n    onActiveChangeRef.current = onActiveChange;\n  });\n  React.useEffect(() => {\n    onActiveChangeRef.current?.(activeId);\n  }, [activeId]);\n\n  /**\n   * Reduced motion keeps the fade and drops the scale — the entrance still\n   * explains that the cluster is built outward from the mark, it just stops\n   * moving to say so. Same for the hover lift and the press, handled below.\n   */\n  const enter = {\n    initial: reduceMotion\n      ? { opacity: 0 }\n      : // 0.95, never 0 — nothing appears from nothing.\n        { opacity: 0, scale: 0.95 },\n    animate: reduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 },\n    transition: (rank: number) => ({\n      duration: ENTRANCE_DURATION,\n      ease: EASE_ENTRANCE,\n      delay: rank * RING_STAGGER,\n    }),\n  };\n\n  /**\n   * Tiles are grouped into rings by their distance from the mark, so members\n   * equidistant from the centre arrive together and the entrance reads as a\n   * wave outward rather than as six unrelated fades. Ranking by list order\n   * instead would stagger by nothing the eye can see.\n   */\n  const ringRank = React.useMemo(() => {\n    const distance = (cell: [number, number]) =>\n      Math.round(\n        Math.hypot(cell[0] - logoCell[0], cell[1] - logoCell[1]) * 100,\n      ) / 100;\n    const rings = Array.from(\n      new Set(members.map((member) => distance(member.cell))),\n    ).sort((a, b) => a - b);\n    return new Map(\n      members.map((member) => [\n        member.id,\n        // +1 leaves rank 0 to the brand mark at the centre.\n        rings.indexOf(distance(member.cell)) + 1,\n      ]),\n    );\n  }, [members, logoCell]);\n\n  // Both layers must resolve to identical tracks or the shells drift off the\n  // grid the avatars sit on.\n  const gridTemplate: React.CSSProperties = {\n    gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,\n    gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,\n  };\n\n  const occupied = React.useMemo(() => {\n    const taken = new Set<string>([`${logoCell[0]}-${logoCell[1]}`]);\n    for (const member of members) taken.add(`${member.cell[0]}-${member.cell[1]}`);\n    for (const tile of extraTiles) taken.add(`${tile.cell[0]}-${tile.cell[1]}`);\n    return taken;\n  }, [members, logoCell, extraTiles]);\n\n  const emptyCells = React.useMemo(() => {\n    const cells: Array<[number, number]> = [];\n    for (let row = 1; row <= rows; row++) {\n      for (let column = 1; column <= columns; column++) {\n        if (!occupied.has(`${row}-${column}`)) cells.push([row, column]);\n      }\n    }\n    return cells;\n  }, [rows, columns, occupied]);\n\n  return (\n    <div\n      className={`flex h-full w-full items-center justify-center bg-[var(--avatar-surface)] p-6 ${className ?? \"\"}`}\n    >\n      <div\n        className={`relative grid aspect-square w-full max-w-[460px] ${GRID_GAP}`}\n        style={gridTemplate}\n        onMouseLeave={() => setHoveredId(null)}\n      >\n        {/*\n          Depth is a layered box-shadow, not a border: the ring layer reads as\n          the 1px edge while the two soft layers do the lift, and because every\n          layer is transparent it holds up over any background the block is\n          dropped onto. Dark mode collapses to a single white ring — soft black\n          depth layers are invisible on a dark surface. Written as a scoped\n          style block because a three-layer shadow with commas doesn't survive\n          Tailwind's arbitrary-value syntax cleanly.\n        */}\n        <style>\n          {`\n            .tile-${scopeId} {\n              box-shadow:\n                0px 0px 0px 1px oklch(0 0 0 / 0.06),\n                0px 1px 2px -1px oklch(0 0 0 / 0.06),\n                0px 2px 4px 0px oklch(0 0 0 / 0.04);\n            }\n            .dark .tile-${scopeId} {\n              box-shadow: 0 0 0 1px oklch(1 0 0 / 0.08);\n            }\n            /* Gated on a real pointer: touch fires :hover on tap and then has\n               no way to un-fire it, which would leave a tapped tile stuck in\n               its hover shadow. */\n            @media (hover: hover) and (pointer: fine) {\n              .tile-${scopeId}:hover {\n                box-shadow:\n                  0px 0px 0px 1px oklch(0 0 0 / 0.08),\n                  0px 1px 2px -1px oklch(0 0 0 / 0.08),\n                  0px 2px 4px 0px oklch(0 0 0 / 0.06);\n              }\n              .dark .tile-${scopeId}:hover {\n                box-shadow: 0 0 0 1px oklch(1 0 0 / 0.13);\n              }\n            }\n\n            /*\n              The brand tile's edge — the one tile with no photo of its own to\n              define it, so it gets a conic gradient masked down to a 1px band\n              instead of the flat outline the avatars use. The two masks are\n              the border box and the content box; compositing them with exclude\n              erases the middle and leaves only the ring, with no wrapper and\n              no concentric-radius math.\n\n              'from 45deg' puts gradient angle 0 on the top-right corner, so\n              the stops at 0/90/180/270 land exactly on the four corners and\n              alternate light, dark, light, dark around them. Rotate that one\n              value to move which corner catches the highlight.\n            */\n            .logo-edge-${scopeId} {\n              position: absolute;\n              inset: 0;\n              box-sizing: border-box;\n              padding: 1px;\n              border-radius: 26%;\n              pointer-events: none;\n              background: conic-gradient(\n                from 45deg,\n                var(--edge-a) 0deg,\n                var(--edge-b) 90deg,\n                var(--edge-a) 180deg,\n                var(--edge-b) 270deg,\n                var(--edge-a) 360deg\n              );\n              -webkit-mask:\n                linear-gradient(#000 0 0) content-box,\n                linear-gradient(#000 0 0);\n              -webkit-mask-composite: xor;\n              mask:\n                linear-gradient(#000 0 0) content-box,\n                linear-gradient(#000 0 0);\n              mask-composite: exclude;\n              /* Tuned against the tile's own muted fill, not against the\n                 page: a 55% white would be swallowed by the grey. */\n              --edge-a: oklch(1 0 0 / 0.95);\n              --edge-b: oklch(0 0 0 / 0.28);\n            }\n            /* The tile sits on a dark muted fill in dark mode, where a black ink is\n               invisible — so both inks go white and the corner alternation\n               comes from alpha rather than from hue. */\n            .dark .logo-edge-${scopeId} {\n              --edge-a: oklch(1 0 0 / 0.38);\n              --edge-b: oklch(1 0 0 / 0.07);\n            }\n          `}\n        </style>\n\n        {/*\n          Placeholder shells live on their own layer so the radial mask can fade\n          them out towards the edges without touching the avatars stacked above\n          — masking the whole grid would dim the outer cards with them. The\n          layer mirrors the parent's tracks and gap exactly, so the shells stay\n          on the same grid they would have occupied inline.\n        */}\n        <div\n          aria-hidden=\"true\"\n          className={`pointer-events-none absolute inset-0 grid ${GRID_GAP}`}\n          style={{\n            ...gridTemplate,\n            maskImage: mask || undefined,\n            WebkitMaskImage: mask || undefined,\n          }}\n        >\n          {emptyCells.map(([row, column]) => (\n            <EmptyTile key={`empty-${row}-${column}`} cell={[row, column]} />\n          ))}\n        </div>\n\n        {/* Brand mark — same tile geometry as an avatar, so the cluster stays\n            on grid. Rank 0 of the entrance: the cluster is built around it, so\n            it is the thing the eye should be given first. */}\n        <motion.div\n          style={{ gridRow: logoCell[0], gridColumn: logoCell[1] }}\n          className={`tile-${scopeId} relative flex items-center justify-center rounded-[26%] bg-[var(--avatar-muted)] text-[var(--avatar-fg)]`}\n          initial={enter.initial}\n          animate={enter.animate}\n          transition={enter.transition(0)}\n        >\n          {logo}\n          <span aria-hidden=\"true\" className={`logo-edge-${scopeId}`} />\n        </motion.div>\n\n        {extraTiles.map((tile) => (\n          <div\n            key={`extra-${tile.cell[0]}-${tile.cell[1]}`}\n            style={{ gridRow: tile.cell[0], gridColumn: tile.cell[1] }}\n            className=\"relative\"\n          >\n            {tile.content}\n          </div>\n        ))}\n\n        {members.map((member) => {\n          const isActive = activeId === member.id;\n          return (\n            // Two layers on purpose: the outer one owns the entrance, the\n            // inner one owns hover and press. Sharing a single element would\n            // mean one `transition` serving a delayed tween and an\n            // interruptible spring at once, and the entrance delay would leak\n            // into every hover.\n            <motion.div\n              key={member.id}\n              style={{ gridRow: member.cell[0], gridColumn: member.cell[1] }}\n              // z-index has to jump on hover or the lifted tile slides under\n              // its neighbours mid-animation.\n              className={`relative ${isActive ? \"z-10\" : \"z-0\"}`}\n              initial={enter.initial}\n              animate={enter.animate}\n              transition={enter.transition(ringRank.get(member.id) ?? 1)}\n            >\n              <motion.div\n                className=\"size-full\"\n                // Hover only ever comes from a real pointer. On touch, tapping\n                // fires enter with no matching leave, which would strand a\n                // tile lifted and in colour until something else was tapped.\n                onPointerEnter={(event) => {\n                  if (event.pointerType === \"mouse\") setHoveredId(member.id);\n                }}\n                onPointerLeave={(event) => {\n                  if (event.pointerType === \"mouse\") setHoveredId(null);\n                }}\n                onFocusCapture={() => setHoveredId(member.id)}\n                onBlurCapture={() => setHoveredId(null)}\n                animate={{ scale: reduceMotion ? 1 : isActive ? 1.03 : 1 }}\n                // Press wins over the hover lift — Motion gives gesture props\n                // priority over `animate`, so the two never fight.\n                whileTap={reduceMotion ? undefined : { scale: 0.96 }}\n                transition={SPRING}\n              >\n                <button\n                  type=\"button\"\n                  aria-label={`${member.name} — ${member.role}`}\n                  aria-pressed={selectedId === member.id}\n                  onClick={() => select(member.id)}\n                  className={`tile-${scopeId} relative block size-full cursor-pointer overflow-hidden rounded-[26%] transition-[box-shadow] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--avatar-ring)]`}\n                >\n                  <img\n                    src={member.src}\n                    alt=\"\"\n                    // outline, not border or ring: it sits inset without adding\n                    // to the layout, and it follows the border-radius. Pure\n                    // black/white at 10% — a tinted neutral picks up the surface\n                    // underneath and reads as dirt on the photo edge.\n                    //\n                    // The filter timing is tuned to the lift's spring rather\n                    // than left on Tailwind's default: colour and lift are one\n                    // object answering one hover, so they have to land together.\n                    className=\"size-full rounded-[26%] object-cover object-center outline-1 -outline-offset-1 outline-black/10 transition-[filter,opacity] duration-200 ease-[cubic-bezier(0.25,0.46,0.45,0.94)] dark:outline-white/10\"\n                    // Two-part state, one gesture: the active face gains its\n                    // colour and the rest step back. The dim only applies once\n                    // something is active — at rest every face sits at full\n                    // strength, so the cluster still reads as a group portrait\n                    // rather than a permanently deselected list.\n                    style={{\n                      filter: isActive ? \"none\" : \"grayscale(1)\",\n                      opacity: !activeId || isActive ? 1 : DIM_OPACITY,\n                    }}\n                    draggable={false}\n                  />\n                </button>\n              </motion.div>\n\n              {/* Outside the hover layer on purpose: nested in it, the badge\n                  would inherit the 1.08 lift and its text would scale with the\n                  photo. Side follows the tile's column so a badge on a\n                  left-hand tile points inward, never off the grid. */}\n              <AnimatePresence initial={false}>\n                {roleBadges && isActive && (\n                  <RoleBadge\n                    key={member.id}\n                    // The caller's render function is handed down rather than\n                    // called here: invoking opaque code with a member in this\n                    // scope makes the React Compiler treat `members` as\n                    // possibly mutated, and it bails out of memoizing\n                    // everything derived from it. Inside the badge — a\n                    // component with no memoization to lose — it costs nothing.\n                    member={member}\n                    renderBadge={renderBadge}\n                    // Outermost columns always point inward — a badge hung off\n                    // column 1 has nothing to hang into and leaves the grid\n                    // entirely, which is what happens on a compact 3-wide\n                    // layout where every tile is an edge tile. Everything in\n                    // between points away from the centre, so badges don't\n                    // land on top of the brand mark.\n                    side={\n                      member.cell[1] === 1\n                        ? \"right\"\n                        : member.cell[1] === columns\n                          ? \"left\"\n                          : member.cell[1] <= logoCell[1]\n                            ? \"left\"\n                            : \"right\"\n                    }\n                    reduceMotion={reduceMotion}\n                  />\n                )}\n              </AnimatePresence>\n            </motion.div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "~/components/craftui/blocks/avatar/default.tsx"
    }
  ],
  "meta": {
    "displayName": "Avatar Grid - Avatar Grid",
    "description": "A team cluster laid out on a hatched placeholder grid — avatars sit in fixed cells around a centre brand mark under a circular fade, and hovering one lifts it out of the grid and restores its colour.",
    "category": "avatar",
    "tags": [
      "avatar",
      "team",
      "grid",
      "roles",
      "hover",
      "animation"
    ],
    "animationType": "hover"
  }
}