{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fan-view",
  "type": "registry:ui",
  "title": "Fan View",
  "description": "A fanned card menu with controlled state, item callbacks, keyboard dismissal, outside-click handling, and reduced-motion support.",
  "files": [
    {
      "path": "registry/sonaui/fan-view/fan-view.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport {\n  AnimatePresence,\n  MotionConfig,\n  type Transition,\n  useReducedMotion,\n} from \"motion/react\";\nimport * as m from \"motion/react-m\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\n\nimport { cn } from \"@/lib/sona-utils\";\n\nexport interface FanViewItem {\n  /** Display label shown on the card. */\n  label: string;\n  /**\n   * Width of the card pill in pixels.\n   * @default 160\n   */\n  width?: number;\n  /** Called when the item is selected. */\n  onSelect?: () => void;\n}\n\nexport interface FanViewProps {\n  /**\n   * Items displayed as fanned cards when open.\n   */\n  items?: FanViewItem[];\n  /** Controlled open state. */\n  open?: boolean;\n  /** Initial open state for uncontrolled usage. @default false */\n  defaultOpen?: boolean;\n  /** Called when the view opens or closes. */\n  onOpenChange?: (open: boolean) => void;\n  /**\n   * Spring stiffness for the fan animation.\n   * @default 540\n   */\n  stiffness?: number;\n  /**\n   * Spring damping for the fan animation.\n   * @default 28\n   */\n  damping?: number;\n  /** Additional CSS classes for the root container. */\n  className?: string;\n}\n\nconst DEFAULT_ITEMS: FanViewItem[] = [];\n\nfunction getFanPoint(index: number, total: number) {\n  const progress = total <= 1 ? 0 : index / (total - 1);\n  const angle = (progress - 0.5) * 80 * (Math.PI / 180);\n  const radius = 190;\n  return {\n    x: Math.sin(angle) * radius,\n    y: -(Math.cos(angle) * radius * 0.65 + 110),\n    rotate: (progress - 0.5) * 28,\n    zIndex: total - index,\n  };\n}\n\nexport default function FanView({\n  items = DEFAULT_ITEMS,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  stiffness = 540,\n  damping = 28,\n  className,\n}: FanViewProps) {\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const isControlled = controlledOpen !== undefined;\n  const isOpen = isControlled ? controlledOpen : internalOpen;\n  const rootRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const shouldReduceMotion = useReducedMotion();\n\n  const springConfig: Transition = useMemo(\n    () => ({ type: \"spring\", stiffness, damping, mass: 0.95 }),\n    [stiffness, damping],\n  );\n  const resolvedTransition: Transition = shouldReduceMotion\n    ? { duration: 0 }\n    : springConfig;\n  const setOpen = useCallback(\n    (nextOpen: boolean) => {\n      if (!isControlled) setInternalOpen(nextOpen);\n      onOpenChange?.(nextOpen);\n    },\n    [isControlled, onOpenChange],\n  );\n\n  const positionedItems = useMemo(\n    () =>\n      items.map((item, index) => ({\n        ...item,\n        ...getFanPoint(index, items.length),\n      })),\n    [items],\n  );\n\n  useEffect(() => {\n    if (!isOpen) return;\n    const handlePointerDown = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) setOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", handlePointerDown);\n    return () => document.removeEventListener(\"pointerdown\", handlePointerDown);\n  }, [isOpen, setOpen]);\n\n  return (\n    <MotionConfig transition={resolvedTransition}>\n      <div\n        className={cn(\n          \"flex min-h-[420px] w-full items-end justify-center px-4 pb-16 pt-8\",\n          className,\n        )}\n      >\n        <div ref={rootRef} className=\"relative flex items-end justify-center\">\n          <AnimatePresence initial={false}>\n            {isOpen &&\n              positionedItems.map(\n                (\n                  { label, onSelect, width = 160, x, y, rotate, zIndex },\n                  index,\n                ) => (\n                  <m.button\n                    key={label}\n                    type=\"button\"\n                    title={label}\n                    aria-label={label}\n                    className=\"absolute bottom-0 left-1/2 cursor-pointer -translate-x-1/2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n                    style={{ width, zIndex }}\n                    initial={\n                      shouldReduceMotion\n                        ? false\n                        : {\n                            x: 0,\n                            y: 0,\n                            opacity: 0,\n                            scale: 0.55,\n                            rotate: 0,\n                            filter: \"blur(10px)\",\n                          }\n                    }\n                    animate={{\n                      x,\n                      y,\n                      opacity: 1,\n                      scale: 1,\n                      rotate,\n                      filter: \"blur(0px)\",\n                      transition: {\n                        ...(shouldReduceMotion\n                          ? { duration: 0 }\n                          : springConfig),\n                        delay: shouldReduceMotion ? 0 : index * 0.04,\n                      },\n                    }}\n                    exit={\n                      shouldReduceMotion\n                        ? { opacity: 0, transition: { duration: 0 } }\n                        : {\n                            x: 0,\n                            y: 0,\n                            opacity: 0,\n                            scale: 0.45,\n                            rotate: 0,\n                            filter: \"blur(10px)\",\n                            transition: {\n                              duration: 0.18,\n                              ease: \"easeInOut\",\n                              delay: (items.length - index - 1) * 0.025,\n                            },\n                          }\n                    }\n                    whileHover={\n                      shouldReduceMotion ? {} : { scale: 1.05, zIndex: 30 }\n                    }\n                    whileTap={shouldReduceMotion ? {} : { scale: 0.96 }}\n                    onClick={() => {\n                      onSelect?.();\n                      setOpen(false);\n                    }}\n                    onKeyDown={(event) => {\n                      if (event.key === \"Escape\") {\n                        event.preventDefault();\n                        setOpen(false);\n                        triggerRef.current?.focus();\n                      }\n                    }}\n                  >\n                    <span\n                      className={cn(\n                        \"flex h-12 w-full items-center justify-center rounded-xl border px-5\",\n                        \"border-border bg-foreground text-background shadow-sm\",\n                        \"text-[17px] font-medium whitespace-nowrap\",\n                      )}\n                    >\n                      {label}\n                    </span>\n                  </m.button>\n                ),\n              )}\n          </AnimatePresence>\n\n          <m.button\n            ref={triggerRef}\n            type=\"button\"\n            aria-expanded={isOpen}\n            aria-label={isOpen ? \"Close fan view\" : \"Open fan view\"}\n            onClick={() => setOpen(!isOpen)}\n            className={cn(\n              \"relative z-20 flex h-16 w-16 items-center justify-center rounded-full border\",\n              \"border-border bg-background text-foreground shadow-lg\",\n              \"hover:border-border/80 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n            )}\n            whileHover={shouldReduceMotion ? {} : { scale: 1.04 }}\n            whileTap={shouldReduceMotion ? {} : { scale: 0.94 }}\n          >\n            <m.span\n              className=\"block relative size-8\"\n              animate={isOpen ? \"open\" : \"closed\"}\n              initial={false}\n            >\n              <m.span\n                className=\"absolute left-0 top-1/2 h-1 w-full bg-foreground rounded-full -translate-y-1/2\"\n                variants={{ closed: { rotate: 0 }, open: { rotate: 45 } }}\n              />\n              <m.span\n                className=\"absolute left-0 top-1/2 h-1 w-full bg-foreground rounded-full -translate-y-1/2\"\n                variants={{ closed: { rotate: 90 }, open: { rotate: -45 } }}\n              />\n            </m.span>\n          </m.button>\n        </div>\n      </div>\n    </MotionConfig>\n  );\n}\n",
      "target": "components/ui/fan-view/fan-view.tsx"
    }
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "@sona-ui/sona-utils"
  ]
}