{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "card-stack",
  "title": "Card Stack",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Pilha de cards em que o de cima vai pro fundo quando você arrasta e solta, como num baralho.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/visant/ui/card-stack.tsx",
      "content": "\"use client\";\n\n// Adaptado de Card stack — https://uiarc.dev/components/card-stack — Copyright (c) 2026 Elia Kuratli, licença MIT (texto integral em ATTRIBUTIONS.md do @visant/ui).\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform, type MotionValue, type Transition } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Card Stack: um baralho em que o card de cima é jogado para o fundo.\n *\n * Depoimento, case, foto de portfólio: quando há meia dúzia de peças e espaço\n * para uma, a pilha mostra que existe mais sem virar carrossel. O gesto é o de\n * baralho de verdade: arrasta o de cima, solta, ele sai na direção do arremesso\n * e volta por BAIXO dos outros. Nada se perde, a pilha só gira.\n *\n * Diferente do original (triagem com aceitar e recusar), aqui não há decisão:\n * a peça é de vitrine, não de fila de revisão. Por isso não há desfazer nem\n * estado vazio, e a API fica em quatro props.\n *\n * ── Decisões que não são óbvias ────────────────────────────────────────────\n *\n * 1. **O arremesso tem duas metades.** Primeiro o card sai seguindo o dedo\n *    (ainda por cima de todos), DEPOIS troca de camada e volta para o fundo.\n *    Trocar a camada antes faria o card sumir atrás da pilha no meio do gesto.\n * 2. **Flick conta tanto quanto distância.** Soltar rápido joga mesmo com pouco\n *    caminho; soltar devagar e perto do centro devolve o card para casa na mola,\n *    com a velocidade da soltura, para assentar em vez de parar seco.\n * 3. **Só os quatro de cima aparecem.** Os demais ficam montados e invisíveis\n *    um degrau atrás, para entrar pela mesma mola quando subirem.\n * 3b. **A pilha responde ao gesto, não ao fim dele.** A pose de cada card é uma\n *    profundidade contínua, e o arrasto a empurra: conforme o de cima sai, os de\n *    trás já sobem um degrau. Quando o arremesso se confirma, a pilha já está no\n *    lugar e nada salta. É isso que faz a soltura parecer consequência da mão.\n * 4. **Teclado:** a pilha é uma parada de Tab; setas, Enter e Espaço mandam o\n *    card de cima para o fundo. `touch-action: pan-y` deixa a rolagem vertical\n *    da página livre no telefone.\n */\n\n/** Lido depois de montar: o servidor não sabe do dedo, e a hidratação tem de bater. */\nfunction usePorToque() {\n  const [toque, setToque] = React.useState(false);\n  React.useEffect(() => setToque(window.matchMedia?.(\"(pointer: coarse)\").matches ?? false), []);\n  return toque;\n}\n\nexport interface CardStackProps<T> {\n  /** Os cards, na ordem em que a pilha começa. */\n  items: T[];\n  /** Chave estável por item. */\n  getKey: (item: T) => string;\n  /** Nome curto do card, para o rótulo acessível e o aviso de troca. */\n  getLabel: (item: T) => string;\n  /** O conteúdo de cada card. */\n  renderCard: (item: T) => React.ReactNode;\n  /** Chamado quando um card vai para o fundo, com o item que subiu para o topo. */\n  onCycle?: (top: T) => void;\n  /** Nome acessível da pilha. */\n  label?: string;\n  className?: string;\n}\n\nconst VISIVEIS = 4;\n/** Quanto cada card de trás aparece abaixo do da frente, e quanto encolhe. */\nconst DEGRAU = 12;\nconst ENCOLHE = 0.05;\n/**\n * O leque: cada card de trás sai levemente torto, alternando o lado. Pilha\n * perfeitamente alinhada lê como fatias de interface empilhadas; uns poucos graus\n * pra cada lado é o que faz ler como cartas de verdade numa mesa.\n */\nconst GIRO = [0, -5, 3.5, -2, 0];\n/** Px por segundo a partir do qual a soltura é arremesso. */\nconst FLICK = 500;\n\nconst molaPilha: Transition = { type: \"spring\", visualDuration: 0.34, bounce: 0.06 };\nconst molaVolta: Transition = { type: \"spring\", visualDuration: 0.42, bounce: 0.04 };\n/** Tempo da saída: a troca de camada acontece com o card quase fora da pilha. */\nconst SAIDA = 0.26;\n\ninterface CartaProps {\n  depth: number;\n  total: number;\n  topo: boolean;\n  reduce: boolean;\n  /** Sinal de envio por teclado: muda a cada pedido, com a direção. */\n  envio: { n: number; dir: 1 | -1 } | null;\n  onSend: () => void;\n  /** 0..1: quanto o card de cima já saiu. Compartilhado pela pilha inteira. */\n  avanco: MotionValue<number>;\n  label: string;\n  children: React.ReactNode;\n}\n\nfunction Carta({ depth, total, topo, reduce, envio, onSend, avanco, label, children }: CartaProps) {\n  const porToque = usePorToque();\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const rotate = useTransform(x, [-240, 240], [-10, 10]);\n  const [voando, setVoando] = React.useState(false);\n  const ref = React.useRef<HTMLDivElement>(null);\n\n  // A pose inteira sai de uma profundidade contínua. Ver a nota 3b.\n  const d = Math.min(depth, VISIVEIS);\n  const prof = useMotionValue(d);\n  const poseY = useTransform(prof, (p) => p * DEGRAU);\n  const escala = useTransform(prof, (p) => 1 - p * ENCOLHE);\n  const giro = useTransform(prof, [0, 1, 2, 3, 4], reduce ? [0, 0, 0, 0, 0] : GIRO);\n  const opacidade = useTransform(prof, [VISIVEIS - 1, VISIVEIS], [1, 0]);\n  const veu = useTransform(prof, [0, 3], [0, 0.54]);\n\n  const alvoRef = React.useRef(d);\n  alvoRef.current = d;\n  // O card que acabou de ir para o fundo volta pela própria mola, sem ouvir o avanço.\n  const voltando = React.useRef(false);\n\n  React.useEffect(() => {\n    if (reduce) {\n      prof.jump(d);\n      return;\n    }\n    const a = animate(prof, d, molaVolta);\n    void a.then(() => {\n      voltando.current = false;\n    });\n    return () => a.stop();\n  }, [d, prof, reduce]);\n\n  useMotionValueEvent(avanco, \"change\", (v) => {\n    if (alvoRef.current === 0 || voltando.current) return;\n    prof.set(alvoRef.current - v);\n  });\n\n  useMotionValueEvent(x, \"change\", (v) => {\n    if (!topo) return;\n    const largura = ref.current?.offsetWidth ?? 280;\n    avanco.set(Math.min(1, Math.abs(v) / (largura * 0.6)));\n  });\n\n  const lancar = React.useCallback(\n    async (dir: number, vx = 0, vy = 0) => {\n      if (reduce) {\n        onSend();\n        return;\n      }\n      setVoando(true);\n      voltando.current = true;\n      const largura = ref.current?.offsetWidth ?? 280;\n      // Metade um: sai por cima, seguindo o arremesso, até quase não encostar na pilha.\n      animate(x, dir * largura * 0.92, { type: \"spring\", visualDuration: SAIDA + 0.06, bounce: 0, velocity: vx });\n      animate(y, y.get() - 12, { type: \"spring\", visualDuration: SAIDA + 0.06, bounce: 0, velocity: vy });\n      await new Promise((resolve) => setTimeout(resolve, SAIDA * 1000));\n      // Metade dois: troca de camada e volta para casa, já no fundo. A mola herda a\n      // velocidade da saída, então a curva da ida emenda na da volta sem parar.\n      onSend();\n      setVoando(false);\n      animate(x, 0, molaVolta);\n      animate(y, 0, molaVolta);\n    },\n    [onSend, reduce, x, y],\n  );\n\n  // O pedido que já existia quando a carta montou não é dela.\n  const visto = React.useRef(envio?.n);\n  React.useEffect(() => {\n    if (envio?.n === visto.current) return;\n    visto.current = envio?.n;\n    if (envio && topo) void lancar(envio.dir);\n    // Só o pedido novo dispara; `topo` é lido no momento.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [envio?.n]);\n\n  return (\n    <motion.div\n      className=\"pointer-events-none absolute inset-x-0 top-0 h-[var(--card-stack-h)] origin-bottom\"\n      style={{\n        zIndex: voando ? total + 1 : total - depth,\n        y: poseY,\n        scale: escala,\n        rotate: giro,\n        opacity: opacidade,\n      }}\n      aria-hidden={topo ? undefined : true}\n      // `inert` como atributo cru: o React 18 não conhece a prop e só repassa string.\n      {...({ inert: topo ? undefined : \"\" } as object)}\n    >\n      <motion.div\n        ref={ref}\n        role={topo ? \"group\" : undefined}\n        aria-roledescription={topo ? \"card\" : undefined}\n        aria-label={topo ? label : undefined}\n        style={{ x, y, rotate }}\n        // No dedo só de lado: `drag` livre faz o motion escrever `touch-action: none` e\n        // a pilha inteira deixava de rolar a página. Com mouse segue livre.\n        drag={topo && !voando ? (porToque ? \"x\" : true) : false}\n        dragMomentum={false}\n        // Livre na horizontal, borracha na vertical: o gesto é de lado, e o card mostra isso.\n        dragConstraints={{ top: 0, bottom: 0 }}\n        dragElastic={{ top: 0.16, bottom: 0.16 }}\n        whileHover={reduce || !topo ? undefined : { scale: 1.012 }}\n        whileDrag={reduce ? undefined : { scale: 1.025 }}\n        transition={molaPilha}\n        onDragEnd={(_, info) => {\n          const dx = info.offset.x;\n          const largura = ref.current?.offsetWidth ?? 280;\n          const flick = Math.abs(info.velocity.x) > FLICK && Math.sign(info.velocity.x) === Math.sign(dx);\n          if (dx !== 0 && (flick || Math.abs(dx) > largura * 0.3)) {\n            void lancar(Math.sign(dx), info.velocity.x, info.velocity.y);\n            return;\n          }\n          if (reduce) {\n            x.jump(0);\n            y.jump(0);\n            return;\n          }\n          animate(x, 0, { ...molaVolta, velocity: info.velocity.x });\n          animate(y, 0, { ...molaVolta, velocity: info.velocity.y });\n        }}\n        className={cn(\n          \"absolute inset-0 touch-pan-y select-none overflow-hidden rounded-[var(--r-surface)] border border-border bg-card text-foreground\",\n          topo && \"pointer-events-auto cursor-grab active:cursor-grabbing\",\n          (topo || voando) && \"[box-shadow:var(--e-raised)]\",\n        )}\n      >\n        {children}\n        {/*\n         * A sombra de profundidade: quanto mais fundo, mais o card afunda no\n         * fundo do tema. É um véu da cor do `--background`, não um cinza, então\n         * funciona igual no claro e no escuro, e some no card de cima.\n         */}\n        <motion.span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 bg-background\"\n          style={{ opacity: veu }}\n        />\n      </motion.div>\n    </motion.div>\n  );\n}\n\nexport function CardStack<T>({ items, getKey, getLabel, renderCard, onCycle, label = \"Pilha de cards\", className }: CardStackProps<T>) {\n  const reduce = useReducedMotion() ?? false;\n  const [ordem, setOrdem] = React.useState<string[]>(() => items.map(getKey));\n  const [envio, setEnvio] = React.useState<{ n: number; dir: 1 | -1 } | null>(null);\n  const [aviso, setAviso] = React.useState(\"\");\n  const dica = React.useId();\n  const avanco = useMotionValue(0);\n\n  // Se a lista mudar, mantém a ordem do que sobrou e põe o novo no fim.\n  React.useEffect(() => {\n    const chaves = items.map(getKey);\n    setOrdem((o) => [...o.filter((k) => chaves.includes(k)), ...chaves.filter((k) => !o.includes(k))]);\n  }, [items, getKey]);\n\n  const porChave = new Map(items.map((it) => [getKey(it), it]));\n\n  // Depois da troca o avanço zera sem ninguém ver: cada card já está na pose nova.\n  React.useEffect(() => {\n    avanco.jump(0);\n  }, [ordem, avanco]);\n\n  // A ordem vive também num ref: o envio lê o topo do momento sem efeito colateral dentro do updater.\n  const ordemRef = React.useRef(ordem);\n  ordemRef.current = ordem;\n\n  const enviar = () => {\n    const o = ordemRef.current;\n    if (o.length < 2) return;\n    const nova = [...o.slice(1), o[0]];\n    ordemRef.current = nova;\n    setOrdem(nova);\n    const topo = porChave.get(nova[0]);\n    if (topo) {\n      setAviso(`${getLabel(topo)}, agora no topo.`);\n      onCycle?.(topo);\n    }\n  };\n\n  return (\n    <div className={cn(\"w-[min(100%,20rem)] [--card-stack-h:17rem]\", className)}>\n      <div\n        role=\"group\"\n        aria-label={label}\n        aria-describedby={dica}\n        tabIndex={0}\n        onKeyDown={(e) => {\n          if (e.target !== e.currentTarget) return;\n          const dir = e.key === \"ArrowLeft\" ? -1 : [\"ArrowRight\", \"Enter\", \" \"].includes(e.key) ? 1 : 0;\n          if (!dir) return;\n          e.preventDefault();\n          setEnvio((s) => ({ n: (s?.n ?? 0) + 1, dir }));\n        }}\n        className=\"relative isolate h-[calc(var(--card-stack-h)+3*12px)] rounded-[var(--r-surface)] outline-none focus-visible:[box-shadow:var(--focus)]\"\n      >\n        {ordem.map((k, depth) => {\n          const item = porChave.get(k);\n          if (!item) return null;\n          return (\n            <Carta\n              key={k}\n              depth={depth}\n              total={ordem.length}\n              topo={depth === 0}\n              reduce={reduce}\n              envio={envio}\n              onSend={enviar}\n              avanco={avanco}\n              label={`${getLabel(item)}, ${items.indexOf(item) + 1} de ${items.length}`}\n            >\n              {renderCard(item)}\n            </Carta>\n          );\n        })}\n      </div>\n      <p id={dica} className=\"sr-only\">\n        Arraste o card de cima para mandá-lo ao fundo, ou use as setas.\n      </p>\n      <p className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {aviso}\n      </p>\n    </div>\n  );\n}\n\nexport default CardStack;\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Pilha de cards arrastáveis pra passar depoimentos ou dicas um a um, em landing ou onboarding",
      "evite": "Cartões de pagamento com saldo: wallet-stack; depoimentos todos à vista, testimonial-wall"
    }
  },
  "categories": [
    "conteudo",
    "pilha",
    "arrastar",
    "gesto",
    "depoimentos",
    "cards"
  ],
  "type": "registry:ui"
}