{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "saved-board",
  "title": "Saved board",
  "description": "A saved-items board as a frosted pocket: up to five covers fan out of the top and bob gently on their own beats, with the board's name, owner and item count on the glass.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/items/saved-board/saved-board.tsx",
      "content": "\"use client\";\n/* eslint-disable @next/next/no-img-element -- Portable registry component; covers are passed as image URLs. */\n\nimport * as React from \"react\";\nimport { motion, useReducedMotionConfig } from \"motion/react\";\nimport { Bookmark } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type SavedBoardImage = string | { src: string; alt?: string };\n\nexport interface SavedBoardProps\n  extends Omit<React.HTMLAttributes<HTMLElement>, \"title\" | \"children\"> {\n  /** The board's name, on the glass. */\n  title: string;\n  /** Up to five cover images; they fan out of the card behind the glass. */\n  images: SavedBoardImage[];\n  /** How many items the board holds. Defaults to the number of images. */\n  itemCount?: number;\n  /** Shown after the item count when given: \"12 items • 1,204 views\". */\n  views?: number;\n  /** Who made the board: a small avatar and name under the title. */\n  owner?: { name: string; avatarSrc?: string };\n  /** Card width in pixels; the card is 1.15 times as tall. Defaults to 228. */\n  size?: number;\n  /** Makes the card a link. */\n  href?: string;\n  onClick?: React.MouseEventHandler<HTMLElement>;\n}\n\ntype Fan = {\n  idx: number;\n  left?: number;\n  right?: number;\n  bottom: number;\n  rotate: number;\n  w: number;\n  h: number;\n  z: number;\n  /** Which float curve the image rides, so neighbours never bob in step. */\n  float: number;\n};\n\n/** Per fan position: leg duration (ms), amplitude (px) and start delay (ms). */\nconst FLOAT_MS = [1400, 1700, 1200, 1600, 2000];\nconst FLOAT_PX = [3.5, 4, 3, 4.5, 3.5];\nconst FLOAT_DELAY = [0, 300, 150, 450, 200];\n\n/** The fan for one to five images, in fractions of the card width. Centre images sit in flow. */\nfunction fan(count: number, S: number): Fan[] {\n  if (count === 1) return [{ idx: 0, float: 0, bottom: 6, rotate: 0, w: S * 0.44, h: S * 0.56, z: 1 }];\n  if (count === 2)\n    return [\n      { idx: 0, float: 0, left: S * 0.1, bottom: -4, rotate: -12, w: S * 0.4, h: S * 0.52, z: 1 },\n      { idx: 1, float: 1, right: S * 0.1, bottom: -4, rotate: 12, w: S * 0.4, h: S * 0.52, z: 1 },\n    ];\n  if (count === 3)\n    return [\n      { idx: 1, float: 0, left: S * 0.06, bottom: -8, rotate: -15, w: S * 0.36, h: S * 0.48, z: 1 },\n      { idx: 0, float: 1, bottom: 6, rotate: 0, w: S * 0.42, h: S * 0.54, z: 2 },\n      { idx: 2, float: 2, right: S * 0.06, bottom: -8, rotate: 15, w: S * 0.36, h: S * 0.48, z: 1 },\n    ];\n  if (count === 4)\n    return [\n      { idx: 1, float: 0, left: S * 0.01, bottom: -10, rotate: -20, w: S * 0.32, h: S * 0.42, z: 1 },\n      { idx: 2, float: 1, left: S * 0.14, bottom: -4, rotate: -8, w: S * 0.36, h: S * 0.48, z: 2 },\n      { idx: 3, float: 2, right: S * 0.14, bottom: -4, rotate: 8, w: S * 0.36, h: S * 0.48, z: 2 },\n      { idx: 0, float: 3, right: S * 0.01, bottom: -10, rotate: 20, w: S * 0.32, h: S * 0.42, z: 1 },\n    ];\n  if (count >= 5)\n    return [\n      { idx: 3, float: 0, left: S * -0.02, bottom: -12, rotate: -24, w: S * 0.3, h: S * 0.4, z: 1 },\n      { idx: 1, float: 1, left: S * 0.1, bottom: -6, rotate: -12, w: S * 0.34, h: S * 0.44, z: 2 },\n      { idx: 0, float: 2, bottom: 6, rotate: 0, w: S * 0.4, h: S * 0.52, z: 3 },\n      { idx: 2, float: 3, right: S * 0.1, bottom: -6, rotate: 12, w: S * 0.34, h: S * 0.44, z: 2 },\n      { idx: 4, float: 4, right: S * -0.02, bottom: -12, rotate: 24, w: S * 0.3, h: S * 0.4, z: 1 },\n    ];\n  return [];\n}\n\nfunction toImage(image: SavedBoardImage) {\n  return typeof image === \"string\" ? { src: image, alt: \"\" } : { alt: \"\", ...image };\n}\n\n/**\n * Steeze's suggested-board card: a board's covers fan out of a frosted pocket\n * like photos from a folder, each one bobbing gently on its own beat, with the\n * board's name, owner and count printed on the glass.\n *\n * The glass is ink at 45% under a light blur, and its text is on-solid, so it\n * reads light-on-dark pages and dark-on-light ones. Reduced motion holds the\n * covers still.\n */\nexport function SavedBoard({\n  title,\n  images,\n  itemCount,\n  views,\n  owner,\n  size = 228,\n  href,\n  onClick,\n  className,\n  style,\n  ...props\n}: SavedBoardProps) {\n  const reduceMotion = useReducedMotionConfig();\n  const covers = images.slice(0, 5).map(toImage);\n  const items = fan(covers.length, size);\n  const count = itemCount ?? images.length;\n\n  const body = (\n    <>\n      {/* The fan: the top 60%, images anchored to its bottom edge so they dip behind the glass. */}\n      <div className=\"absolute inset-x-0 top-0 z-0 flex h-[60%] items-end justify-center\">\n        {covers.length > 0 ? (\n          items.map((item) => {\n            const centred = item.left === undefined && item.right === undefined;\n            const amplitude = FLOAT_PX[item.float];\n            const cover = covers[item.idx];\n            return (\n              <motion.div\n                key={item.idx}\n                className={cn(centred ? \"relative\" : \"absolute\")}\n                style={{ left: item.left, right: item.right, bottom: item.bottom, zIndex: item.z }}\n                // Rotate first, then bob along the rotated axis, as the app composes it.\n                transformTemplate={({ y }) => `rotate(${item.rotate}deg) translateY(${y ?? \"0px\"})`}\n                initial={{ y: 0 }}\n                animate={reduceMotion ? { y: 0 } : { y: [0, -amplitude, amplitude, -amplitude, 0] }}\n                transition={\n                  reduceMotion\n                    ? { duration: 0 }\n                    : {\n                        // Reanimated's reversing repeat: up, down, up, back to rest, one leg each.\n                        duration: (FLOAT_MS[item.float] * 4) / 1000,\n                        times: [0, 0.25, 0.5, 0.75, 1],\n                        ease: \"easeInOut\",\n                        delay: FLOAT_DELAY[item.float] / 1000,\n                        repeat: Infinity,\n                        repeatType: \"loop\",\n                      }\n                }\n              >\n                <div className=\"overflow-hidden rounded-md bg-card shadow-md\" style={{ width: item.w, height: item.h }}>\n                  <img src={cover.src} alt={cover.alt} draggable={false} className=\"size-full object-cover\" />\n                </div>\n              </motion.div>\n            );\n          })\n        ) : (\n          <div\n            className=\"flex items-center justify-center rounded-md bg-paper-sunk text-faint\"\n            style={{ width: size * 0.42, height: size * 0.52 }}\n          >\n            <Bookmark className=\"size-6\" aria-hidden=\"true\" />\n          </div>\n        )}\n      </div>\n\n      {/* The pocket: frosted glass over the bottom 55%. */}\n      <div className=\"absolute inset-x-0 bottom-0 z-[1] h-[55%] overflow-hidden rounded-2xl bg-ink/45 shadow-lg backdrop-blur-[6px]\" />\n\n      <div className=\"absolute inset-x-0 bottom-0 z-[2] flex h-[55%] flex-col items-center justify-end px-4 pt-4 pb-3.5 text-on-solid\">\n        <p className=\"mb-1.5 w-full truncate text-center font-heading text-[17px] font-medium tracking-tighter\">\n          {title}\n        </p>\n        {owner ? (\n          <span className=\"mb-1 flex items-center justify-center\">\n            {owner.avatarSrc ? (\n              <img src={owner.avatarSrc} alt=\"\" className=\"size-[18px] shrink-0 rounded-full object-cover\" />\n            ) : (\n              <span\n                aria-hidden=\"true\"\n                className=\"flex size-[18px] shrink-0 items-center justify-center rounded-full bg-on-solid/15 font-body text-[10px] font-semibold\"\n              >\n                {owner.name.slice(0, 1).toUpperCase()}\n              </span>\n            )}\n            <span\n              className=\"ml-1.5 truncate font-body text-xs font-semibold tracking-tighter text-on-solid/75\"\n              style={{ maxWidth: size * 0.5 }}\n            >\n              {owner.name}\n            </span>\n          </span>\n        ) : null}\n        <p className=\"text-center font-heading text-[11px] font-bold text-on-solid/50\">\n          {count} {count === 1 ? \"item\" : \"items\"}\n          {typeof views === \"number\" ? (\n            <>\n              <span className=\"mx-1\">•</span>\n              {views.toLocaleString()} {views === 1 ? \"view\" : \"views\"}\n            </>\n          ) : null}\n        </p>\n      </div>\n    </>\n  );\n\n  const shared = {\n    className: cn(\n      \"relative block shrink-0 touch-manipulation text-left transition-opacity duration-150 select-none active:opacity-85 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring\",\n      (href || onClick) && \"cursor-pointer\",\n      className,\n    ),\n    style: { width: size, height: size * 1.15, ...style },\n  };\n\n  if (href) {\n    return (\n      <a href={href} onClick={onClick} {...shared} {...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)}>\n        {body}\n      </a>\n    );\n  }\n  if (onClick) {\n    return (\n      <button type=\"button\" onClick={onClick} {...shared} {...(props as React.ButtonHTMLAttributes<HTMLButtonElement>)}>\n        {body}\n      </button>\n    );\n  }\n  return (\n    <div {...shared} {...props}>\n      {body}\n    </div>\n  );\n}\n\nexport default SavedBoard;\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "kind": "widget",
    "whenToUse": "Use for collections people make and share, such as boards, wishlists or lookbooks, in a carousel or grid where the covers should do the talking. Not for a single product; use an ordinary product card for that.",
    "origin": {
      "project": "Steeze",
      "path": "components/SuggestedBoardCard.tsx (app_steeze; discovery and search)"
    },
    "credits": [
      {
        "name": "Steeze category photos, found on Pinterest (original photographers unknown); preview fixtures only, not shipped",
        "license": "unknown"
      }
    ]
  },
  "type": "registry:component"
}
