{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bento-product",
  "title": "Bento product",
  "description": "A product section in Tiles: a large media tile that cross-fades between variants chosen with pills, plus facts beside it.",
  "registryDependencies": [
    "utils",
    "@manniche/chart-kit",
    "@manniche/data-tile",
    "@manniche/use-reduced-motion"
  ],
  "files": [
    {
      "path": "registry/manniche/bento-product/bento-product.tsx",
      "content": "// Bento product: a product showcase made of tiles. A large media tile (any `ReactNode`, so a Tiles-styled SVG, a\n// photo or a render), a tile with the variant switch (`Pills`), a price tile with the price and the one primary\n// action, and a row of three or four spec tiles with mono values.\n//\n// Signature: switching variant cross-fades the media (opacity only, 300 ms) and rolls the price (`BigNumber`). The\n// media tile's mono caption names the chosen variant in words, so colour never carries the choice alone.\n//\n// Screen readers: one `h2` (the product), the variants as a radio group read as \"<variantLabel>\", and an `img` role on\n// the media named by the variant's `mediaLabel`. The price is read once in full. Specs are a description list.\n// Reduced motion: the media swaps at once and the price shows its new value without rolling.\nimport { useEffect, useId, useState, type ComponentProps, type ReactNode } from 'react'\nimport { cn } from '@/lib/utils'\nimport { BigNumber, Pills } from '@/registry/manniche/chart-kit/chart-kit'\nimport type { ValueFormat } from '@/registry/manniche/chart-kit/chart-utils'\nimport { DataTile } from '@/registry/manniche/data-tile/data-tile'\nimport { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'\n\nexport type ProductVariant = {\n  /** A stable key for the variant. */\n  id: string\n  /** The variant's name on the switch and in the caption: \"Sage\". */\n  label: string\n  /** The price of this variant. */\n  price: number\n  /** What the media shows, read aloud and shown in the caption: \"Desk lamp in sage\". Default the label. */\n  mediaLabel?: string\n}\n\nexport type ProductSpec = {\n  /** What is measured: \"Weight\". */\n  label: string\n  /** The value, set in mono: \"1.2 kg\". */\n  value: string\n}\n\nexport type BentoProductProps = Omit<ComponentProps<'section'>, 'children' | 'title'> & {\n  /** The product's name, shown as the section's `h2`. */\n  name: ReactNode\n  /** A short line under the name. */\n  intro?: ReactNode\n  /** A small mono label above the name, such as the maker. */\n  eyebrow?: ReactNode\n  /** The picture: a node, or a function that gets the chosen variant (so the picture can change with it). */\n  media: ReactNode | ((variant: ProductVariant) => ReactNode)\n  /** The variants, two to four. */\n  variants: readonly ProductVariant[]\n  /** The variant chosen first when `variantId` is not controlled. Default the first. */\n  defaultVariantId?: string\n  /** The chosen variant, when you control it. */\n  variantId?: string\n  /** Called when the visitor picks another variant. */\n  onVariantChange?: (id: string) => void\n  /** The group's name on the switch and its tile: \"Colour\". */\n  variantLabel: string\n  /** The name of the price tile. Default \"Price\". */\n  priceLabel?: string\n  /** The specs, three or four. */\n  specs: readonly ProductSpec[]\n  /** How the price is written. Default euros, no decimals. */\n  priceFormat?: ValueFormat\n  /** Fine print under the price: \"Incl. VAT. Ships in 3 to 5 days.\" */\n  priceNote?: ReactNode\n  /** The one primary action. */\n  action: { label: string; href: string }\n}\n\nexport function BentoProduct({\n  name,\n  intro,\n  eyebrow,\n  media,\n  variants,\n  defaultVariantId,\n  variantId,\n  onVariantChange,\n  variantLabel,\n  specs,\n  priceLabel = 'Price',\n  priceFormat = { currency: 'EUR' },\n  priceNote,\n  action,\n  className,\n  ...rest\n}: BentoProductProps) {\n  const headingId = useId()\n  const reduced = useReducedMotion()\n  const [own, setOwn] = useState(defaultVariantId ?? variants[0]?.id)\n  const id = variantId ?? own\n  const variant = variants.find((v) => v.id === id) ?? variants[0]\n\n  // The picture that was showing stays under the new one until the new one has faded in.\n  const [layers, setLayers] = useState<{ cur: ProductVariant; prev: ProductVariant | null }>({ cur: variant, prev: null })\n  const [out, setOut] = useState(false)\n  if (layers.cur.id !== variant.id) {\n    setLayers({ cur: variant, prev: reduced ? null : layers.cur })\n    setOut(false)\n  }\n  useEffect(() => {\n    if (!layers.prev) return\n    const raf = requestAnimationFrame(() => setOut(true))\n    const end = window.setTimeout(() => setLayers((l) => ({ ...l, prev: null })), 340)\n    return () => {\n      cancelAnimationFrame(raf)\n      window.clearTimeout(end)\n    }\n  }, [layers])\n\n  const pick = (next: string) => {\n    setOwn(next)\n    onVariantChange?.(next)\n  }\n  const render = (v: ProductVariant) => (typeof media === 'function' ? media(v) : media)\n  const spans = specs.length === 3 ? '@3xl:grid-cols-3' : '@3xl:grid-cols-4'\n\n  return (\n    <section aria-labelledby={headingId} className={cn('@container w-full py-12 @3xl:py-20', className)} {...rest}>\n      <div className=\"mx-auto max-w-6xl px-4 sm:px-6\">\n        <header className=\"mb-8 grid max-w-2xl gap-3 @3xl:mb-12\">\n          {eyebrow && <p className=\"font-mono text-xs tracking-[0.04em] text-muted-foreground tabular-nums\">{eyebrow}</p>}\n          <h2\n            id={headingId}\n            className=\"text-[clamp(30px,5.4cqw,52px)] leading-[1.02] font-extrabold tracking-[-0.035em] text-balance text-foreground\"\n            style={{ fontFamily: 'var(--font-display, inherit)', fontStretch: '86%' }}\n          >\n            {name}\n          </h2>\n          {intro && <p className=\"text-base leading-relaxed text-pretty text-muted-foreground\">{intro}</p>}\n        </header>\n\n        <div className=\"grid grid-cols-1 gap-3 @2xl:gap-4 @3xl:grid-cols-3\">\n          <div className=\"relative min-h-72 overflow-hidden rounded-[calc(var(--radius)*2+2px)] bg-card shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent),0_1px_2px_rgba(0,0,0,0.03)] @3xl:col-span-2 @3xl:row-span-2 @3xl:min-h-[28rem]\">\n            <div role=\"img\" aria-label={variant.mediaLabel ?? variant.label} className=\"absolute inset-0\">\n              {layers.prev && (\n                <div\n                  aria-hidden\n                  className=\"absolute inset-0 transition-opacity duration-300 ease-out-quint\"\n                  style={{ opacity: out ? 0 : 1 }}\n                >\n                  {render(layers.prev)}\n                </div>\n              )}\n              <div\n                key={layers.cur.id}\n                className={cn('absolute inset-0', layers.prev && 'transition-opacity duration-300 ease-out-quint')}\n                style={{ opacity: layers.prev && !out ? 0 : 1 }}\n              >\n                {render(layers.cur)}\n              </div>\n            </div>\n            <span\n              aria-hidden\n              className=\"absolute bottom-4 left-4 rounded-full bg-card px-3 py-1.5 shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent)] font-mono text-[11.5px] leading-none text-muted-foreground tabular-nums\"\n            >\n              {variantLabel}: {variant.label}\n            </span>\n          </div>\n\n          <DataTile title={variantLabel}>\n            <Pills\n              label={variantLabel}\n              options={variants.map((v) => ({ id: v.id, label: v.label }))}\n              value={variant.id}\n              onChange={pick}\n              className=\"max-w-full\"\n            />\n          </DataTile>\n\n          <DataTile title={priceLabel}>\n            <div className=\"grid gap-5\">\n              <div className=\"grid gap-1.5\">\n                <BigNumber value={variant.price} format={priceFormat} size=\"xl\" />\n                {priceNote && <p className=\"text-[13.5px] leading-normal text-pretty text-muted-foreground\">{priceNote}</p>}\n              </div>\n              <a\n                href={action.href}\n                className=\"inline-flex min-h-11 items-center justify-center rounded-full bg-primary px-6 text-[15px] font-medium text-primary-foreground transition-opacity duration-150 ease-out-quint hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\"\n              >\n                {action.label}\n              </a>\n            </div>\n          </DataTile>\n\n          <dl className={cn('grid grid-cols-2 gap-3 @2xl:gap-4 @3xl:col-span-3', spans)}>\n            {specs.map((s) => (\n              <div\n                key={s.label}\n                className=\"grid content-between gap-6 rounded-[calc(var(--radius)*2+2px)] bg-card p-[18px] shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent),0_1px_2px_rgba(0,0,0,0.03)]\"\n              >\n                <dt className=\"text-sm leading-[1.3] font-medium text-muted-foreground\">{s.label}</dt>\n                <dd className=\"font-mono text-[15px] tracking-[0.01em] break-words tabular-nums\">{s.value}</dd>\n              </div>\n            ))}\n          </dl>\n        </div>\n      </div>\n    </section>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "templates"
  ],
  "type": "registry:block"
}