{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "product-card",
  "type": "registry:ui",
  "title": "Product card",
  "description": "One product in a grid: an inset 4/5 cover, the name and its price (From when the variants differ), as one link to its page. No buy button and no accent. Also the Product type and priceRange, findVariant, defaultPicks and optionsFor for a product page.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/card.json",
    "https://littmd.vercel.app/r/media.json",
    "https://littmd.vercel.app/r/option-picker.json",
    "https://littmd.vercel.app/r/price.json",
    "https://littmd.vercel.app/r/tag.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/product-card.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/product-card 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Product card: one product in a grid, which opens its page (components.md, \"Product card\"). Also the home of the\n * product's shape (Product, ProductVariant, ProductImage) and the small functions a product page needs: the price\n * range, the variant for a set of picks, and which values are in stock among the other picks.\n *\n * - A product is plain data, so a Shopify store, a Stripe store and a file of products use the same card. The\n *   `shopify` item maps the Storefront API into it.\n * - Anatomy: a Card with an inset cover. The cover is a Media at 4/5 by default and the control radius, the product's\n *   first image, or drawn art passed as `cover`. Under it, an optional vendor at the caption size in --litt-fg-subtle,\n *   4px above the title at the body size and the title weight in --litt-fg, and the Price under it (From when the\n *   variants differ in price, the compare-at price struck through when the lowest is on sale).\n * - Flat: the Card is --litt-surface-1 with the float shadow and no hairline, and the grey cover sits inset at the surface radius\n *   less the card's 4px, so it stays concentric.\n * - One link: the title is the card's CardLink, stretched over the card, so the card is one tab stop named by the\n *   product. No buy button and no accent: a grid of cards would be a dozen accent fills (R6), and the card's job is to\n *   open the product. Hover is the Card's: the fill steps once, nothing lifts.\n * - Sold out: a product with nothing in stock keeps its card and adds a neutral \"Sold out\" Tag after the price.\n * - ProductGrid: a ul that measures itself, two across on a phone, then as many 14rem columns as fit, 16px apart.\n *   Every cover shares one shape, so the rows stay even.\n * - Never a second picture on hover, a badge over the picture, or a picture that zooms.\n * - Motion: none of its own. The Card's fill fades on the instant tier.\n */\nimport type { ReactNode } from \"react\";\nimport { Card, CardBody, CardCover, CardLink, CardTitle } from \"./card\";\nimport { Media, type MediaRatio } from \"./media\";\nimport type { OptionValue, ProductOption } from \"./option-picker\";\nimport { Price, type Money } from \"./price\";\nimport { Tag } from \"./tag\";\n\n/** A picture of a product. */\nexport type ProductImage = {\n  src: string;\n  /** What the picture shows, in a sentence. */\n  alt: string;\n  srcSet?: string;\n  /** Its own size in pixels, so its frame keeps its shape before it loads. */\n  width?: number;\n  height?: number;\n};\n\n/** One buyable version of a product: a size in a frame. */\nexport type ProductVariant = {\n  /** The store's id for it, which a cart adds. */\n  id: string;\n  /** Its picks in words: \"A2 / Natural oak\". */\n  title: string;\n  /** False when it is out of stock. */\n  available: boolean;\n  price: Money;\n  /** What it cost before a sale. */\n  compareAt?: Money | null;\n  /** Its value for each option, by the option's name: { Size: \"A2\", Frame: \"Natural oak\" }. */\n  options: Record<string, string>;\n  /** Its own picture, shown when it is picked. */\n  image?: ProductImage;\n};\n\n/** A product, as a store lists it. */\nexport type Product = {\n  id: string;\n  /** Its address in the store: /products/open-water. */\n  handle: string;\n  title: string;\n  /** Who makes it, for a store that sells more than one maker's work. */\n  vendor?: string;\n  /** A paragraph or two about it, as plain text. */\n  description?: string;\n  images: ProductImage[];\n  /** Its options and their values, in order. A product with one variant has none. */\n  options: ProductOption[];\n  variants: ProductVariant[];\n};\n\n/* ----------------------------------------------------------------- what a product page needs */\n\nconst n = (m: Money) => (typeof m.amount === \"number\" ? m.amount : Number.parseFloat(m.amount));\n\n/** The lowest and highest price of a product's variants, and the lowest one's compare-at price. */\nexport function priceRange(product: Product): { min: Money; max: Money; compareAt?: Money | null; varies: boolean } {\n  const sorted = [...product.variants].sort((a, b) => n(a.price) - n(b.price));\n  const low = sorted[0];\n  const high = sorted[sorted.length - 1];\n  if (!low || !high) return { min: { amount: 0, currencyCode: \"EUR\" }, max: { amount: 0, currencyCode: \"EUR\" }, varies: false };\n  return { min: low.price, max: high.price, compareAt: low.compareAt, varies: n(low.price) !== n(high.price) };\n}\n\n/** True when no variant of the product is in stock. */\nexport const isSoldOut = (product: Product) => product.variants.every((v) => !v.available);\n\n/** The variant whose options match every pick, or undefined. */\nexport const findVariant = (product: Product, picks: Record<string, string>) =>\n  product.variants.find((v) => product.options.every((o) => v.options[o.name] === picks[o.name]));\n\n/** The picks a product page opens on: the first variant in stock, else the first one. */\nexport function defaultPicks(product: Product): Record<string, string> {\n  const v = product.variants.find((x) => x.available) ?? product.variants[0];\n  return v ? { ...v.options } : {};\n}\n\n/**\n * The product's options with `available` set on each value for the picks so far: a value is in stock when a variant\n * with it and the other picks is. Pass the result to OptionPicker.\n */\nexport function optionsFor(product: Product, picks: Record<string, string>): ProductOption[] {\n  return product.options.map((o) => ({\n    name: o.name,\n    values: o.values.map(\n      (v): OptionValue => ({\n        ...v,\n        available: product.variants.some(\n          (x) => x.available && x.options[o.name] === v.value && product.options.every((p) => p.name === o.name || x.options[p.name] === picks[p.name]),\n        ),\n      }),\n    ),\n  }));\n}\n\n/* ----------------------------------------------------------------- the card */\n\nexport type ProductCardProps = {\n  product: Product;\n  /** Where the card goes: the product's page. Leave it out and the card is a button; use onOpen. */\n  href?: string;\n  /** Called when the card is pressed, when it has no href. */\n  onOpen?: () => void;\n  /** Drawn art for the cover, in place of the first image. It fills the frame. */\n  cover?: ReactNode;\n  /** The cover's shape: 4/5 (the default, for prints and things you hold), 1/1, 4/3, or a number. One shape for a whole grid. */\n  ratio?: MediaRatio | number;\n  /** How wide the cover is drawn, for the image's srcSet. */\n  sizes?: string;\n  /** The title's heading level, to fit the page's outline. The size never changes. */\n  headingLevel?: \"h2\" | \"h3\" | \"h4\";\n  /** The element: li in a ProductGrid (the default), article on its own. */\n  as?: \"li\" | \"article\" | \"div\";\n  /** The locale for the price. Leave it out for the page's. */\n  locale?: string;\n  className?: string;\n};\n\n/** One product in a grid: its picture, its name and its price, as one link to its page. No buy button. */\nexport function ProductCard({\n  product,\n  href,\n  onOpen,\n  cover,\n  ratio = \"4/5\",\n  sizes = \"(min-width: 64rem) 25vw, 50vw\",\n  headingLevel = \"h3\",\n  as = \"li\",\n  locale,\n  className = \"\",\n}: ProductCardProps) {\n  const image = product.images[0];\n  const range = priceRange(product);\n  const soldOut = isSoldOut(product);\n  return (\n    <Card as={as} cover=\"inset\" className={className}>\n      <CardCover ratio=\"auto\">\n        <Media ratio={ratio} radius=\"control\" className=\"in-[[data-material=flat]]:rounded-[calc(var(--litt-radius-surface)-4px)] in-[[data-material=flat]]:[&>span[aria-hidden=true].pointer-events-none]:shadow-none\" src={cover ? undefined : image?.src} srcSet={image?.srcSet} sizes={sizes} alt={image?.alt ?? \"\"} width={image?.width} height={image?.height}>\n          {cover}\n        </Media>\n      </CardCover>\n      <CardBody>\n        {product.vendor && <p className=\"text-litt-caption font-normal text-litt-fg-subtle\">{product.vendor}</p>}\n        <CardTitle as={headingLevel}>\n          <CardLink href={href} onClick={href === undefined ? onOpen : undefined}>\n            {product.title}\n          </CardLink>\n        </CardTitle>\n        <div className=\"flex flex-wrap items-center gap-2 text-litt-body\">\n          <Price price={range.min} compareAt={range.compareAt} from={range.varies} locale={locale} />\n          {soldOut && <Tag>Sold out</Tag>}\n        </div>\n      </CardBody>\n    </Card>\n  );\n}\n\nexport type ProductGridProps = {\n  /** The ProductCards. */\n  children: ReactNode;\n  /** Names the list for screen readers, such as \"Prints\". */\n  label?: string;\n  className?: string;\n};\n\n/** A grid of ProductCards that measures itself: two across on a phone, then as many 14rem columns as fit. */\nexport function ProductGrid({ children, label, className = \"\" }: ProductGridProps) {\n  return (\n    <div className={\"@container \" + className}>\n      <ul aria-label={label} className=\"grid grid-cols-2 gap-4 @min-[40rem]:grid-cols-[repeat(auto-fill,minmax(14rem,1fr))]\">\n        {children}\n      </ul>\n    </div>\n  );\n}\n",
      "target": "components/litt/product-card.tsx"
    }
  ],
  "docs": "Import ProductCard and ProductGrid from components/litt/product-card. A Product is plain data: the shopify item's toProduct makes one from the Storefront API, or write your own. Give every card in a grid one ratio.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
