{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lookbook-strip",
  "title": "Lookbook Strip",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "A campaign photo you can shop: numbered hairline-ring hotspots, placed by percentage with their own positions for a mobile crop, each opening its product on your shadcn Popover — on hover with a pointer, on tap or Enter otherwise. The card grows out of the hotspot and folds back into it, held open until the fold has played (Motion; a fade under reduced motion). The same products are listed under the image, and hovering one marks its hotspot.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "popover"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/lookbook-strip/lookbook-strip.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowUpRight } from \"lucide-react\"\nimport { MotionConfig, motion, useReducedMotion } from \"motion/react\"\n\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\"\nimport { cn } from \"@/lib/utils\"\n\n/*\n * A campaign photo you can shop: numbered hotspots on the image, each\n * opening the product it points at, and the same products listed under it.\n *\n *   <LookbookStrip\n *     image={<img src=\"/look.jpg\" alt=\"…\" />}\n *     mobileImage={<img src=\"/look-portrait.jpg\" alt=\"…\" />}\n *     hotspots={[{ id: \"coat\", x: 38, y: 42, mobile: { x: 52, y: 30 }, product }]}\n *   />\n *\n * Hotspots are hairline rings, not filled dots — they read as annotation.\n * Positions are percentages of the image, so they hold at any width; a\n * phone usually gets a different crop, so each hotspot can carry its own\n * `mobile` position for `mobileImage`. A hotspot opens on hover with a\n * pointer, and on tap or Enter otherwise; the card grows out of the\n * hotspot and folds back into it (Motion; a fade under reduced motion),\n * kept open until the fold has played. Hovering a product in the list\n * marks its hotspot. Your shadcn Popover does the positioning and focus.\n */\n\nexport interface LookbookProduct {\n  name: string\n  href: string\n  /** Major units: 420 for $420. */\n  price: number\n  /** Was-price; struck through when higher. */\n  compareAt?: number\n  brand?: string\n  /** Already rendered — a thumbnail for the card. */\n  image?: React.ReactNode\n}\n\nexport interface LookbookHotspot {\n  id: string\n  /** Percent from the left of `image`, 0–100. */\n  x: number\n  /** Percent from the top of `image`, 0–100. */\n  y: number\n  /** Where it sits on `mobileImage`, whose crop differs. Defaults to x / y. */\n  mobile?: { x: number; y: number }\n  product: LookbookProduct\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\nexport interface LookbookStripProps extends Omit<React.HTMLAttributes<HTMLElement>, \"title\"> {\n  /** Already rendered; it fills the strip. */\n  image: React.ReactNode\n  /** A different crop for phones (below md). Give it `loading=\"lazy\"` so only one loads. */\n  mobileImage?: React.ReactNode\n  hotspots: LookbookHotspot[]\n  title?: React.ReactNode\n  /** The strip's shape from md up. Defaults to \"16 / 7\". */\n  aspectRatio?: string\n  /** On a phone. Defaults to \"4 / 5\". */\n  mobileAspectRatio?: string\n  currency?: string\n  /** `undefined` follows the visitor's locale, once hydrated. */\n  locale?: string\n  /** Lists the products under the image. Defaults to true. */\n  showList?: boolean\n}\n\nexport function LookbookStrip({\n  image,\n  mobileImage,\n  hotspots,\n  title,\n  aspectRatio = \"16 / 7\",\n  mobileAspectRatio = \"4 / 5\",\n  currency = \"USD\",\n  locale: localeProp,\n  showList = true,\n  className,\n  ...props\n}: LookbookStripProps) {\n  const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false)\n  const locale = localeProp ?? (hydrated ? undefined : \"en-US\")\n  const money = React.useMemo(() => new Intl.NumberFormat(locale, { style: \"currency\", currency }), [locale, currency])\n  const headingId = React.useId()\n  const [marked, setMarked] = React.useState<string | null>(null)\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <section\n        data-slot=\"lookbook-strip\"\n        aria-labelledby={title ? headingId : undefined}\n        className={cn(\"flex flex-col gap-5\", className)}\n        {...props}\n      >\n        {title ? (\n          <h2 id={headingId} className=\"border-b pb-4 text-2xl\">\n            {title}\n          </h2>\n        ) : null}\n\n        <div\n          data-slot=\"lookbook-strip-media\"\n          className=\"relative isolate overflow-hidden bg-secondary [aspect-ratio:var(--lookbook-mobile-ratio)] md:[aspect-ratio:var(--lookbook-ratio)]\"\n          style={{ \"--lookbook-ratio\": aspectRatio, \"--lookbook-mobile-ratio\": mobileImage ? mobileAspectRatio : aspectRatio } as React.CSSProperties}\n        >\n          <div className={cn(\"absolute inset-0 -z-10 [&>img]:size-full [&>img]:object-cover\", mobileImage && \"max-md:hidden\")}>{image}</div>\n          {mobileImage ? <div className=\"absolute inset-0 -z-10 md:hidden [&>img]:size-full [&>img]:object-cover\">{mobileImage}</div> : null}\n\n          {hotspots.map((spot, i) => (\n            <Hotspot key={spot.id} spot={spot} index={i} money={money} marked={marked === spot.id} />\n          ))}\n        </div>\n\n        {showList ? (\n          <ol data-slot=\"lookbook-strip-list\" aria-label=\"In this look\" className=\"grid border-t sm:grid-cols-2 lg:grid-cols-4\">\n            {hotspots.map((spot, i) => (\n              <li key={spot.id} className=\"border-b sm:odd:border-r lg:border-r lg:[&:nth-child(4n)]:border-r-0\">\n                <a\n                  href={spot.product.href}\n                  onPointerEnter={() => setMarked(spot.id)}\n                  onPointerLeave={() => setMarked(null)}\n                  onFocus={() => setMarked(spot.id)}\n                  onBlur={() => setMarked(null)}\n                  className=\"group flex items-baseline gap-3 px-4 py-3.5 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\"\n                >\n                  <span className=\"font-mono text-[11px] text-muted-foreground tabular-nums\">{String(i + 1).padStart(2, \"0\")}</span>\n                  <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n                    <span className=\"truncate text-sm underline-offset-4 group-hover:underline\">{spot.product.name}</span>\n                    {spot.product.brand ? <span className=\"text-[12.5px] text-muted-foreground\">{spot.product.brand}</span> : null}\n                  </span>\n                  <Price product={spot.product} money={money} />\n                </a>\n              </li>\n            ))}\n          </ol>\n        ) : null}\n      </section>\n    </MotionConfig>\n  )\n}\n\nfunction Price({ product, money }: { product: LookbookProduct; money: Intl.NumberFormat }) {\n  const sale = product.compareAt !== undefined && product.compareAt > product.price\n  return (\n    <span className=\"flex shrink-0 items-baseline gap-1.5 text-sm tabular-nums\">\n      <span>{money.format(product.price)}</span>\n      {sale ? (\n        <s className=\"text-[12.5px] text-muted-foreground\">\n          <span className=\"sr-only\">was </span>\n          {money.format(product.compareAt!)}\n        </s>\n      ) : null}\n    </span>\n  )\n}\n\n/** One ring on the photo, and the card it opens. */\nfunction Hotspot({\n  spot,\n  index,\n  money,\n  marked,\n}: {\n  spot: LookbookHotspot\n  index: number\n  money: Intl.NumberFormat\n  marked: boolean\n}) {\n  const reduce = useReducedMotion()\n  // `open` is what was asked for; `mounted` keeps the Popover open until\n  // the fold back into the hotspot has played.\n  const [open, setOpen] = React.useState(false)\n  const [mounted, setMounted] = React.useState(false)\n  if (open && !mounted) setMounted(true)\n  const timer = React.useRef(0)\n  React.useEffect(() => () => window.clearTimeout(timer.current), [])\n\n  const show = () => {\n    window.clearTimeout(timer.current)\n    setOpen(true)\n  }\n  // A short grace, so the pointer can cross from the ring to the card.\n  const hideSoon = () => {\n    window.clearTimeout(timer.current)\n    timer.current = window.setTimeout(() => setOpen(false), 160)\n  }\n  // Hover is for a mouse; touch and pens open it with a tap, through the Popover.\n  const onPointerEnter = (event: React.PointerEvent) => {\n    if (event.pointerType === \"mouse\") show()\n  }\n  const onPointerLeave = (event: React.PointerEvent) => {\n    if (event.pointerType === \"mouse\") hideSoon()\n  }\n\n  const at = spot.mobile ?? spot\n  const position = {\n    \"--hx\": `${spot.x}%`,\n    \"--hy\": `${spot.y}%`,\n    \"--mx\": `${at.x}%`,\n    \"--my\": `${at.y}%`,\n  } as React.CSSProperties\n  const sale = spot.product.compareAt !== undefined && spot.product.compareAt > spot.product.price\n\n  return (\n    <Popover open={mounted} onOpenChange={(next: boolean) => (next ? show() : setOpen(false))}>\n      <PopoverTrigger\n        data-slot=\"lookbook-strip-hotspot\"\n        aria-label={`${index + 1}: ${spot.product.name}, ${money.format(spot.product.price)}${sale ? \" on sale\" : \"\"}`}\n        onPointerEnter={onPointerEnter}\n        onPointerLeave={onPointerLeave}\n        style={position}\n        className={cn(\n          \"absolute top-[var(--my)] left-[var(--mx)] grid size-9 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full outline-none md:top-[var(--hy)] md:left-[var(--hx)]\",\n          \"focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-black/40\"\n        )}\n      >\n        {/* The hairline ring, with the number it shares with the list. */}\n        <span\n          aria-hidden\n          className={cn(\n            \"grid size-7 place-items-center rounded-full border border-white/95 bg-black/15 font-mono text-[10.5px] text-white shadow-[0_0_0_1px_rgba(0,0,0,0.12)] backdrop-blur-[2px] transition-transform duration-300\",\n            (marked || open) && \"scale-125 bg-black/35\"\n          )}\n        >\n          {index + 1}\n        </span>\n        <span aria-hidden className=\"absolute inset-0 rounded-full border border-white/60 motion-safe:animate-ping [animation-duration:2.4s]\" />\n      </PopoverTrigger>\n      <PopoverContent\n        side=\"top\"\n        sideOffset={10}\n        // Motion runs the open and close; the primitive's keyframes would fight it.\n        className=\"w-auto animate-none! border-0 bg-transparent p-0 shadow-none\"\n        onPointerEnter={onPointerEnter}\n        onPointerLeave={onPointerLeave}\n      >\n        <motion.div\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n          animate={open ? { opacity: 1, scale: 1 } : reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n          transition={reduce ? { duration: 0.15 } : MORPH}\n          onAnimationComplete={() => {\n            if (!open) setMounted(false)\n          }}\n          // Grow from the side the hotspot is on: both bases set an origin variable.\n          style={{ transformOrigin: \"var(--radix-popover-content-transform-origin, var(--transform-origin, center bottom))\" }}\n          className=\"flex w-64 gap-3 border bg-popover p-3 text-popover-foreground shadow-lg\"\n        >\n          {spot.product.image ? (\n            <span className=\"relative size-16 shrink-0 overflow-hidden bg-secondary [&>img]:size-full [&>img]:object-cover\">\n              {spot.product.image}\n            </span>\n          ) : null}\n          <span className=\"flex min-w-0 flex-1 flex-col gap-1\">\n            {spot.product.brand ? (\n              <span className=\"text-[11px] tracking-[0.14em] text-muted-foreground uppercase\">{spot.product.brand}</span>\n            ) : null}\n            <span className=\"text-sm leading-snug font-medium\">{spot.product.name}</span>\n            <Price product={spot.product} money={money} />\n            <a\n              href={spot.product.href}\n              className=\"mt-1 inline-flex items-center gap-1 self-start text-[12.5px] underline underline-offset-4 hover:text-muted-foreground\"\n            >\n              View product <ArrowUpRight className=\"size-3.5\" aria-hidden />\n            </a>\n          </span>\n        </motion.div>\n      </PopoverContent>\n    </Popover>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/lookbook-strip.tsx"
    }
  ],
  "categories": [
    "ecommerce",
    "marketing"
  ],
  "type": "registry:block"
}