{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "related-products",
  "title": "Related Products",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "A \"pairs well with\" rail of the exact product card on your shadcn Carousel, with the Carousel's own previous / next buttons in the header and a progress rule under it in place of a scrollbar. Below the fold by nature, so nothing in it loads with the page: the cards and their images mount when the rail comes within a screen of the viewport, fading in over skeletons of exactly their shape (Motion). Compound, through useRelatedProducts().",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "carousel",
    "skeleton",
    "@ecomcn/product-card"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/related-products/related-products.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, MotionConfig, motion, useReducedMotion } from \"motion/react\"\n\nimport {\n  Carousel,\n  CarouselContent,\n  CarouselItem,\n  CarouselNext,\n  CarouselPrevious,\n  type CarouselApi,\n} from \"@/components/ui/carousel\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\nimport {\n  ProductCard,\n  type ProductCardProduct,\n  type ProductCardProps,\n} from \"@/components/ecomcn/product-card\"\nimport { cn } from \"@/lib/utils\"\n\n/*\n * A \"pairs well with\" rail under the product: the exact product card, never\n * a second, smaller one, on your shadcn Carousel.\n *\n *   <RelatedProducts products={related} onQuickAdd={addToBag} />\n *\n *   <RelatedProducts products={related} title=\"Complete the look\">       or composed\n *     <RelatedProductsHeader />                       title, previous / next\n *     <RelatedProductsRail renderCard={(p) => <ProductCard product={p} density=\"compact\" />} />\n *     <RelatedProductsProgress />\n *   </RelatedProducts>\n *\n * The whole section is the Carousel, so the header's previous and next\n * buttons are the Carousel's own. The rail sits below the fold, so nothing in it loads with the page: the\n * cards — and their images — mount when the rail comes within a screen of\n * the viewport, fading in over skeletons of exactly their shape. A thin rule\n * under the rail shows how far along it is and grows as it scrolls.\n *\n * Everything the shadcn Carousel takes passes through: `opts` (Embla's\n * options — loop, dragFree, slidesToScroll…), `plugins` (autoplay, wheel\n * gestures), `setApi`, and `orientation`. Arrow keys move the rail when it\n * has focus, as on the Carousel.\n */\n\n/** The same settle curve as the other ecomcn morphs: no bounce, an editorial ease. */\nconst MORPH = { duration: 0.45, ease: [0.2, 0, 0, 1] } as const\n\ntype CarouselProps = React.ComponentProps<typeof Carousel>\n\n/* ─── context ──────────────────────────────────────────────────────────── */\n\nexport interface RelatedProductsContextValue {\n  products: ProductCardProduct[]\n  title: React.ReactNode\n  headingId: string\n  /** True once the rail is near the viewport: cards and images are mounted. */\n  inView: boolean\n  loading: boolean\n  /** How far the rail has scrolled, 0 to 1, and how much of it is on screen. */\n  progress: number\n  visibleShare: number\n  /** The rail's Embla API, once the carousel has mounted. */\n  api: CarouselApi | undefined\n  orientation: \"horizontal\" | \"vertical\"\n  currency?: string\n  locale?: string\n  onQuickAdd?: (product: ProductCardProduct, colorIndex: number) => void | Promise<void>\n  cardProps?: Omit<ProductCardProps, \"product\">\n}\n\nconst RelatedProductsContext = React.createContext<RelatedProductsContextValue | null>(null)\n\nexport function useRelatedProducts() {\n  const context = React.useContext(RelatedProductsContext)\n  if (!context) {\n    throw new Error(\"useRelatedProducts must be used within <RelatedProducts>.\")\n  }\n  return context\n}\n\n/* ─── root ─────────────────────────────────────────────────────────────── */\n\nexport interface RelatedProductsProps\n  extends Omit<React.HTMLAttributes<HTMLElement>, \"title\"> {\n  products: ProductCardProduct[]\n  title?: React.ReactNode\n  currency?: string\n  locale?: string\n  onQuickAdd?: (product: ProductCardProduct, colorIndex: number) => void | Promise<void>\n  /**\n   * Passed to every card: `onWishlistChange` for the heart, `showColors={false}`\n   * for products without colours, `density`.\n   */\n  cardProps?: Omit<ProductCardProps, \"product\">\n  /** Mount the cards with the page instead of near the viewport — above the fold. */\n  eager?: boolean\n  /** How far ahead of the viewport the cards mount. Defaults to one screen. */\n  rootMargin?: string\n  /** Placeholders while the products are being fetched. */\n  loading?: boolean\n  /**\n   * Embla's options, over the rail's own (`align: \"start\"`,\n   * `containScroll: \"trimSnaps\"`): `loop`, `dragFree`, `slidesToScroll`…\n   */\n  opts?: CarouselProps[\"opts\"]\n  /** Embla plugins — `Autoplay()` from embla-carousel-autoplay, say. */\n  plugins?: CarouselProps[\"plugins\"]\n  /** The Embla API, for your own controls. */\n  setApi?: CarouselProps[\"setApi\"]\n  /** A vertical rail needs a height: set one on RelatedProductsRail. */\n  orientation?: \"horizontal\" | \"vertical\"\n}\n\nexport function RelatedProducts({\n  products,\n  title = \"Pairs well with\",\n  currency,\n  locale,\n  onQuickAdd,\n  cardProps,\n  eager = false,\n  rootMargin = \"100% 0px\",\n  loading = false,\n  opts,\n  plugins,\n  setApi: setApiProp,\n  orientation = \"horizontal\",\n  className,\n  children,\n  ...props\n}: RelatedProductsProps) {\n  const headingId = React.useId()\n  const section = React.useRef<HTMLElement>(null)\n  const [near, setNear] = React.useState(false)\n  const [api, setApiState] = React.useState<CarouselApi>()\n  const setApi = React.useCallback(\n    (next: CarouselApi) => {\n      setApiState(next)\n      setApiProp?.(next)\n    },\n    [setApiProp]\n  )\n  const [scroll, setScroll] = React.useState({ progress: 0, visibleShare: 1 })\n\n  React.useEffect(() => {\n    if (eager || near) return\n    const node = section.current\n    if (!node) return\n    const observer = new IntersectionObserver(\n      (entries) => {\n        if (entries.some((e) => e.isIntersecting)) {\n          setNear(true)\n          observer.disconnect()\n        }\n      },\n      { rootMargin }\n    )\n    observer.observe(node)\n    return () => observer.disconnect()\n  }, [eager, near, rootMargin])\n\n  React.useEffect(() => {\n    if (!api) return\n    const update = () => {\n      // The share of the track on screen, as a scrollbar thumb measures it:\n      // the viewport's width over the slides' full run. Layout offsets, not\n      // scrollWidth — Embla moves the track with a transform.\n      const slides = api.slideNodes()\n      const first = slides[0]\n      const last = slides[slides.length - 1]\n      const vertical = orientation === \"vertical\"\n      const track =\n        first && last\n          ? vertical\n            ? last.offsetTop + last.offsetHeight - first.offsetTop\n            : last.offsetLeft + last.offsetWidth - first.offsetLeft\n          : 0\n      const view = vertical ? api.rootNode().clientHeight : api.rootNode().clientWidth\n      setScroll({\n        progress: Math.min(1, Math.max(0, api.scrollProgress())),\n        visibleShare: track > 0 ? Math.min(1, view / track) : 1,\n      })\n    }\n    update()\n    api.on(\"scroll\", update).on(\"reInit\", update).on(\"resize\", update)\n    return () => {\n      api.off(\"scroll\", update).off(\"reInit\", update).off(\"resize\", update)\n    }\n  }, [api, orientation])\n\n  const context = React.useMemo<RelatedProductsContextValue>(\n    () => ({\n      products,\n      title,\n      headingId,\n      inView: eager || near,\n      loading,\n      progress: scroll.progress,\n      visibleShare: scroll.visibleShare,\n      api,\n      orientation,\n      currency,\n      locale,\n      onQuickAdd,\n      cardProps,\n    }),\n    [products, title, headingId, eager, near, loading, scroll, api, orientation, currency, locale, onQuickAdd, cardProps]\n  )\n\n  // Nothing related: the section goes, rather than an empty rail.\n  if (!loading && products.length === 0) return null\n\n  return (\n    <RelatedProductsContext.Provider value={context}>\n      <MotionConfig reducedMotion=\"user\">\n        <section\n          ref={section}\n          aria-labelledby={headingId}\n          aria-busy={loading || undefined}\n          data-slot=\"related-products\"\n          className={cn(\"w-full\", className)}\n          {...props}\n        >\n          {/* The whole section is the carousel, so the header can hold its\n              previous and next buttons. Snaps a card at a time. */}\n          <Carousel\n            setApi={setApi}\n            opts={{ align: \"start\", containScroll: \"trimSnaps\", ...opts }}\n            plugins={plugins}\n            orientation={orientation}\n            aria-labelledby={headingId}\n          >\n            {children ?? (\n              <>\n                <RelatedProductsHeader />\n                <RelatedProductsRail />\n                <RelatedProductsProgress />\n              </>\n            )}\n          </Carousel>\n        </section>\n      </MotionConfig>\n    </RelatedProductsContext.Provider>\n  )\n}\n\n/* ─── parts ────────────────────────────────────────────────────────────── */\n\n/** The Carousel's own buttons, squared off to sit in the header's rule. */\nconst ARROW = \"static size-9 translate-x-0 translate-y-0 rounded-none shadow-none\"\n\n/** The title, ruled, with the Carousel's previous and next buttons on the right. */\nexport function RelatedProductsHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  const { title, headingId, products, loading, inView } = useRelatedProducts()\n  // Spread only when there is nothing to scroll yet: passing `disabled`\n  // at all would override the Carousel's own can-scroll state.\n  const idle = loading || !inView || products.length < 2 ? { disabled: true } : {}\n  return (\n    <div\n      data-slot=\"related-products-header\"\n      className={cn(\"flex items-end justify-between gap-4 border-b pb-4\", className)}\n      {...props}\n    >\n      <h2 id={headingId} className=\"text-2xl\">\n        {title}\n      </h2>\n      <div className=\"flex gap-2\">\n        {/* The Carousel turns these a quarter for a vertical rail. */}\n        <CarouselPrevious {...idle} className={ARROW} />\n        <CarouselNext {...idle} className={ARROW} />\n      </div>\n    </div>\n  )\n}\n\nexport interface RelatedProductsRailProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Your own card for each product — still the product card, composed your way. */\n  renderCard?: (product: ProductCardProduct) => React.ReactNode\n  /** Placeholders shown while loading, or before the rail is near. Defaults to 4. */\n  skeletonCount?: number\n}\n\n/**\n * The cards: drag, swipe, or the arrow keys once the rail has focus. Two and\n * a bit cards on a phone — the cut-off one says there are more — four on a\n * desktop.\n */\nexport function RelatedProductsRail({\n  renderCard,\n  skeletonCount = 4,\n  className,\n  ...props\n}: RelatedProductsRailProps) {\n  const { products, inView, loading, currency, locale, onQuickAdd, cardProps, orientation } = useRelatedProducts()\n  const vertical = orientation === \"vertical\"\n  const reduce = useReducedMotion()\n  const ready = inView && !loading\n  const slides = ready ? products : Array.from({ length: skeletonCount }, () => null)\n\n  return (\n    <div\n      data-slot=\"related-products-rail\"\n      // The Carousel's viewport clips. Started 4px early on the left — and\n      // the track 4px late — a selected swatch's ring and a focus ring on\n      // the first card are not cut off, while the cards still line up with\n      // the header. py-1 does the same at the top and bottom.\n      className={cn(\n        \"pt-5\",\n        vertical\n          ? \"[&>[data-slot=carousel-content]]:h-full\"\n          : \"-ml-1\",\n        className\n      )}\n      {...props}\n    >\n      <CarouselContent className={vertical ? \"-mt-5 h-full py-1\" : \"-ml-4 py-1\"}>\n        {slides.map((product, i) => (\n          <CarouselItem\n            key={product?.id ?? `skeleton-${i}`}\n            className={vertical ? \"basis-auto pt-5\" : \"basis-[44%] pl-5 sm:basis-1/3 lg:basis-1/4\"}\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {product ? (\n                <motion.div\n                  key=\"card\"\n                  initial={{ opacity: 0, y: reduce ? 0 : 8 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  transition={reduce ? { duration: 0.15 } : { ...MORPH, delay: Math.min(i, 4) * 0.05 }}\n                >\n                  {renderCard ? (\n                    renderCard(product)\n                  ) : (\n                    <ProductCard\n                      currency={currency}\n                      locale={locale}\n                      onQuickAdd={onQuickAdd}\n                      {...cardProps}\n                      product={product}\n                    />\n                  )}\n                </motion.div>\n              ) : (\n                <motion.div key=\"skeleton\" exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>\n                  <CardSkeleton />\n                </motion.div>\n              )}\n            </AnimatePresence>\n          </CarouselItem>\n        ))}\n      </CarouselContent>\n    </div>\n  )\n}\n\n/** Same box as the product card — image ratio and text bars — so nothing jumps. */\nfunction CardSkeleton() {\n  return (\n    <div aria-hidden data-slot=\"related-products-skeleton\">\n      <Skeleton className=\"aspect-[4/5] w-full rounded-none\" />\n      <Skeleton className=\"mt-3.5 h-3 w-16 rounded-none\" />\n      <Skeleton className=\"mt-2 h-5 w-3/4 rounded-none\" />\n      <Skeleton className=\"mt-2 h-3 w-14 rounded-none\" />\n    </div>\n  )\n}\n\n/**\n * A hairline under the rail with a heavier segment for the part on screen.\n * It is as long as the share of cards in view and slides as the rail\n * scrolls — a scrollbar's information, without a scrollbar.\n */\nexport function RelatedProductsProgress({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  const { progress, visibleShare, products, inView } = useRelatedProducts()\n  const reduce = useReducedMotion()\n  if (!inView || products.length < 2 || visibleShare >= 1) return null\n  const width = Math.max(0.12, visibleShare)\n\n  return (\n    <div\n      aria-hidden\n      data-slot=\"related-products-progress\"\n      className={cn(\"relative mt-8 h-px bg-border\", className)}\n      {...props}\n    >\n      <motion.span\n        className=\"absolute -top-px left-0 h-[3px] origin-left bg-foreground\"\n        initial={false}\n        animate={{ width: `${width * 100}%`, left: `${progress * (1 - width) * 100}%` }}\n        transition={reduce ? { duration: 0 } : { duration: 0.2, ease: MORPH.ease }}\n      />\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/related-products.tsx"
    }
  ],
  "categories": [
    "ecommerce",
    "product",
    "pdp",
    "merchandising"
  ],
  "type": "registry:block"
}