{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "coverflow",
  "type": "registry:component",
  "title": "Coverflow",
  "description": "Album covers in perspective with reflections: the center one faces you and the rest fan out. Arrows, drag with inertia, click and keyboard.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Galleries"
  ],
  "docs": "Installed in components/furin/coverflow/. Docs, live controls and props table: https://furin.dev/components/coverflow/",
  "files": [
    {
      "path": "registry/coverflow/Coverflow.tsx",
      "content": "\"use client\";\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { CSSProperties, KeyboardEvent, PointerEvent } from \"react\";\nimport \"./Coverflow.css\";\n\nexport interface CoverflowItem {\n  /** Título de la portada. */\n  title: string;\n  /** Línea secundaria bajo el título. */\n  subtitle?: string;\n}\n\nexport interface CoverflowProps {\n  /** Portadas (si no pasas ninguna se usan nueve de ejemplo). */\n  items?: CoverflowItem[];\n  /** Lado de cada portada cuadrada, en px. */\n  coverSize?: number;\n  /** Portada que empieza al centro (desde 0). */\n  startIndex?: number;\n  /** Segundos entre avances automáticos (0 los desactiva). */\n  autoplay?: number;\n  /** Muestra el reflejo bajo cada portada. */\n  reflection?: boolean;\n  /** Cambia las escenas generadas de las portadas. */\n  seed?: number;\n  /** Clase extra para el contenedor. */\n  className?: string;\n  /** Estilos extra para el contenedor. */\n  style?: CSSProperties;\n}\n\n// Escenas generadas por código (sin imágenes externas): degradados, formas y grano, deterministas por semilla.\nconst PALETAS = [\n  [\"#1b1340\", \"#6a3df0\", \"#ff7ab6\", \"#ffd6a5\"],\n  [\"#06202f\", \"#0d5f7a\", \"#27c2b5\", \"#f4f1de\"],\n  [\"#2a0e0e\", \"#b3341f\", \"#ff9d4d\", \"#ffe8b8\"],\n  [\"#0e231a\", \"#2f6b4f\", \"#9ad1a0\", \"#f2f5d0\"],\n  [\"#14162e\", \"#3a46a8\", \"#7aa2ff\", \"#e8f0ff\"],\n  [\"#2d1b2e\", \"#9a3f6f\", \"#f2789f\", \"#ffe0e6\"],\n  [\"#101216\", \"#4a4f5c\", \"#c8ccd6\", \"#ffffff\"],\n  [\"#2b1a05\", \"#a8651b\", \"#f0b44c\", \"#fff3d1\"],\n];\nconst MODOS = [\"orbes\", \"arcos\", \"sol\", \"cintas\", \"bauhaus\", \"anillos\"] as const;\nconst hacerRnd = (semilla: number) => {\n  let a = (semilla * 2654435761) >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n};\nlet GRANO: HTMLCanvasElement | null = null;\nfunction grano() {\n  if (GRANO) return GRANO;\n  const c = document.createElement(\"canvas\");\n  c.width = c.height = 96;\n  const g = c.getContext(\"2d\")!;\n  const d = g.createImageData(96, 96);\n  const r = hacerRnd(99);\n  for (let i = 0; i < d.data.length; i += 4) { const v = r() * 255; d.data[i] = d.data[i + 1] = d.data[i + 2] = v; d.data[i + 3] = 255; }\n  g.putImageData(d, 0, 0);\n  return (GRANO = c);\n}\nfunction pintarEscena(cv: HTMLCanvasElement, W: number, H: number, semilla: number) {\n  const dpr = Math.min(window.devicePixelRatio || 1, 2);\n  cv.width = Math.round(W * dpr);\n  cv.height = Math.round(H * dpr);\n  const g = cv.getContext(\"2d\");\n  if (!g) return;\n  g.scale(dpr, dpr);\n  const rnd = hacerRnd(semilla + 3);\n  const pal = PALETAS[(semilla * 3 + 1) % PALETAS.length];\n  const modo = MODOS[semilla % MODOS.length];\n  const lin = (x0: number, y0: number, x1: number, y1: number, cols: string[]) => {\n    const l = g.createLinearGradient(x0, y0, x1, y1);\n    cols.forEach((c, i) => l.addColorStop(i / (cols.length - 1), c));\n    return l;\n  };\n  const M = Math.max(W, H);\n  g.fillStyle = lin(0, 0, W * 0.4, H, [pal[0], pal[1]]);\n  g.fillRect(0, 0, W, H);\n\n  if (modo === \"orbes\") {\n    g.globalCompositeOperation = \"screen\";\n    for (let i = 0; i < 4; i++) {\n      const x = W * (0.15 + rnd() * 0.7), y = H * (0.15 + rnd() * 0.7), r = M * (0.35 + rnd() * 0.4);\n      const c = pal[1 + (i % 3)];\n      const rg = g.createRadialGradient(x, y, 0, x, y, r);\n      rg.addColorStop(0, c + \"ee\"); rg.addColorStop(0.5, c + \"55\"); rg.addColorStop(1, c + \"00\");\n      g.fillStyle = rg; g.fillRect(0, 0, W, H);\n    }\n    g.globalCompositeOperation = \"source-over\";\n    const x = W * (0.3 + rnd() * 0.4), y = H * (0.3 + rnd() * 0.3), r = M * 0.1;\n    const rg = g.createRadialGradient(x - r * 0.3, y - r * 0.3, 0, x, y, r);\n    rg.addColorStop(0, \"#fff\"); rg.addColorStop(0.5, pal[3]); rg.addColorStop(1, pal[2]);\n    g.shadowColor = pal[3]; g.shadowBlur = r * 2.2; g.fillStyle = rg;\n    g.beginPath(); g.arc(x, y, r, 0, 7); g.fill(); g.shadowBlur = 0;\n  } else if (modo === \"arcos\") {\n    const cx = W * (0.4 + rnd() * 0.2), base = H * 1.02, n = 5;\n    for (let k = 0; k < n; k++) {\n      const r = (W * 0.52) * (1 - k / (n + 0.4));\n      g.shadowColor = \"rgba(0,0,0,0.35)\"; g.shadowBlur = M * 0.05; g.shadowOffsetY = M * 0.012;\n      g.fillStyle = lin(cx - r, base - r * 1.5, cx + r, base, [pal[(k + 1) % 4 === 0 ? 3 : (k + 1) % 4], pal[(k + 2) % 4]]);\n      g.beginPath(); g.moveTo(cx - r, base); g.lineTo(cx - r, base - r); g.arc(cx, base - r, r, Math.PI, 0); g.lineTo(cx + r, base); g.closePath(); g.fill();\n    }\n    g.shadowBlur = 0; g.shadowOffsetY = 0;\n  } else if (modo === \"sol\") {\n    g.fillStyle = lin(0, 0, 0, H * 0.8, [pal[0], pal[1], pal[2], pal[3]]);\n    g.fillRect(0, 0, W, H);\n    const sx = W * (0.3 + rnd() * 0.4), sy = H * 0.42, sr = M * 0.1;\n    const gl = g.createRadialGradient(sx, sy, sr * 0.3, sx, sy, sr * 6);\n    gl.addColorStop(0, pal[3] + \"dd\"); gl.addColorStop(0.3, pal[2] + \"55\"); gl.addColorStop(1, pal[2] + \"00\");\n    g.fillStyle = gl; g.fillRect(0, 0, W, H);\n    g.fillStyle = \"#fffaf0\"; g.beginPath(); g.arc(sx, sy, sr, 0, 7); g.fill();\n    for (let l = 0; l < 3; l++) {\n      const by = H * (0.6 + l * 0.14), amp = H * (0.16 - l * 0.03), ph = rnd() * 9;\n      g.fillStyle = lin(0, by - amp, 0, H, [pal[(l + 1) % 4], pal[0]]);\n      g.globalAlpha = 0.55 + l * 0.2;\n      g.beginPath(); g.moveTo(0, H);\n      for (let x = 0; x <= W; x += 3) g.lineTo(x, by - amp * (0.5 + 0.5 * (Math.sin(x / W * 5 + ph) * 0.6 + Math.sin(x / W * 11 + ph * 2) * 0.4)));\n      g.lineTo(W, H); g.fill();\n    }\n    g.globalAlpha = 1;\n  } else if (modo === \"cintas\") {\n    g.globalCompositeOperation = \"screen\";\n    for (let k = 0; k < 7; k++) {\n      const y0 = H * (0.1 + k * 0.14), a = H * (0.25 + rnd() * 0.3), th = H * (0.07 + rnd() * 0.09);\n      g.fillStyle = lin(0, y0, W, y0 + a, [pal[1 + (k % 3)] + \"cc\", pal[3] + \"55\"]);\n      g.beginPath(); g.moveTo(-10, y0);\n      g.bezierCurveTo(W * 0.3, y0 - a, W * 0.65, y0 + a, W + 10, y0 + a * 0.2);\n      g.lineTo(W + 10, y0 + a * 0.2 + th);\n      g.bezierCurveTo(W * 0.65, y0 + a + th, W * 0.3, y0 - a + th, -10, y0 + th);\n      g.closePath(); g.fill();\n    }\n    g.globalCompositeOperation = \"source-over\";\n  } else if (modo === \"bauhaus\") {\n    g.fillStyle = pal[3]; g.fillRect(0, 0, W, H);\n    g.fillStyle = pal[1]; g.beginPath(); g.arc(W * 0.34, H * 0.34, M * 0.3, 0, 7); g.fill();\n    g.fillStyle = pal[2]; g.beginPath(); g.arc(W * 0.78, H * 0.78, M * 0.26, Math.PI, 0, true); g.lineTo(W * 0.78 - M * 0.26, H * 0.78); g.fill();\n    g.fillStyle = pal[0]; g.fillRect(W * 0.52, H * 0.12, W * 0.22, H * 0.46);\n    g.fillStyle = pal[2]; g.beginPath(); g.moveTo(0, H); g.lineTo(0, H * 0.62); g.arc(0, H, H * 0.38, -Math.PI / 2, 0); g.closePath(); g.fill();\n  } else {\n    const cx = W * (0.35 + rnd() * 0.3), cy = H * (0.4 + rnd() * 0.2);\n    const rg = g.createRadialGradient(cx, cy, 0, cx, cy, M * 0.8);\n    rg.addColorStop(0, pal[2]); rg.addColorStop(0.6, pal[1]); rg.addColorStop(1, pal[0]);\n    g.fillStyle = rg; g.fillRect(0, 0, W, H);\n    for (let k = 0; k < 7; k++) {\n      const r = M * (0.1 + k * 0.1);\n      g.strokeStyle = pal[(k % 2) ? 3 : 2] + (k % 2 ? \"88\" : \"cc\");\n      g.lineWidth = M * (0.012 + 0.014 * rnd());\n      g.beginPath(); g.arc(cx, cy, r, 0, 7); g.stroke();\n    }\n    const gr = g.createRadialGradient(cx - M * 0.03, cy - M * 0.03, 0, cx, cy, M * 0.1);\n    gr.addColorStop(0, \"#fff\"); gr.addColorStop(1, pal[3]);\n    g.fillStyle = gr; g.shadowColor = pal[3]; g.shadowBlur = M * 0.12;\n    g.beginPath(); g.arc(cx, cy, M * 0.075, 0, 7); g.fill(); g.shadowBlur = 0;\n  }\n\n  // Viñeta suave y grano fino para que se sienta fotográfico.\n  const vg = g.createRadialGradient(W / 2, H / 2, M * 0.25, W / 2, H / 2, M * 0.8);\n  vg.addColorStop(0, \"rgba(0,0,0,0)\"); vg.addColorStop(1, \"rgba(0,0,0,0.34)\");\n  g.fillStyle = vg; g.fillRect(0, 0, W, H);\n  g.globalAlpha = 0.06; g.globalCompositeOperation = \"overlay\";\n  g.fillStyle = g.createPattern(grano(), \"repeat\")!;\n  g.fillRect(0, 0, W, H);\n  g.globalAlpha = 1; g.globalCompositeOperation = \"source-over\";\n}\n\nconst EJEMPLO: CoverflowItem[] = [\"Aurora\", \"Tide\", \"Amber\", \"Mist\", \"Cobalt\", \"Dune\", \"Ether\", \"Flare\", \"Grotto\"].map((title, i) => ({ title, subtitle: `Album ${String(i + 1).padStart(2, \"0\")}` }));\nconst limitar = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));\n// Pinta antes del primer fotograma (en el servidor no hay efectos, así que da igual cuál sea).\nconst useEfectoTemprano = typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\n\n/** Coverflow: portadas en perspectiva con reflejo; la del centro mira de frente y las demás se abren en abanico. Se navega con flechas, arrastre con inercia, clic en una portada o el teclado. */\nexport function Coverflow({\n  items,\n  coverSize = 156,\n  startIndex = 0,\n  autoplay = 0,\n  reflection = true,\n  seed = 0,\n  className = \"\",\n  style,\n}: CoverflowProps) {\n  const lista = useMemo(() => (items && items.length >= 2 ? items : EJEMPLO), [items]);\n  const n = lista.length;\n  const [indice, setIndice] = useState(() => limitar(startIndex, 0, n - 1));\n\n  const escena = useRef<HTMLDivElement>(null);\n  const portadas = useRef<(HTMLDivElement | null)[]>([]);\n  const lienzos = useRef<(HTMLCanvasElement | null)[]>([]);\n  const reflejos = useRef<(HTMLCanvasElement | null)[]>([]);\n  const fis = useRef({\n    pos: limitar(startIndex, 0, n - 1), vel: 0, meta: limitar(startIndex, 0, n - 1),\n    raf: 0, t: 0, visible: false, quieto: false,\n    arrastre: false, x: 0, p0: 0, x0: 0, movido: 0, ultimo: 0, id: -1,\n    encima: false, dir: 1, timer: 0, ult: -1,\n  });\n  const param = useRef({ n, cover: coverSize });\n  param.current = { n, cover: coverSize };\n\n  // Las escenas se pintan en un efecto temprano: así la portada nunca se ve vacía entre el montaje y el primer pintado.\n  useEfectoTemprano(() => {\n    for (let i = 0; i < n; i++) {\n      const c = lienzos.current[i];\n      if (!c) continue;\n      pintarEscena(c, coverSize, coverSize, i + seed * 17 + 5);\n      const r = reflejos.current[i];\n      if (r) {\n        r.width = c.width;\n        r.height = c.height;\n        r.getContext(\"2d\")?.drawImage(c, 0, 0);\n      }\n    }\n  }, [n, coverSize, seed, reflection]);\n\n  const pintar = () => {\n    const s = fis.current;\n    const cs = param.current.cover;\n    const sep = cs * 0.34;\n    const hueco = cs * 0.56;\n    for (let i = 0; i < param.current.n; i++) {\n      const el = portadas.current[i];\n      if (!el) continue;\n      const d = i - s.pos;\n      const ad = Math.abs(d);\n      const k = limitar(d, -1, 1);\n      const x = d * sep + k * hueco;\n      const z = -Math.abs(k) * cs * 0.9 - Math.max(ad - 1, 0) * cs * 0.1;\n      const rot = -k * 58;\n      el.style.transform = `translate3d(${x.toFixed(2)}px, 0, ${z.toFixed(2)}px) rotateY(${rot.toFixed(2)}deg)`;\n      el.style.zIndex = String(1000 - Math.round(ad * 20));\n      el.style.opacity = String(limitar(1 - (ad - 2.8) / 1.1, 0, 1).toFixed(3));\n      el.style.setProperty(\"--furin-cf-dim\", limitar(ad * 0.42, 0, 0.62).toFixed(3));\n    }\n    const cercano = limitar(Math.round(s.pos), 0, param.current.n - 1);\n    if (cercano !== s.ult) {\n      s.ult = cercano;\n      setIndice(cercano);\n    }\n  };\n\n  const correr = () => {\n    const s = fis.current;\n    if (s.raf || !s.visible) return;\n    s.t = performance.now();\n    const paso = (ahora: number) => {\n      let dt = Math.min(0.04, Math.max(0, (ahora - s.t) / 1000));\n      s.t = ahora;\n      if (!s.arrastre) {\n        const w = 2 * Math.PI * 1.25;\n        const z = 0.8;\n        while (dt > 0) {\n          const h = Math.min(dt, 1 / 240);\n          dt -= h;\n          s.vel += (w * w * (s.meta - s.pos) - 2 * z * w * s.vel) * h;\n          s.pos += s.vel * h;\n        }\n      }\n      pintar();\n      if (!s.arrastre && Math.abs(s.pos - s.meta) < 0.0015 && Math.abs(s.vel) < 0.01) { s.pos = s.meta; s.vel = 0; pintar(); s.raf = 0; return; }\n      if (!s.visible) { s.raf = 0; return; }\n      s.raf = requestAnimationFrame(paso);\n    };\n    s.raf = requestAnimationFrame(paso);\n  };\n\n  const ir = (i: number) => {\n    const s = fis.current;\n    s.meta = limitar(Math.round(i), 0, param.current.n - 1);\n    if (s.quieto) { s.pos = s.meta; s.vel = 0; pintar(); return; }\n    correr();\n  };\n\n  // Posición inicial de las portadas antes del primer pintado (sin esto aparecerían todas apiladas en el centro un instante).\n  useEfectoTemprano(() => {\n    fis.current.quieto = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    pintar();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n  useEffect(() => {\n    const el = escena.current;\n    if (!el) return;\n    const s = fis.current;\n    const io = new IntersectionObserver(([e]) => {\n      s.visible = e.isIntersecting;\n      if (e.isIntersecting) correr();\n    });\n    io.observe(el);\n    return () => { io.disconnect(); cancelAnimationFrame(s.raf); s.raf = 0; };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n  useEfectoTemprano(() => { pintar(); correr(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [n, coverSize]);\n\n  // Avance automático: va y vuelve de un extremo al otro, y se detiene cuando alguien lo toca.\n  useEffect(() => {\n    const s = fis.current;\n    if (autoplay <= 0 || window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n    const tic = () => {\n      if (s.visible && !s.encima && !s.arrastre) {\n        if (s.meta >= n - 1) s.dir = -1;\n        else if (s.meta <= 0) s.dir = 1;\n        ir(s.meta + s.dir);\n      }\n      s.timer = window.setTimeout(tic, autoplay * 1000);\n    };\n    s.timer = window.setTimeout(tic, autoplay * 1000);\n    return () => window.clearTimeout(s.timer);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [autoplay, n]);\n\n  const alBajar = (e: PointerEvent<HTMLDivElement>) => {\n    if ((e.target as HTMLElement).closest(\"button\")) return;\n    const s = fis.current;\n    s.arrastre = true;\n    s.x = e.clientX;\n    s.x0 = e.clientX;\n    s.p0 = s.pos;\n    s.movido = 0;\n    s.ultimo = performance.now();\n    s.vel = 0;\n    s.id = e.pointerId;\n    e.currentTarget.setPointerCapture(e.pointerId);\n  };\n  const alMover = (e: PointerEvent<HTMLDivElement>) => {\n    const s = fis.current;\n    if (!s.arrastre || e.pointerId !== s.id) return;\n    const ahora = performance.now();\n    const dx = e.clientX - s.x;\n    s.movido += Math.abs(dx);\n    s.x = e.clientX;\n    const sep = param.current.cover * 0.34;\n    const previa = s.pos;\n    // Más allá de los extremos la fila se resiste, como una banda elástica.\n    let np = s.p0 - (e.clientX - s.x0) / sep;\n    if (np < 0) np = np * 0.35;\n    if (np > param.current.n - 1) np = param.current.n - 1 + (np - (param.current.n - 1)) * 0.35;\n    s.pos = np;\n    const dt = Math.max(1, ahora - s.ultimo) / 1000;\n    s.ultimo = ahora;\n    s.vel = s.vel * 0.6 + ((s.pos - previa) / dt) * 0.4;\n    pintar();\n  };\n  const alSoltar = (e: PointerEvent<HTMLDivElement>) => {\n    const s = fis.current;\n    if (!s.arrastre) return;\n    s.arrastre = false;\n    if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);\n    if (s.movido < 5) {\n      const el = (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest(\"[data-i]\") as HTMLElement | null;\n      if (el) { ir(Number(el.dataset.i)); return; }\n    }\n    if (performance.now() - s.ultimo > 90) s.vel = 0;\n    // Destino: hacia donde llevaba el gesto, con un empujón proporcional a la velocidad.\n    ir(s.pos + limitar(s.vel, -9, 9) * 0.22);\n  };\n  const alTeclear = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (e.key === \"ArrowLeft\") { e.preventDefault(); ir(fis.current.meta - 1); }\n    else if (e.key === \"ArrowRight\") { e.preventDefault(); ir(fis.current.meta + 1); }\n    else if (e.key === \"Home\") { e.preventDefault(); ir(0); }\n    else if (e.key === \"End\") { e.preventDefault(); ir(n - 1); }\n  };\n\n  const vars = { \"--furin-cf-size\": `${coverSize}px` } as CSSProperties;\n  const actual = lista[indice] ?? lista[0];\n\n  // El título es el mismo elemento siempre (antes se remontaba con `key` y la línea quedaba en blanco un instante): al cambiar de\n  // portada solo se anima su entrada, sin pasar nunca por opacidad 0.\n  const titulo = useRef<HTMLSpanElement>(null);\n  const tituloPrevio = useRef(indice);\n  useEffect(() => {\n    if (tituloPrevio.current === indice) return;\n    tituloPrevio.current = indice;\n    if (fis.current.quieto || typeof titulo.current?.animate !== \"function\") return;\n    titulo.current.animate(\n      [{ opacity: 0.35, transform: \"translateY(5px)\" }, { opacity: 1, transform: \"none\" }],\n      { duration: 380, easing: \"cubic-bezier(0.2, 0.9, 0.3, 1)\" },\n    );\n  }, [indice]);\n\n  return (\n    <div className={`furin-coverflow ${className}`} style={{ ...vars, ...style }}>\n      <div\n        ref={escena}\n        className=\"furin-coverflow__scene\"\n        role=\"group\"\n        aria-roledescription=\"carousel\"\n        aria-label=\"Coverflow; use the arrow keys or drag to change the cover\"\n        tabIndex={0}\n        onPointerDown={alBajar}\n        onPointerMove={alMover}\n        onPointerUp={alSoltar}\n        onPointerCancel={alSoltar}\n        onPointerEnter={() => { fis.current.encima = true; }}\n        onPointerLeave={() => { fis.current.encima = false; }}\n        onKeyDown={alTeclear}\n      >\n        <div className=\"furin-coverflow__floor\" aria-hidden=\"true\" />\n        <div className=\"furin-coverflow__stage\">\n          {lista.map((it, i) => (\n            <div\n              key={it.title + i}\n              ref={(el) => { portadas.current[i] = el; }}\n              data-i={i}\n              className=\"furin-coverflow__cover\"\n              role=\"img\"\n              aria-label={`${it.title}${it.subtitle ? `, ${it.subtitle}` : \"\"}`}\n            >\n              <div className=\"furin-coverflow__art\">\n                <canvas ref={(el) => { lienzos.current[i] = el; }} aria-hidden=\"true\" />\n                <i className=\"furin-coverflow__gloss\" />\n                <i className=\"furin-coverflow__shade\" />\n              </div>\n              {reflection ? (\n                <div className=\"furin-coverflow__refl\" aria-hidden=\"true\">\n                  <canvas ref={(el) => { reflejos.current[i] = el; }} />\n                  <i className=\"furin-coverflow__shade\" />\n                </div>\n              ) : null}\n            </div>\n          ))}\n        </div>\n        <button type=\"button\" className=\"furin-coverflow__nav is-prev\" aria-label=\"Previous cover\" disabled={indice <= 0} onClick={() => ir(fis.current.meta - 1)}>\n          <svg viewBox=\"0 0 20 20\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M12.5 4.5 7 10l5.5 5.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" /></svg>\n        </button>\n        <button type=\"button\" className=\"furin-coverflow__nav is-next\" aria-label=\"Next cover\" disabled={indice >= n - 1} onClick={() => ir(fis.current.meta + 1)}>\n          <svg viewBox=\"0 0 20 20\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M7.5 4.5 13 10l-5.5 5.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" /></svg>\n        </button>\n      </div>\n      <div className=\"furin-coverflow__caption\" aria-live=\"polite\">\n        <span ref={titulo} className=\"furin-coverflow__title\">{actual.title}</span>\n        <span className=\"furin-coverflow__sub\">{actual.subtitle ?? \"\"}</span>\n      </div>\n      <div className=\"furin-coverflow__dots\" aria-hidden=\"true\">\n        {lista.map((_, i) => <i key={i} className={`furin-coverflow__dot${i === indice ? \" is-on\" : \"\"}`} />)}\n      </div>\n    </div>\n  );\n}\n\nexport default Coverflow;\n",
      "type": "registry:component",
      "target": "components/furin/coverflow/Coverflow.tsx"
    },
    {
      "path": "registry/coverflow/Coverflow.css",
      "content": ".furin-coverflow {\n  --furin-cf-size: 156px;\n  display: grid;\n  justify-items: center;\n  gap: 6px;\n  width: min(520px, 100%);\n  font-family: \"Geist\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n  color: #f3f5f8;\n}\n.furin-coverflow__scene {\n  position: relative;\n  width: 100%;\n  height: calc(var(--furin-cf-size) * 1.78);\n  perspective: 900px;\n  perspective-origin: 50% 36%;\n  cursor: grab;\n  touch-action: pan-y;\n  user-select: none;\n  -webkit-user-select: none;\n  outline: none;\n  overflow: hidden;\n  -webkit-tap-highlight-color: transparent;\n}\n.furin-coverflow__scene:active { cursor: grabbing; }\n.furin-coverflow__scene:focus-visible { box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.3); border-radius: 16px; }\n\n.furin-coverflow__stage {\n  position: absolute;\n  left: 50%;\n  top: calc(var(--furin-cf-size) * 0.1);\n  width: 0;\n  height: var(--furin-cf-size);\n  transform-style: preserve-3d;\n}\n.furin-coverflow__cover {\n  --furin-cf-dim: 0;\n  position: absolute;\n  left: calc(var(--furin-cf-size) / -2);\n  top: 0;\n  width: var(--furin-cf-size);\n  height: var(--furin-cf-size);\n  /* Plano: la portada y su reflejo son coplanares, no necesitan 3D propio. Con preserve-3d, cada vez que el opacity de la portada\n     cruzaba 1 (las de los extremos se desvanecen) el navegador cambiaba entre \"3D\" y \"aplanado\" y reconstruía las capas. */\n  transform-style: flat;\n  will-change: transform, opacity;\n}\n/* Cada capa de la portada se redondea a sí misma (border-radius: inherit) en vez de depender de un overflow:hidden del padre: un recorte\n   redondeado sobre un canvas compuesto dentro de un padre 3D obliga al navegador a rehacer la máscara en cada fotograma, y es de\n   donde salen los parpadeos de la imagen durante la transición. */\n.furin-coverflow__art {\n  position: absolute;\n  inset: 0;\n  border-radius: 6px;\n  background: #14161b;\n  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.14), 0 14px 30px -10px rgba(0, 0, 0, 0.7);\n}\n.furin-coverflow__art canvas,\n.furin-coverflow__refl canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; border-radius: inherit; }\n.furin-coverflow__shade { position: absolute; inset: 0; border-radius: inherit; background: #07080b; opacity: var(--furin-cf-dim); pointer-events: none; }\n.furin-coverflow__gloss {\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  pointer-events: none;\n  background:\n    linear-gradient(120deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.03) 34%, rgba(255, 255, 255, 0) 50%),\n    linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0) 12%);\n  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);\n}\n/* Reflejo: el mismo cuadro volteado y desvanecido hacia abajo, como sobre vidrio negro. */\n.furin-coverflow__refl {\n  position: absolute;\n  left: 0;\n  top: calc(100% + 3px);\n  width: 100%;\n  height: 100%;\n  border-radius: 6px;\n  transform: scaleY(-1);\n  -webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0) 52%, rgba(0, 0, 0, 0.34) 100%);\n  mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0) 52%, rgba(0, 0, 0, 0.34) 100%);\n  pointer-events: none;\n}\n\n.furin-coverflow__floor {\n  position: absolute;\n  left: 0;\n  right: 0;\n  top: calc(var(--furin-cf-size) * 1.12);\n  height: calc(var(--furin-cf-size) * 0.7);\n  background: linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0) 70%);\n  pointer-events: none;\n}\n.furin-coverflow__floor::before {\n  content: \"\";\n  position: absolute;\n  left: 0;\n  right: 0;\n  top: 0;\n  height: 1px;\n  background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.14), transparent);\n}\n\n.furin-coverflow__nav {\n  position: absolute;\n  top: calc(var(--furin-cf-size) * 0.1 + var(--furin-cf-size) / 2 - 18px);\n  z-index: 2000;\n  display: grid;\n  place-items: center;\n  width: 36px;\n  height: 36px;\n  padding: 0;\n  border: 0;\n  border-radius: 50%;\n  color: #f3f5f8;\n  cursor: pointer;\n  background: rgba(255, 255, 255, 0.08);\n  -webkit-backdrop-filter: blur(12px) saturate(1.4);\n  backdrop-filter: blur(12px) saturate(1.4);\n  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.22), 0 8px 20px rgba(0, 0, 0, 0.45);\n  transition: transform 0.25s cubic-bezier(0.3, 1.5, 0.5, 1), background 0.2s ease, opacity 0.2s ease;\n}\n.furin-coverflow__nav.is-prev { left: 8px; }\n.furin-coverflow__nav.is-next { right: 8px; }\n.furin-coverflow__nav:hover:not(:disabled) { background: rgba(255, 255, 255, 0.16); transform: scale(1.1); }\n.furin-coverflow__nav:active:not(:disabled) { transform: scale(0.94); }\n.furin-coverflow__nav:disabled { opacity: 0.28; cursor: default; }\n.furin-coverflow__nav:focus-visible { outline: 2px solid rgba(255, 255, 255, 0.6); outline-offset: 2px; }\n\n.furin-coverflow__caption { display: grid; justify-items: center; gap: 3px; min-height: 40px; text-align: center; }\n.furin-coverflow__title { font-size: 16px; font-weight: 600; letter-spacing: -0.015em; }\n.furin-coverflow__sub { font-size: 12px; color: rgba(243, 245, 248, 0.5); letter-spacing: 0.04em; }\n\n.furin-coverflow__dots { display: flex; gap: 6px; padding-top: 4px; }\n.furin-coverflow__dot {\n  width: 6px;\n  height: 6px;\n  border-radius: 3px;\n  background: rgba(255, 255, 255, 0.2);\n  transition: width 0.4s cubic-bezier(0.3, 1.5, 0.5, 1), background 0.25s ease;\n}\n.furin-coverflow__dot.is-on { width: 18px; background: rgba(255, 255, 255, 0.85); }\n\n@media (prefers-reduced-motion: reduce) {\n  .furin-coverflow__dot, .furin-coverflow__nav { transition: none; }\n}\n",
      "type": "registry:file",
      "target": "components/furin/coverflow/Coverflow.css"
    }
  ]
}
