{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "apple-cards-carousel",
  "title": "Apple Cards Carousel",
  "description": "Snap-scrolling row of tall image cards that morph into a full modal story.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/velora/apple-cards-carousel.tsx",
      "content": "\"use client\";\n\nimport { Children, useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, MotionConfig, motion, useReducedMotion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst noopSubscribe = () => () => {};\n\nconst Icon = ({ d }: { d: string }) => (\n  <svg aria-hidden viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" className=\"size-4\">\n    <path d={d} />\n  </svg>\n);\n\ninterface CardsCarouselProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** `CarouselCard` elements */\n  children: React.ReactNode;\n  /** Accessible name for the scrollable region */\n  label?: string;\n}\n\n/** Snap-scrolling row of `CarouselCard`s with previous/next buttons. */\nexport function CardsCarousel({\n  children,\n  label = \"Carousel\",\n  className,\n  ...props\n}: CardsCarouselProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const reducedMotion = useReducedMotion();\n  const [edge, setEdge] = useState(1); // 1: at start, 2: at end\n\n  const measure = () => {\n    const el = ref.current!;\n    setEdge(+(el.scrollLeft < 2) | (+(el.scrollLeft > el.scrollWidth - el.clientWidth - 2) << 1));\n  };\n\n  useEffect(() => {\n    const observer = new ResizeObserver(measure);\n    observer.observe(ref.current!);\n    return () => observer.disconnect();\n  }, []);\n\n  return (\n    <div data-slot=\"cards-carousel\" className={cn(\"w-full min-w-0\", className)} {...props}>\n      <motion.div\n        ref={ref}\n        layoutScroll\n        role=\"region\"\n        aria-label={label}\n        tabIndex={0}\n        onScroll={measure}\n        className=\"snap-x snap-mandatory scroll-px-2 overflow-x-auto rounded-3xl scrollbar-none focus-visible:outline-2 focus-visible:outline-ring [&::-webkit-scrollbar]:hidden\"\n      >\n        <ul className=\"flex w-max gap-4 px-2 py-4\">\n          {Children.map(children, (child) => (\n            <li className=\"snap-start\">{child}</li>\n          ))}\n        </ul>\n      </motion.div>\n      <div className=\"mt-1 flex justify-end gap-2 px-2\">\n        {[-1, 1].map((direction, i) => (\n          <button\n            key={direction}\n            type=\"button\"\n            aria-label={i ? \"Next cards\" : \"Previous cards\"}\n            disabled={!!(edge & (i + 1))}\n            onClick={() =>\n              ref.current?.scrollBy({\n                left: direction * ref.current.clientWidth * 0.8,\n                behavior: reducedMotion ? \"auto\" : \"smooth\",\n              })\n            }\n            className=\"inline-flex size-10 items-center justify-center rounded-full border bg-card shadow-xs transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\"\n          >\n            <Icon d={i ? \"m9 18 6-6-6-6\" : \"m15 18-6-6 6-6\"} />\n          </button>\n        ))}\n      </div>\n    </div>\n  );\n}\n\ninterface CarouselCardProps {\n  /** Cover image URL */\n  src: string;\n  /** Small label above the title */\n  category: string;\n  /** Card title and dialog heading */\n  title: string;\n  /** Rich content shown once the card is opened */\n  children: React.ReactNode;\n  /** Extra classes for the card */\n  className?: string;\n}\n\n/** A tall image card for `CardsCarousel`; clicking it morphs into a modal dialog. */\nexport function CarouselCard({\n  src,\n  category,\n  title,\n  children,\n  className,\n}: CarouselCardProps) {\n  const id = useId();\n  const [open, setOpen] = useState(false);\n  const reducedMotion = useReducedMotion();\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const dialogRef = useRef<HTMLDivElement>(null);\n\n  // Modal: scroll lock, focus trap, Escape, focus restore.\n  useEffect(() => {\n    if (!open) return;\n    const trigger = triggerRef.current;\n    const dialog = dialogRef.current!;\n    const overflow = document.body.style.overflow;\n    document.body.style.overflow = \"hidden\";\n    dialog.focus({ preventScroll: true });\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") setOpen(false);\n      if (event.key !== \"Tab\") return;\n      const focusable = dialog.querySelectorAll<HTMLElement>(\n        'a[href],button:not(:disabled),input:not(:disabled),select,textarea,[tabindex]:not([tabindex=\"-1\"])'\n      );\n      const first = focusable[0];\n      const last = focusable[focusable.length - 1];\n      const active = document.activeElement;\n      if (event.shiftKey ? active === first || active === dialog : active === last) {\n        event.preventDefault();\n        (event.shiftKey ? last : first).focus();\n      }\n    };\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      document.removeEventListener(\"keydown\", onKeyDown);\n      document.body.style.overflow = overflow;\n      trigger?.focus({ preventScroll: true });\n    };\n  }, [open]);\n\n  const layers = (inDialog: boolean) => {\n    const Title = inDialog ? motion.h2 : motion.span;\n    return (\n      <>\n        <motion.img\n          layoutId={`${id}-img`}\n          src={src}\n          alt=\"\"\n          loading=\"lazy\"\n          className=\"absolute inset-0 size-full object-cover\"\n        />\n        <span aria-hidden className=\"absolute inset-0 bg-linear-to-b from-black/75 via-black/20 to-black/20\" />\n        <motion.span layoutId={`${id}-category`} className=\"relative text-sm font-medium text-white/80\">\n          {category}\n        </motion.span>\n        <Title\n          id={inDialog ? `${id}-heading` : undefined}\n          layoutId={`${id}-title`}\n          className={cn(\n            \"relative mt-1.5 max-w-lg font-semibold leading-tight tracking-tight text-balance text-white\",\n            inDialog ? \"text-2xl md:text-4xl\" : \"text-xl sm:text-2xl\"\n          )}\n        >\n          {title}\n        </Title>\n      </>\n    );\n  };\n\n  // Portal the dialog to <body> so a transformed ancestor can't trap it.\n  const hydrated = useSyncExternalStore(noopSubscribe, () => true, () => false);\n\n  return (\n    <MotionConfig\n      transition={reducedMotion ? { duration: 0 } : { type: \"spring\", stiffness: 260, damping: 30 }}\n    >\n      <motion.button\n        ref={triggerRef}\n        type=\"button\"\n        layoutId={`${id}-card`}\n        style={{ borderRadius: 24 }}\n        onClick={() => setOpen(true)}\n        aria-haspopup=\"dialog\"\n        aria-expanded={open}\n        data-slot=\"carousel-card\"\n        className={cn(\n          \"group/carousel-card relative flex h-80 w-56 flex-col items-start overflow-hidden bg-muted p-6 text-left shadow-sm transition-shadow hover:shadow-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring sm:h-[26rem] sm:w-72\",\n          className\n        )}\n      >\n        {layers(false)}\n        <span aria-hidden className=\"absolute right-4 bottom-4 flex size-8 items-center justify-center rounded-full bg-white/90 text-black shadow-md transition-transform motion-safe:group-hover/carousel-card:scale-110\">\n          <Icon d=\"M12 5v14M5 12h14\" />\n        </span>\n      </motion.button>\n\n      {hydrated && createPortal(\n      <AnimatePresence>\n        {open && (\n          <motion.div layoutScroll className=\"fixed inset-0 z-50 overflow-y-auto px-4 py-6 md:py-12\">\n            <motion.div\n              aria-hidden\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              transition={{ duration: reducedMotion ? 0 : 0.25 }}\n              onClick={() => setOpen(false)}\n              className=\"fixed inset-0 bg-black/50 backdrop-blur-md\"\n            />\n            <motion.div\n              ref={dialogRef}\n              layoutId={`${id}-card`}\n              style={{ borderRadius: 24 }}\n              role=\"dialog\"\n              aria-modal=\"true\"\n              aria-labelledby={`${id}-heading`}\n              tabIndex={-1}\n              className=\"relative mx-auto max-w-3xl overflow-hidden bg-card shadow-2xl outline-none\"\n            >\n              <div className=\"relative flex h-64 flex-col items-start p-6 md:h-80 md:p-10\">\n                {layers(true)}\n                <button\n                  type=\"button\"\n                  aria-label=\"Close\"\n                  onClick={() => setOpen(false)}\n                  className=\"absolute top-4 right-4 flex size-9 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur hover:bg-black/65 focus-visible:outline-2 focus-visible:outline-white\"\n                >\n                  <Icon d=\"M18 6 6 18M6 6l12 12\" />\n                </button>\n              </div>\n              <div className=\"space-y-4 p-6 leading-relaxed text-muted-foreground md:p-10\">\n                {children}\n              </div>\n            </motion.div>\n          </motion.div>\n        )}\n      </AnimatePresence>,\n      document.body\n      )}\n    </MotionConfig>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/velora/apple-cards-carousel.tsx"
    }
  ],
  "type": "registry:ui"
}