{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cover-flow",
  "title": "Cover Flow",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Uma prateleira de capas em perspectiva: a da vez fica de frente, as vizinhas giram de lado e você arrasta ou joga pra trocar.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/visant/ui/cover-flow.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Cover Flow — a prateleira de capas em perspectiva.\n *\n * Grade de fotos mostra tudo ao mesmo tempo e não destaca nada. Carrossel\n * plano destaca uma e esconde o resto. A prateleira em perspectiva faz as duas\n * coisas: a capa da vez fica de frente, inteira, e as vizinhas viram de lado\n * mostrando que a coleção continua. Serve a portfólio, coleção, cardápio de\n * roteiros: qualquer lista em que a imagem vende antes do texto.\n *\n * ── O que se vê, e o que reage a quê ───────────────────────────────────────\n *\n * - A capa do centro de frente; as de cada lado giradas ~48° em Y, empilhadas\n *   para trás, com um reflexo curto no chão.\n * - Arrastar desliza a prateleira junto do dedo (a posição é contínua, não por\n *   degrau); soltar com força joga algumas capas adiante e assenta em mola.\n * - Clique numa capa lateral traz ela para o centro. Setas, Home e End fazem o\n *   mesmo pelo teclado; o título e a legenda embaixo acompanham a capa da vez.\n *\n * ── Decisões que não são óbvias ────────────────────────────────────────────\n *\n * 1. **Uma posição fracionária manda em tudo.** Cada capa só sabe a distância\n *    `o = i − pos` e deriva dela deslocamento, giro, profundidade e ordem. Por\n *    isso o arrasto no meio do caminho já é a animação, e soltar é só levar\n *    `pos` a um inteiro.\n * 2. **O espaçamento encolhe longe do centro.** A primeira vizinha se afasta\n *    72% de uma capa; as seguintes, 26%. Com passo uniforme a prateleira não\n *    cabe em tela nenhuma depois da terceira capa.\n * 3. **Medida sem escala**: o delta do ponteiro é dividido pela escala do\n *    palco (`rect.width / offsetWidth`), senão sob `scale(0.5)` a prateleira\n *    anda a metade do dedo.\n * 4. **O reflexo é `-webkit-box-reflect`**, sem duplicar a mídia. Onde não\n *    existe (Firefox), simplesmente não há reflexo: é enfeite, não informação.\n * 5. **Hierarquia em três sinais juntos.** A da vez é a única em escala cheia,\n *    sem véu e na frente; as laterais recuam 12%, ganham véu da cor do fundo e\n *    afundam em Z. Um sinal só (giro) deixava cinco capas disputando o olho.\n */\n\nexport interface CoverFlowItem {\n  title: string;\n  caption?: string;\n  /** A capa: imagem, vídeo, qualquer coisa que preencha a caixa. */\n  media: React.ReactNode;\n}\n\nexport interface CoverFlowProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  items: CoverFlowItem[];\n  defaultIndex?: number;\n  onIndexChange?: (index: number) => void;\n  /** Largura da capa em px. A altura segue 4:5. */\n  cardWidth?: number;\n  /** Graus de giro das capas laterais. */\n  tilt?: number;\n  /** Rótulo da prateleira para leitor de tela. */\n  label?: string;\n}\n\nconst MOLA = { type: \"spring\" as const, stiffness: 300, damping: 34, mass: 0.8 };\n\nfunction Capa({\n  pos,\n  i,\n  largura,\n  tilt,\n  children,\n}: {\n  pos: MotionValue<number>;\n  i: number;\n  largura: number;\n  tilt: number;\n  children: React.ReactNode;\n}) {\n  const o = useTransform(pos, (p) => i - p);\n  const x = useTransform(o, (v) => {\n    const a = Math.abs(v);\n    return Math.sign(v) * (Math.min(a, 1) * largura * 0.72 + Math.max(a - 1, 0) * largura * 0.26);\n  });\n  const rotateY = useTransform(o, (v) => -Math.max(-1, Math.min(1, v)) * tilt);\n  const z = useTransform(o, (v) => -Math.min(Math.abs(v), 4) * 90);\n  // A da vez é a única em tamanho cheio: as vizinhas recuam um degrau, e a\n  // hierarquia se lê antes do título.\n  const scale = useTransform(o, (v) => 1 - Math.min(Math.abs(v), 1) * 0.12);\n  const zIndex = useTransform(o, (v) => 100 - Math.round(Math.abs(v) * 10));\n  const opacity = useTransform(o, (v) => Math.max(0, Math.min(1, 3.6 - Math.abs(v))));\n  // Véu e não `opacity` pra apagar as laterais: capa translúcida deixaria ver a\n  // de trás através dela, e a pilha viraria um borrão. O véu é da cor do\n  // fundo, então a lateral \"some no papel\" igual nos dois temas.\n  const veu = useTransform(o, (v) => Math.min(0.62, Math.abs(v) * 0.48));\n\n  return (\n    <motion.div\n      aria-hidden\n      data-capa={i}\n      className=\"absolute top-0 left-1/2 aspect-[4/5] cursor-pointer overflow-hidden rounded-[var(--r-surface)] bg-muted\"\n      style={{\n        width: largura,\n        marginLeft: -largura / 2,\n        x,\n        z,\n        scale,\n        rotateY,\n        zIndex,\n        opacity,\n        boxShadow:\n          \"0 1px 0 color-mix(in oklab, var(--foreground) 8%, transparent) inset, 0 26px 44px -18px color-mix(in oklab, black 62%, transparent)\",\n        WebkitBoxReflect:\n          \"below 6px linear-gradient(transparent 76%, color-mix(in oklab, var(--foreground) 14%, transparent))\",\n      }}\n    >\n      {children}\n      <motion.span\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 bg-[var(--background)]\"\n        style={{ opacity: veu }}\n      />\n    </motion.div>\n  );\n}\n\nexport function CoverFlow({\n  items,\n  defaultIndex = 0,\n  onIndexChange,\n  cardWidth = 180,\n  tilt = 48,\n  label = \"Coleção\",\n  className,\n  ...props\n}: CoverFlowProps) {\n  const reduzir = useReducedMotion();\n  const ultimo = items.length - 1;\n  const pos = useMotionValue(defaultIndex);\n  const [indice, setIndice] = React.useState(defaultIndex);\n  const alvoRef = React.useRef(defaultIndex);\n  const palco = React.useRef<HTMLDivElement>(null);\n  const passo = cardWidth * 0.72;\n\n  useMotionValueEvent(pos, \"change\", (p) => {\n    const r = Math.max(0, Math.min(ultimo, Math.round(p)));\n    setIndice((atual) => (atual === r ? atual : r));\n  });\n\n  const ir = (n: number) => {\n    const alvo = Math.max(0, Math.min(ultimo, n));\n    if (reduzir) pos.set(alvo);\n    else animate(pos, alvo, MOLA);\n    if (alvo !== alvoRef.current) {\n      alvoRef.current = alvo;\n      onIndexChange?.(alvo);\n    }\n  };\n\n  const gesto = React.useRef<{\n    x0: number;\n    p0: number;\n    escala: number;\n    t: number;\n    v: number;\n    ult: number;\n    andou: boolean;\n    capa: number | null;\n  } | null>(null);\n\n  const atual = items[indice];\n  const altura = cardWidth * 1.25;\n\n  return (\n    <div\n      role=\"group\"\n      aria-roledescription=\"prateleira\"\n      aria-label={label}\n      className={cn(\"flex w-full flex-col items-center gap-4\", className)}\n      {...props}\n    >\n      <div\n        ref={palco}\n        tabIndex={0}\n        aria-label={`${label}: ${indice + 1} de ${items.length}. Use as setas para trocar.`}\n        className=\"relative w-full touch-pan-y cursor-grab overflow-hidden select-none rounded-[var(--r-surface)] outline-none focus-visible:[box-shadow:var(--focus)] active:cursor-grabbing\"\n        style={{ height: altura + cardWidth * 0.3, perspective: cardWidth * 5 }}\n        onKeyDown={(e) => {\n          const mapa: Record<string, number> = {\n            ArrowLeft: alvoRef.current - 1,\n            ArrowRight: alvoRef.current + 1,\n            Home: 0,\n            End: ultimo,\n          };\n          if (!(e.key in mapa)) return;\n          e.preventDefault();\n          ir(mapa[e.key]);\n        }}\n        // Imagem dentro da peça dispara o arrasto nativo do navegador, que cancela\n        // o ponteiro no meio do gesto.\n        onDragStart={(e) => e.preventDefault()}\n        onPointerDown={(e) => {\n          if (e.button !== 0) return;\n          const el = palco.current!;\n          const escala = el.getBoundingClientRect().width / (el.offsetWidth || 1);\n          el.setPointerCapture(e.pointerId);\n          pos.stop();\n          // Com o ponteiro capturado, o clique não chega na capa: guarda aqui\n          // qual foi tocada e decide no soltar se foi toque ou arrasto.\n          const capa = (e.target as HTMLElement).closest<HTMLElement>(\"[data-capa]\");\n          gesto.current = {\n            capa: capa ? Number(capa.dataset.capa) : null,\n            x0: e.clientX,\n            p0: pos.get(),\n            escala,\n            t: performance.now(),\n            v: 0,\n            ult: pos.get(),\n            andou: false,\n          };\n        }}\n        onPointerMove={(e) => {\n          const g = gesto.current;\n          if (!g) return;\n          const dx = (e.clientX - g.x0) / g.escala;\n          if (Math.abs(dx) > 4) g.andou = true;\n          // Além das pontas, resiste: anda um terço do dedo.\n          let p = g.p0 - dx / passo;\n          if (p < 0) p /= 3;\n          if (p > ultimo) p = ultimo + (p - ultimo) / 3;\n          const agora = performance.now();\n          g.v = (p - g.ult) / Math.max(1, agora - g.t);\n          g.ult = p;\n          g.t = agora;\n          pos.set(p);\n        }}\n        onPointerUp={() => {\n          const g = gesto.current;\n          if (!g) return;\n          gesto.current = null;\n          if (!g.andou && g.capa !== null) {\n            ir(g.capa);\n            return;\n          }\n          // Peteleco: a velocidade (capas/ms) projeta onde a prateleira pararia.\n          ir(Math.round(pos.get() + g.v * 180));\n        }}\n        onPointerCancel={() => {\n          gesto.current = null;\n          ir(Math.round(pos.get()));\n        }}\n      >\n        <div className=\"absolute inset-0 top-2 [transform-style:preserve-3d]\">\n          {items.map((item, i) => (\n            <Capa\n              key={i}\n              pos={pos}\n              i={i}\n              largura={cardWidth}\n              tilt={tilt}\n            >\n              {item.media}\n            </Capa>\n          ))}\n        </div>\n      </div>\n\n      <div aria-live=\"polite\" className=\"flex min-h-11 flex-col items-center text-center\">\n        <span className=\"text-base font-medium text-foreground\">{atual?.title}</span>\n        {atual?.caption && <span className=\"text-sm text-muted-foreground\">{atual.caption}</span>}\n      </div>\n\n      <div className=\"flex items-center gap-1.5\" aria-hidden>\n        {items.map((_, i) => (\n          <button\n            key={i}\n            type=\"button\"\n            tabIndex={-1}\n            onClick={() => ir(i)}\n            className={cn(\n              // motion-lint-disable-next-line layout-property-animation  o ponto vira traço sem deformar a ponta redonda e abre espaço nos vizinhos\n              \"h-1.5 rounded-[var(--r-pill)] transition-[width,background-color] duration-[var(--dur-base)] ease-[var(--ease-out)]\",\n              i === indice ? \"w-5 bg-foreground\" : \"w-1.5 bg-border hover:bg-muted-foreground\",\n            )}\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Galeria de capas em perspectiva com arrasto, pra portfólio, discos ou livros em destaque",
      "evite": "Muitos itens pra navegar: rail; fotos em mosaico, masonry-gallery"
    }
  },
  "categories": [
    "conteudo",
    "galeria",
    "carrossel",
    "3d",
    "portfolio",
    "arrastar",
    "capas"
  ],
  "type": "registry:ui"
}