{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "radial-stagger",
  "title": "Radial Stagger",
  "description": "Radial stagger — grid tiles spring in, ordered by their distance from a center point.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://motion.asmitsah.dev/r/provider.json"
  ],
  "files": [
    {
      "path": "components/motion/radial-stagger.tsx",
      "content": "\"use client\";\n\nimport type { Variants } from \"motion/react\";\nimport { useAnimationControls } from \"motion/react\";\nimport * as m from \"motion/react-m\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport { Children, useEffect, useState } from \"react\";\nimport { feel, springPop, springPopTransition } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\n\ntype DistanceMetric = \"euclidean\" | \"manhattan\" | \"chebyshev\";\n\ntype RadialStaggerProps = {\n  children: ReactNode;\n  className?: string;\n  style?: CSSProperties;\n  /** classes for each tile (the grid cell) */\n  itemClassName?: string;\n  /** grid columns — drives both the layout and the distance geometry */\n  columns?: number;\n  /** seconds of delay added per unit of distance from the clicked origin */\n  step?: number;\n  /** how ring distance is measured from the origin cell */\n  distance?: DistanceMetric;\n  /** index the first cascade ripples out from (before any click) */\n  defaultOrigin?: number;\n  /**\n   * Opt into real interactive controls — each tile renders as a focusable\n   * `<button>`. Requires an accessible name per tile (see `getItemLabel`),\n   * otherwise you ship nameless buttons (WCAG 4.1.2). Leave off (default) for\n   * a decorative ripple grid: tiles render as non-focusable `role=\"presentation\"`\n   * elements, so keyboard/SR users never hit empty controls.\n   */\n  interactive?: boolean;\n  /** Accessible name for the tile at `index` — required when `interactive`. */\n  getItemLabel?: (index: number) => string;\n  /** Fires with the tile index when an interactive tile is selected. */\n  onSelect?: (index: number) => void;\n};\n\nconst radialItem: Variants = {\n  hidden: springPop.initial,\n  visible: (delay: number) => ({\n    ...springPop.animate,\n    transition: { ...springPopTransition, delay },\n  }),\n};\n\nfunction ringDistance(a: number, b: number, columns: number, metric: DistanceMetric) {\n  const cols = Math.max(1, columns);\n  const dr = Math.abs(Math.floor(a / cols) - Math.floor(b / cols));\n  const dc = Math.abs((a % cols) - (b % cols));\n  if (metric === \"manhattan\") return dr + dc;\n  if (metric === \"chebyshev\") return Math.max(dr, dc);\n  return Math.hypot(dr, dc);\n}\n\n// FLAT export (never a compound `RadialStagger.Item` — bolted-on props are\n// stripped across the RSC client boundary and crash a Server Component at build).\nexport function RadialStagger({\n  children,\n  className,\n  style,\n  itemClassName,\n  columns = 4,\n  step = feel.stagger,\n  distance = \"euclidean\",\n  defaultOrigin = 0,\n  interactive = false,\n  getItemLabel,\n  onSelect,\n}: RadialStaggerProps) {\n  const [origin, setOrigin] = useState(defaultOrigin);\n  const [nonce, setNonce] = useState(0);\n  const controls = useAnimationControls();\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: origin/nonce are the replay trigger — re-running after the fresh `custom` delays commit is the point\n  useEffect(() => {\n    controls.set(\"hidden\");\n    controls.start(\"visible\");\n  }, [origin, nonce, controls]);\n\n  function ripple(index: number) {\n    setOrigin(index);\n    setNonce((n) => n + 1);\n  }\n\n  return (\n    <div\n      className={className}\n      style={{\n        display: \"grid\",\n        gridTemplateColumns: `repeat(${Math.max(1, columns)}, minmax(0, 1fr))`,\n        ...style,\n      }}\n    >\n      {Children.map(children, (child, index) => {\n        const motionProps = {\n          custom: ringDistance(index, origin, columns, distance) * step,\n          variants: radialItem,\n          initial: \"hidden\" as const,\n          animate: controls,\n        };\n\n        // Opt-in interactive: real focusable button, must be labelled.\n        if (interactive) {\n          return (\n            <m.button\n              {...motionProps}\n              type=\"button\"\n              aria-label={getItemLabel?.(index)}\n              onClick={() => {\n                ripple(index);\n                onSelect?.(index);\n              }}\n              className={cn(\n                \"cursor-pointer appearance-none border-0 bg-transparent p-0 text-left focus-visible:outline-2 focus-visible:outline-offset-2\",\n                itemClassName,\n              )}\n            >\n              {child}\n            </m.button>\n          );\n        }\n\n        // Default decorative: non-focusable, no accessible name needed. The\n        // click-to-ripple-outward effect still works — clicking a tile cascades\n        // ripples from it — it just isn't exposed to keyboard/SR as a control.\n        return (\n          <m.div\n            {...motionProps}\n            role=\"presentation\"\n            onClick={() => ripple(index)}\n            className={cn(\"cursor-pointer\", itemClassName)}\n          >\n            {child}\n          </m.div>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/motion/radial-stagger.tsx"
    }
  ],
  "type": "registry:component"
}