{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "carousel-3d",
  "title": "Carousel 3D",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Capas num carrossel circular em WebGL: o card da frente cresce no hover, o botão central abre o item e o canvas só nasce perto da dobra. Aposentado da listagem. Bom em vitrine de poucas peças.",
  "dependencies": [
    "three@^0.170.0",
    "@react-three/fiber@^8.18.0",
    "@react-three/drei@^9.122.0",
    "lucide-react"
  ],
  "devDependencies": [
    "@types/three@^0.170.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/visant/lib/webgl-stage.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as THREE from \"three\";\nimport { useThree } from \"@react-three/fiber\";\nimport { Environment, Lightformer } from \"@react-three/drei\";\n\n/**\n * Luz de estúdio assada em Lightformers — sem baixar HDR da rede.\n *\n * Material metálico sem environment não tem o que refletir e sai chapado; um\n * .hdr de 2 MB por superfície 3D resolve isso pagando um download que o\n * primeiro quadro espera. Quatro lightformers cobrem o caso de vitrine\n * (chave na frente, dois preenchimentos laterais, um rebote frio embaixo) e\n * custam zero byte de rede.\n *\n * `frames={1}` assa uma vez: a cena não muda de luz, então recalcular o cube\n * render target todo quadro seria GPU queimada à toa.\n */\n/*\n * Luz de estúdio, não cor de marca: branco e um azul frio de céu nas laterais,\n * em sRGB numérico. Hex aqui derrubava o portão de \"componente sem cor própria\"\n * sem ser cor de componente.\n */\nconst BRANCO = new THREE.Color().setRGB(1, 1, 1, THREE.SRGBColorSpace);\nconst CEU = new THREE.Color().setRGB(0.749, 0.937, 1, THREE.SRGBColorSpace);\nconst CEU_FUNDO = new THREE.Color().setRGB(0.533, 0.8, 1, THREE.SRGBColorSpace);\n\nexport function StudioEnvironment() {\n  return (\n    <Environment resolution={256} frames={1}>\n      <Lightformer intensity={2.5} position={[0, 3, 5]} scale={[10, 10, 1]} color={BRANCO} />\n      <Lightformer intensity={1.5} position={[-5, 1, 1]} scale={[5, 10, 1]} color={CEU} />\n      <Lightformer intensity={1.5} position={[5, 1, 1]} scale={[5, 10, 1]} color={BRANCO} />\n      <Lightformer intensity={1} position={[0, -3, 2]} scale={[10, 4, 1]} color={CEU_FUNDO} />\n    </Environment>\n  );\n}\n\n/**\n * Devolve o contexto WebGL no unmount, em vez de esperar o GC.\n *\n * O navegador tem um teto de contextos vivos (≈16 no Chrome). Uma SPA que\n * monta e desmonta superfícies 3D em navegação de cliente estoura esse teto\n * sem nunca ter duas cenas na tela ao mesmo tempo — e o sintoma que aparece é\n * \"THREE.WebGLRenderer: Context Lost\" numa cena que não fez nada de errado.\n *\n * O `setTimeout(…, 0)` não é superstição: o React e o drei ainda vão fazer o\n * dispose deles (o `<Environment>` tem um cube render target próprio) e\n * precisam de um contexto VIVO pra isso. Forçar a perda de forma síncrona aqui\n * faz o dispose deles ler de um contexto já morto e lançar.\n */\nexport function ContextLossGuard() {\n  const gl = useThree((s) => s.gl);\n  React.useEffect(() => {\n    const renderer = gl;\n    return () => {\n      setTimeout(() => {\n        try {\n          // motion-lint-disable-next-line force-context-loss  o palco está desmontando, e o canvas sai junto\n          renderer.forceContextLoss();\n          renderer.dispose();\n        } catch {\n          /* noop */\n        }\n      }, 0);\n    };\n  }, [gl]);\n  return null;\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/visant/ui/carousel-3d.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Canvas, useFrame, useThree } from \"@react-three/fiber\";\nimport { Billboard, Image as DreiImage, Html, OrbitControls } from \"@react-three/drei\";\nimport * as THREE from \"three\";\nimport { ChevronLeft, ChevronRight, MousePointer2 } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { ContextLossGuard, StudioEnvironment } from \"@/lib/webgl-stage\";\n\n/**\n * Carousel 3D — capas num carrossel circular em WebGL.\n *\n * Colhido da home da Visant (2026-09), onde mostrava o portfólio até ser\n * aposentado por uma faixa contínua (`marquee`). Vale a colheita porque a peça\n * é boa no lugar certo — e o lugar certo é uma vitrine com POUCAS capas onde o\n * objeto 3D é o argumento, não o meio de mostrar uma lista.\n *\n * **Quando NÃO usar** (o motivo da aposentadoria, registrado pra não se\n * repetir): numa seção de listagem ele cobra o chunk do three.js, um error\n * boundary de WebGL e um fallback 2D inteiro pra mostrar quatro imagens que um\n * marquee mostra melhor — porque no círculo só o card da frente é legível, e os\n * vizinhos aparecem numa perspectiva rasa que o olho lê como distorção.\n *\n * Decisões que sobrevivem à colheita:\n *\n * 1. **O canvas só nasce perto da dobra.** Contexto WebGL é recurso contado\n *    pelo navegador (≈16 no Chrome); criar um pra uma seção que ninguém rolou\n *    até lá gasta o teto à toa. `IntersectionObserver`, 300px de margem, uma vez.\n * 2. **Hover é a SOMA de duas fontes.** O raycast do card e o botão em DOM por\n *    cima dele. Se só o raycast mandasse, entrar no botão dispararia\n *    `pointerout` no card → `hovered = false` → o botão some → `pointerover` de\n *    novo: flicker infinito.\n * 3. **Só o botão abre o item, e só se for clique de verdade.** O `pointerup`\n *    que termina uma rotação de arrasto não pode virar navegação — daí a guarda\n *    de 6px entre o `pointerdown` e o `click`.\n * 4. **O raio é função da contagem**, `n × (largura + vão) / 2π`. Raio fixo\n *    abre buraco com poucos itens e sobrepõe com muitos.\n * 5. **Nenhuma cor mora aqui.** O hover escurece a própria textura por\n *    multiplicação, o que dispensa um retângulo de overlay por cima do card.\n * 6. **Cada card encara a câmera (`Billboard`), em vez de apontar pra fora do\n *    círculo.** Com seis cards radiais, o vizinho fica a 60° e é visto quase\n *    de quina: na vitrine isso foi reportado como \"todo torto\", com o card do\n *    lado virando uma lasca preta. De frente, o anel continua dando a\n *    profundidade (os de trás menores e mais fundos) e todo card é legível.\n * 7. **Medida de layout, não de tela.** Geometria pela largura do CONTAINER e\n *    canvas com `resize.offsetSize`: dentro de um palco com `transform:\n *    scale` o canvas nascia no tamanho desenhado, encolhia de novo e ia pro\n *    canto.\n *\n * Requer `three`, `@react-three/fiber`, `@react-three/drei` e `lucide-react`.\n */\n\nexport interface Carousel3DItem {\n  id: string;\n  title: string;\n  /** Imagem ou vídeo (`.mp4|.webm|.mov|.m4v`) da capa. */\n  media: string;\n}\n\n// `onSelect` do DOM é um handler de evento; o nosso recebe o id do item. Omitir\n// o nativo é mais honesto que renomear o nosso — quem consome espera `onSelect`.\nexport interface Carousel3DProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onSelect\"> {\n  items: Carousel3DItem[];\n  /** Chamado no clique do botão central. É por aqui que entra o router do app. */\n  onSelect?: (id: string) => void;\n  /** Texto do botão. */\n  cta?: string;\n  /**\n   * Última chance de reescrever a URL da capa antes de virar textura.\n   *\n   * Existe por CORS: textura de outro domínio sem `Access-Control-Allow-Origin`\n   * suja o canvas e o three recusa. Num app Next, `/_next/image?url=…` resolve\n   * servindo a imagem como same-origin. Padrão: passa reto.\n   */\n  resolveSrc?: (url: string) => string;\n  /** Velocidade do giro ocioso. 0 desliga. */\n  autoRotate?: number;\n}\n\ninterface Geometry {\n  radius: number;\n  cardWidth: number;\n  fov: number;\n  cameraZ: number;\n}\n\nconst PLANE = new THREE.PlaneGeometry(1, 1);\nconst GAP = 0.15;\nconst CARD_WIDTH = 4;\nconst isVideoUrl = (u: string) => /\\.(mp4|webm|mov|m4v)$/i.test(u);\n\n/* ── Mídia ─────────────────────────────────────────────────────────────────── */\n\nfunction PulseCard({ width, height }: { width: number; height: number }) {\n  const meshRef = React.useRef<THREE.Mesh>(null);\n  useFrame((state) => {\n    const mat = meshRef.current?.material as THREE.MeshStandardMaterial | undefined;\n    if (mat) mat.opacity = 0.15 + Math.abs(Math.sin(state.clock.elapsedTime * 2)) * 0.15;\n  });\n  return (\n    <mesh ref={meshRef} geometry={PLANE} scale={[width, height, 1]}>\n      <meshStandardMaterial wireframe transparent opacity={0.2} />\n    </mesh>\n  );\n}\n\n/*\n * Multiplicadores de brilho sobre a mídia, não cor de marca: em repouso a capa\n * fica em 80%, no hover cai a 36% para o rótulo HTML ganhar contraste. Escritos\n * como cinza sRGB numérico (e não hex) porque são luminância, e o portão de\n * hex do registry existe para cor, não para conta de luz.\n */\nconst TINTA_REPOUSO = new THREE.Color().setRGB(0.8, 0.8, 0.8, THREE.SRGBColorSpace);\nconst TINTA_HOVER = new THREE.Color().setRGB(0.36, 0.36, 0.36, THREE.SRGBColorSpace);\n\nfunction VideoLayer({\n  url,\n  width,\n  height,\n  hovered,\n}: {\n  url: string;\n  width: number;\n  height: number;\n  hovered: boolean;\n}) {\n  const [video, setVideo] = React.useState<HTMLVideoElement | null>(null);\n\n  React.useEffect(() => {\n    const v = document.createElement(\"video\");\n    v.src = url;\n    v.crossOrigin = \"Anonymous\";\n    v.loop = true;\n    v.muted = true;\n    v.playsInline = true;\n    v.play().catch(() => {});\n    setVideo(v);\n    return () => {\n      v.pause();\n      v.src = \"\";\n      v.load();\n    };\n  }, [url]);\n\n  // Vídeo parado fora do hover: N vídeos decodificando em paralelo num círculo\n  // é o caminho mais curto pra derrubar o frame rate.\n  React.useEffect(() => {\n    if (!video) return;\n    if (hovered) video.play().catch(() => {});\n    else video.pause();\n  }, [hovered, video]);\n\n  if (!video) return null;\n\n  return (\n    <mesh geometry={PLANE} scale={[width, height, 1]}>\n      <meshStandardMaterial transparent opacity={0.95} color={hovered ? TINTA_HOVER : TINTA_REPOUSO}>\n        <videoTexture attach=\"map\" args={[video]} />\n      </meshStandardMaterial>\n    </mesh>\n  );\n}\n\nfunction Media(props: { url: string; width: number; height: number; hovered: boolean }) {\n  const { url, width, height, hovered } = props;\n  if (isVideoUrl(url)) return <VideoLayer {...props} />;\n  return (\n    <React.Suspense fallback={<PulseCard width={width} height={height} />}>\n      {/* eslint-disable-next-line jsx-a11y/alt-text */}\n      <DreiImage\n        url={url}\n        scale={[width, height]}\n        transparent\n        opacity={0.95}\n        color={hovered ? TINTA_HOVER : TINTA_REPOUSO}\n      />\n    </React.Suspense>\n  );\n}\n\n/* ── Card ──────────────────────────────────────────────────────────────────── */\n\nconst Card3D = React.memo(function Card3D({\n  item,\n  index,\n  total,\n  geometry,\n  cta,\n  onSelect,\n  onHoverChange,\n}: {\n  item: Carousel3DItem;\n  index: number;\n  total: number;\n  geometry: Geometry;\n  cta: string;\n  onSelect?: (id: string) => void;\n  onHoverChange?: (hovered: boolean) => void;\n}) {\n  const contentRef = React.useRef<THREE.Group>(null);\n  const [meshHover, setMeshHover] = React.useState(false);\n  const [domHover, setDomHover] = React.useState(false);\n  const hovered = meshHover || domHover; // decisão 2\n  const pressedAt = React.useRef<{ x: number; y: number } | null>(null);\n\n  useFrame(() => {\n    if (!contentRef.current) return;\n    const s = hovered ? 1.05 : 1;\n    contentRef.current.scale.lerp(new THREE.Vector3(s, s, s), 0.1);\n  });\n\n  const theta = (index / total) * Math.PI * 2;\n  const cardWidth = geometry.cardWidth;\n  const cardHeight = cardWidth / (16 / 9);\n\n  return (\n    <group\n      position={[geometry.radius * Math.cos(theta), 0, geometry.radius * Math.sin(theta)]}\n      // O giro radial ficou só como base: quem decide a face é o `Billboard`.\n      rotation={[0, Math.PI / 2 - theta, 0]}\n      onPointerOver={(e) => {\n        e.stopPropagation();\n        setMeshHover(true);\n        onHoverChange?.(true);\n      }}\n      onPointerOut={(e) => {\n        e.stopPropagation();\n        setMeshHover(false);\n        onHoverChange?.(false);\n      }}\n    >\n      {/* Ver a decisão 6: o card sempre de frente pra câmera. */}\n      <Billboard lockX lockZ>\n      <group ref={contentRef}>\n        <Media url={item.media} width={cardWidth} height={cardHeight} hovered={hovered} />\n\n        <Html\n          position={[0, 0, 0.01]}\n          center\n          className={cn(\n            // motion-lint-disable-next-line slow-ui-duration  peça arquivada: legenda de card 3D que entra junto com o giro lento da cena\n            \"pointer-events-none transition-colors duration-700 ease-out\",\n            hovered ? \"opacity-100 scale-100\" : \"opacity-0 scale-105\",\n          )}\n        >\n          <div\n            style={{ width: cardWidth * 65 }}\n            className={cn(\n              // motion-lint-disable-next-line slow-ui-duration  peça arquivada: legenda de card 3D que entra junto com o giro lento da cena\n              \"flex flex-col items-center gap-5 px-4 text-center transition-transform duration-700 will-change-transform\",\n              hovered ? \"translate-y-0\" : \"translate-y-4\",\n            )}\n          >\n            <h3 className=\"w-full text-xl font-black leading-[1.1] tracking-tight text-white drop-shadow-xl md:text-2xl\">\n              {item.title}\n            </h3>\n\n            {/* Única superfície clicável. pointer-events sempre ligado: é ele\n                que reporta o hover do centro (ver decisão 2). */}\n            <button\n              type=\"button\"\n              aria-label={`${cta}: ${item.title}`}\n              onPointerEnter={() => setDomHover(true)}\n              onPointerLeave={() => setDomHover(false)}\n              onPointerDown={(e) => {\n                pressedAt.current = { x: e.clientX, y: e.clientY };\n              }}\n              onClick={(e) => {\n                e.stopPropagation();\n                const start = pressedAt.current;\n                pressedAt.current = null;\n                if (start && Math.hypot(e.clientX - start.x, e.clientY - start.y) > 6) return; // decisão 3\n                onSelect?.(item.id);\n              }}\n              className={cn(\n                // motion-lint-disable-next-line slow-ui-duration  peça arquivada: legenda de card 3D que entra junto com o giro lento da cena\n                \"pointer-events-auto relative flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-full border border-white/25 px-5 py-2 pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-1 transition-opacity duration-500 hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70\",\n                hovered ? \"opacity-100 delay-150\" : \"opacity-0\",\n              )}\n            >\n              <MousePointer2 className=\"h-3 w-3 text-white/90\" />\n              <span className=\"text-xs font-bold uppercase tracking-[0.2em] text-white/90\">{cta}</span>\n            </button>\n          </div>\n        </Html>\n      </group>\n      </Billboard>\n    </group>\n  );\n});\n\n/* ── Cena ──────────────────────────────────────────────────────────────────── */\n\nfunction Scene({\n  items,\n  geometry,\n  rotation,\n  cta,\n  onSelect,\n  onHoverChange,\n}: {\n  items: Carousel3DItem[];\n  geometry: Geometry;\n  rotation: number;\n  cta: string;\n  onSelect?: (id: string) => void;\n  onHoverChange?: (hovered: boolean) => void;\n}) {\n  const groupRef = React.useRef<THREE.Group>(null);\n  useFrame(() => {\n    if (!groupRef.current) return;\n    groupRef.current.rotation.y = THREE.MathUtils.lerp(groupRef.current.rotation.y, rotation, 0.1);\n  });\n\n  return (\n    <group ref={groupRef}>\n      {items.map((item, i) => (\n        <Card3D\n          key={item.id}\n          item={item}\n          index={i}\n          total={items.length}\n          geometry={geometry}\n          cta={cta}\n          onSelect={onSelect}\n          onHoverChange={onHoverChange}\n        />\n      ))}\n      <ambientLight intensity={15} />\n      <pointLight position={[0, 10, 0]} intensity={5} />\n      <spotLight position={[10, 10, 10]} angle={0.15} penumbra={1} intensity={2} />\n    </group>\n  );\n}\n\nfunction CameraFov({ fov }: { fov: number }) {\n  const camera = useThree((s) => s.camera);\n  React.useEffect(() => {\n    if (camera instanceof THREE.PerspectiveCamera) {\n      camera.fov = fov;\n      camera.updateProjectionMatrix();\n    }\n  }, [fov, camera]);\n  return null;\n}\n\n/* ── Público ───────────────────────────────────────────────────────────────── */\n\nexport function Carousel3D({\n  items,\n  onSelect,\n  cta = \"Ver projeto\",\n  resolveSrc,\n  autoRotate = 0.2,\n  className,\n  ...props\n}: Carousel3DProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const [live, setLive] = React.useState(false); // decisão 1\n  const [width, setWidth] = React.useState(1200);\n  const [hovering, setHovering] = React.useState(false);\n  const [active, setActive] = React.useState(0);\n\n  React.useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const io = new IntersectionObserver(\n      ([e]) => {\n        if (e?.isIntersecting) {\n          setLive(true);\n          io.disconnect();\n        }\n      },\n      { rootMargin: \"300px\" },\n    );\n    io.observe(el);\n    return () => io.disconnect();\n  }, []);\n\n  /*\n   * A medida é a do CONTAINER, em `offsetWidth`, e não a da janela.\n   *\n   * Lendo `window.innerWidth`, o carrossel numa coluna de 400px de um monitor\n   * largo escolhia card e câmera de tela cheia, e saía cortado e torto. E\n   * `offsetWidth` porque ele ignora `transform`: dentro de um palco escalado a\n   * caixa medida é a do layout, não a desenhada.\n   */\n  React.useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const read = () => setWidth(el.offsetWidth || 1200);\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    let t: ReturnType<typeof setTimeout>;\n    const ro = new ResizeObserver(() => {\n      clearTimeout(t);\n      t = setTimeout(read, 100);\n    });\n    ro.observe(el);\n    return () => {\n      ro.disconnect();\n      clearTimeout(t);\n    };\n  }, []);\n\n  const geometry = React.useMemo<Geometry>(() => {\n    const factor = width < 480 ? 0.68 : width < 640 ? 0.8 : width < 900 ? 0.88 : 1;\n    const cardWidth = CARD_WIDTH * factor;\n    const gap = GAP * factor;\n    // decisão 4 — o raio segue a contagem, com piso pra poucos itens.\n    const radius = Math.max(2.2, (items.length * (cardWidth + gap)) / (2 * Math.PI));\n    return {\n      radius,\n      cardWidth,\n      // Lente mais fechada e câmera mais longe: menos deformação nas pontas e\n      // o card da frente deixa espaço pros vizinhos aparecerem inteiros.\n      fov: width < 480 ? 48 : width < 900 ? 40 : 36,\n      cameraZ: radius + (width < 480 ? 4.6 : width < 900 ? 6 : 6.8),\n    };\n  }, [width, items.length]);\n\n  const rotation = React.useMemo(\n    () => (items.length ? Math.PI / 2 - (active / items.length) * Math.PI * 2 : 0),\n    [active, items.length],\n  );\n\n  const resolved = React.useMemo(\n    () => (resolveSrc ? items.map((i) => ({ ...i, media: resolveSrc(i.media) })) : items),\n    [items, resolveSrc],\n  );\n\n  if (!items.length) return null;\n\n  return (\n    <div className=\"group relative w-full\" {...props}>\n      <div\n        ref={rootRef}\n        className={cn(\n          // `!`: o OrbitControls escreve `touch-action: none` em linha. De lado gira, em pé rola a página.\n          \"relative h-[50vh] min-h-[350px] w-full cursor-grab active:cursor-grabbing [&_*]:[touch-action:pan-y]! [touch-action:pan-y] [mask-image:linear-gradient(to_right,transparent,black_10%,black_90%,transparent)]\",\n          className,\n        )}\n      >\n        {live && (\n          <Canvas\n            shadows\n            camera={{ position: [0, 0, geometry.cameraZ], fov: geometry.fov }}\n            // `offsetSize`: sob `transform: scale` o canvas mede a caixa de\n            // layout. Medindo o retângulo desenhado, ele nascia na metade do\n            // tamanho, encolhia de novo pela escala e ia pro canto.\n            resize={{ offsetSize: true }}\n            gl={{ antialias: true, alpha: true, powerPreference: \"high-performance\" }}\n            dpr={[1, 2]}\n          >\n            <ContextLossGuard />\n            <React.Suspense fallback={null}>\n              <CameraFov fov={geometry.fov} />\n              <Scene\n                items={resolved}\n                geometry={geometry}\n                rotation={rotation}\n                cta={cta}\n                onSelect={onSelect}\n                onHoverChange={setHovering}\n              />\n              <OrbitControls\n                enableZoom={false}\n                enablePan={false}\n                rotateSpeed={0.05}\n                // No horizonte: câmera acima dele com o card travado em pé\n                // (`lockX`) desenha trapézio, e trapézio é o \"torto\" de novo.\n                minPolarAngle={Math.PI / 2}\n                maxPolarAngle={Math.PI / 2}\n                autoRotate={autoRotate > 0}\n                // Quase parado no hover, e não parado: cena congelada faz a\n                // pessoa achar que travou.\n                autoRotateSpeed={hovering ? autoRotate * 0.1 : autoRotate}\n                minDistance={geometry.cameraZ}\n                maxDistance={geometry.cameraZ}\n              />\n              <StudioEnvironment />\n            </React.Suspense>\n          </Canvas>\n        )}\n      </div>\n\n      {items.length > 1 && (\n        <>\n          <button\n            type=\"button\"\n            onClick={() => setActive((p) => (p - 1 + items.length) % items.length)}\n            className=\"absolute left-3 top-1/2 z-20 grid size-10 pointer-coarse:after:absolute pointer-coarse:after:-inset-0.5 -translate-y-1/2 place-items-center rounded-full bg-[color-mix(in_oklab,var(--background)_70%,transparent)] text-muted-foreground backdrop-blur-md transition-colors [transition-duration:var(--dur-fast)] hover:text-foreground focus-visible:outline-none focus-visible:[box-shadow:var(--focus)]\"\n            aria-label=\"Anterior\"\n          >\n            <ChevronLeft className=\"size-5\" />\n          </button>\n          <button\n            type=\"button\"\n            onClick={() => setActive((p) => (p + 1) % items.length)}\n            className=\"absolute right-3 top-1/2 z-20 grid size-10 pointer-coarse:after:absolute pointer-coarse:after:-inset-0.5 -translate-y-1/2 place-items-center rounded-full bg-[color-mix(in_oklab,var(--background)_70%,transparent)] text-muted-foreground backdrop-blur-md transition-colors [transition-duration:var(--dur-fast)] hover:text-foreground focus-visible:outline-none focus-visible:[box-shadow:var(--focus)]\"\n            aria-label=\"Próximo\"\n          >\n            <ChevronRight className=\"size-5\" />\n          </button>\n        </>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Vitrine de poucas capas num carrossel circular em WebGL, como portfólio curto",
      "evite": "Está aposentado da listagem: prefira cover-flow, mais leve; muitos itens, rail"
    }
  },
  "categories": [
    "conteudo",
    "carrossel",
    "3d",
    "webgl",
    "portfolio",
    "capa",
    "vitrine"
  ],
  "type": "registry:ui"
}