{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "category-rail",
  "title": "Category Rail",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "Numbered category tiles with a photo and an item count, snapping along your shadcn Carousel. Tiles sit 1px apart over a track in your border colour, so the rail reads as one ruled object. The Carousel's previous and next buttons fade out and leave the tab order when everything fits; a tile reached by Tab scrolls into view. Loading skeleton included.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "carousel",
    "skeleton"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/category-rail/category-rail.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { 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 { cn } from \"@/lib/utils\"\n\n/*\n * A row of categories to start from: numbered tiles with a photo, a name\n * and how many items are in each, snapping as it scrolls.\n *\n *   <CategoryRail items={[{ id: \"bags\", label: \"Bags\", href: \"/c/bags\", count: 24, image: <img … /> }]} />\n *\n * Tiles sit 1px apart over a track in your border colour, so the rail\n * reads as one ruled object rather than floating cards. It is your shadcn\n * Carousel: drag, swipe, or the arrow keys once it has focus, and the\n * previous / next buttons are the Carousel's own — hidden, not just\n * disabled, when everything already fits. Tabbing to a tile that is out of\n * view brings it in. The arrows fade in and out (Motion; instant under\n * reduced motion).\n */\n\nexport interface CategoryRailItem {\n  id: string\n  label: string\n  href: string\n  /** Items in the category, shown as \"24 items\". */\n  count?: number\n  /** Already rendered, so the block never assumes next/image. Fills a 4:5 box. */\n  image?: React.ReactNode\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\nconst subscribeNothing = () => () => {}\n\ntype CarouselProps = React.ComponentProps<typeof Carousel>\n\nexport interface CategoryRailProps extends Omit<React.HTMLAttributes<HTMLElement>, \"title\"> {\n  items: CategoryRailItem[]\n  /** \"Shop by category\", by default. Pass null for no header. */\n  title?: React.ReactNode\n  /** Shows \"01\", \"02\" on each tile. Defaults to true. */\n  numbered?: boolean\n  /** \"24 items\" by default. */\n  formatCount?: (count: number) => string\n  /** `undefined` follows the visitor's locale, once hydrated. */\n  locale?: string\n  /** Embla options, passed to the Carousel. */\n  opts?: CarouselProps[\"opts\"]\n  loading?: boolean\n}\n\nexport function CategoryRail({\n  items,\n  title = \"Shop by category\",\n  numbered = true,\n  formatCount,\n  locale: localeProp,\n  opts,\n  loading = false,\n  className,\n  ...props\n}: CategoryRailProps) {\n  const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false)\n  const locale = localeProp ?? (hydrated ? undefined : \"en-US\")\n  const reduce = useReducedMotion()\n  const headingId = React.useId()\n  const [api, setApi] = React.useState<CarouselApi>()\n  const [overflowing, setOverflowing] = React.useState(false)\n\n  React.useEffect(() => {\n    if (!api) return\n    const measure = () => setOverflowing(api.canScrollPrev() || api.canScrollNext())\n    measure()\n    api.on(\"reInit\", measure).on(\"resize\", measure).on(\"select\", measure)\n    return () => {\n      api.off(\"reInit\", measure).off(\"resize\", measure).off(\"select\", measure)\n    }\n  }, [api])\n\n  const number = React.useMemo(() => new Intl.NumberFormat(locale), [locale])\n  const countText = (count: number) =>\n    formatCount ? formatCount(count) : `${number.format(count)} ${count === 1 ? \"item\" : \"items\"}`\n\n  // A tile reached by Tab may be off to the side: bring it in, and undo the\n  // browser's own attempt to scroll the clipped viewport.\n  const reveal = (index: number) => (event: React.FocusEvent<HTMLElement>) => {\n    const viewport = event.currentTarget.closest<HTMLElement>(\"[data-slot=carousel-content]\")\n    if (viewport) viewport.scrollLeft = 0\n    if (api && !api.slidesInView().includes(index)) api.scrollTo(index)\n  }\n\n  const tiles = loading ? Array.from({ length: 5 }, () => null) : items\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <section\n        data-slot=\"category-rail\"\n        aria-labelledby={title !== null ? headingId : undefined}\n        aria-busy={loading || undefined}\n        className={cn(\"flex flex-col gap-5\", className)}\n        {...props}\n      >\n        <Carousel setApi={setApi} opts={{ align: \"start\", containScroll: \"trimSnaps\", ...opts }} className=\"flex flex-col gap-5\">\n          {title !== null ? (\n            <div data-slot=\"category-rail-header\" className=\"flex items-end justify-between gap-4 border-b pb-4\">\n              <h2 id={headingId} className=\"text-2xl\">\n                {title}\n              </h2>\n              <motion.div\n                className=\"flex gap-2\"\n                initial={{ opacity: 0, visibility: \"hidden\" }}\n                // Nothing to scroll: faded, then gone for the pointer, the keyboard and screen readers.\n                animate={overflowing ? { opacity: 1, visibility: \"visible\" } : { opacity: 0, transitionEnd: { visibility: \"hidden\" } }}\n                transition={reduce ? { duration: 0 } : MORPH}\n              >\n                <CarouselPrevious className=\"static size-9 translate-x-0 translate-y-0 rounded-none shadow-none\" />\n                <CarouselNext className=\"static size-9 translate-x-0 translate-y-0 rounded-none shadow-none\" />\n              </motion.div>\n            </div>\n          ) : null}\n\n          <div className=\"border\">\n            {loading ? (\n              <span className=\"sr-only\" role=\"status\">\n                Loading categories\n              </span>\n            ) : null}\n            {/* The track shows through the 1px padding between tiles: one ruled object. */}\n            <CarouselContent className=\"-ml-px bg-border\">\n              {tiles.map((item, i) => (\n                <CarouselItem\n                  key={item?.id ?? i}\n                  className=\"basis-[46%] pl-px sm:basis-1/3 md:basis-1/4 lg:basis-1/5\"\n                  onFocus={reveal(i)}\n                  aria-label={item ? item.label : undefined}\n                  aria-hidden={item ? undefined : true}\n                >\n                  {item ? (\n                    <a\n                      href={item.href}\n                      data-slot=\"category-rail-tile\"\n                      className=\"group flex h-full flex-col bg-background outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\"\n                    >\n                      <span className=\"relative block aspect-[4/5] overflow-hidden bg-secondary\">\n                        {item.image ? (\n                          <span className=\"absolute inset-0 transition-transform duration-500 ease-out group-hover:scale-[1.04] [&>img]:size-full [&>img]:object-cover\">\n                            {item.image}\n                          </span>\n                        ) : null}\n                      </span>\n                      <span className=\"flex items-baseline gap-3 px-4 pt-3 pb-4\">\n                        {numbered ? (\n                          <span className=\"font-mono text-[11px] text-muted-foreground tabular-nums\">\n                            {String(i + 1).padStart(2, \"0\")}\n                          </span>\n                        ) : null}\n                        <span className=\"flex min-w-0 flex-col gap-0.5\">\n                          <span className=\"truncate text-sm font-medium underline-offset-4 group-hover:underline\">{item.label}</span>\n                          {item.count !== undefined ? (\n                            <span className=\"text-[13px] text-muted-foreground tabular-nums\">{countText(item.count)}</span>\n                          ) : null}\n                        </span>\n                      </span>\n                    </a>\n                  ) : (\n                    <span className=\"flex h-full flex-col bg-background\">\n                      <Skeleton className=\"aspect-[4/5] w-full rounded-none\" />\n                      <span className=\"flex flex-col gap-2 px-4 pt-3 pb-4\">\n                        <Skeleton className=\"h-3.5 w-20 rounded-none\" />\n                        <Skeleton className=\"h-3 w-14 rounded-none\" />\n                      </span>\n                    </span>\n                  )}\n                </CarouselItem>\n              ))}\n            </CarouselContent>\n          </div>\n        </Carousel>\n      </section>\n    </MotionConfig>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/category-rail.tsx"
    }
  ],
  "categories": [
    "ecommerce",
    "marketing"
  ],
  "type": "registry:block"
}