{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "photo-stack",
  "type": "registry:component",
  "title": "Photo Stack",
  "description": "A stack of printed photos that fans out when you hover; a click sends the top one to the back.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Galleries"
  ],
  "docs": "Installed in components/furin/photo-stack/. Docs, live controls and props table: https://furin.dev/components/photo-stack/",
  "files": [
    {
      "path": "registry/photo-stack/PhotoStack.tsx",
      "content": "\"use client\";\nimport { useEffect, useRef, useState } from \"react\";\nimport type { CSSProperties } from \"react\";\nimport \"./PhotoStack.css\";\n\nexport interface PhotoStackProps {\n  /** Cuántas fotos hay en la pila (2 a 5). */\n  count?: number;\n  /** Ancho de cada foto, en px. */\n  size?: number;\n  /** Grados entre foto y foto cuando se abren en abanico. */\n  fan?: number;\n  /** Color del papel fotográfico. */\n  paperColor?: string;\n  /** Si es false, el abanico queda abierto siempre. */\n  fanOnHover?: boolean;\n  /** Clase extra para el contenedor. */\n  className?: string;\n  /** Estilos extra para el contenedor. */\n  style?: CSSProperties;\n}\n\ntype Layer = { t: \"ridge\" | \"dunes\" | \"pines\" | \"city\" | \"waves\"; y: number; amp: number; c: string; c2?: string; seed: number };\ntype Look = { sky: string[]; sun?: { x: number; y: number; r: number; c: string; glow: string }; stars?: boolean; layers: Layer[] };\n\n// Cinco escenas generadas por código (nada de imágenes externas): cielo, astro, capas de relieve y grano de película.\nconst LOOKS: Look[] = [\n  { sky: [\"#2b1c52\", \"#9c3f6e\", \"#ff8f5e\", \"#ffd9a2\"], sun: { x: 0.64, y: 0.56, r: 0.07, c: \"#fff3cf\", glow: \"#ff9d62\" }, layers: [\n    { t: \"ridge\", y: 0.6, amp: 0.15, c: \"#8a416c\", c2: \"#b3556d\", seed: 1 }, { t: \"ridge\", y: 0.7, amp: 0.13, c: \"#512b5d\", c2: \"#6e3a66\", seed: 2 }, { t: \"waves\", y: 0.78, amp: 0.1, c: \"#2a1a45\", c2: \"#ff9d62\", seed: 3 } ] },\n  { sky: [\"#f5b67a\", \"#f6c78d\", \"#f9dcae\", \"#fdebd0\"], sun: { x: 0.3, y: 0.34, r: 0.1, c: \"#fffaf0\", glow: \"#ffd6a0\" }, layers: [\n    { t: \"dunes\", y: 0.58, amp: 0.12, c: \"#d98a55\", c2: \"#e9a672\", seed: 4 }, { t: \"dunes\", y: 0.72, amp: 0.14, c: \"#b9633c\", c2: \"#d4814f\", seed: 5 }, { t: \"dunes\", y: 0.88, amp: 0.1, c: \"#7d3a27\", c2: \"#9a4b30\", seed: 6 } ] },\n  { sky: [\"#bfe3dc\", \"#dff1e6\", \"#f3f6e6\", \"#fdf6df\"], sun: { x: 0.74, y: 0.3, r: 0.06, c: \"#ffffff\", glow: \"#fff0c0\" }, layers: [\n    { t: \"ridge\", y: 0.52, amp: 0.1, c: \"#9bc7b8\", c2: \"#c0dccb\", seed: 7 }, { t: \"pines\", y: 0.66, amp: 0.2, c: \"#4f8a78\", c2: \"#7db19b\", seed: 8 }, { t: \"pines\", y: 0.86, amp: 0.3, c: \"#1d4a44\", c2: \"#2c6a5e\", seed: 9 } ] },\n  { sky: [\"#05070f\", \"#0d1730\", \"#233a6b\", \"#d9748b\"], stars: true, sun: { x: 0.28, y: 0.3, r: 0.045, c: \"#fffbe9\", glow: \"#9db4ff\" }, layers: [\n    { t: \"city\", y: 0.7, amp: 0.2, c: \"#121a33\", c2: \"#1d2a52\", seed: 10 }, { t: \"city\", y: 0.9, amp: 0.26, c: \"#070a14\", c2: \"#0d1326\", seed: 11 } ] },\n  { sky: [\"#17315f\", \"#2f6b9a\", \"#8fc4d6\", \"#fbe3c0\"], sun: { x: 0.5, y: 0.5, r: 0.08, c: \"#fff6dc\", glow: \"#ffd9a0\" }, layers: [\n    { t: \"ridge\", y: 0.5, amp: 0.05, c: \"#4d7c93\", c2: \"#6f9bab\", seed: 12 }, { t: \"waves\", y: 0.54, amp: 0.46, c: \"#0f3a5a\", c2: \"#ffe2b0\", seed: 13 } ] },\n];\n\nfunction mix(a: string, b: string, t: number) {\n  const p = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));\n  const A = p(a), B = p(b);\n  return `rgb(${A.map((v, i) => Math.round(v + (B[i] - v) * t)).join(\",\")})`;\n}\nconst wave = (x: number, seed: number, oct = 4) => {\n  let v = 0, a = 0.5, f = 1.4;\n  for (let i = 0; i < oct; i++) { v += a * Math.sin(x * f + seed * 7.13 + i * 1.9) * (0.65 + 0.35 * Math.sin(x * f * 0.37 + seed * 3.1 + i)); a *= 0.5; f *= 2.07; }\n  return v;\n};\n\nfunction paint(cv: HTMLCanvasElement, look: Look, seedBase: number) {\n  const dpr = Math.min(window.devicePixelRatio || 1, 2);\n  const W = Math.round(cv.clientWidth * dpr), H = Math.round(cv.clientHeight * dpr);\n  if (!W || !H) return;\n  cv.width = W; cv.height = H;\n  const g = cv.getContext(\"2d\")!;\n  let seed = seedBase * 9301 + 49297;\n  const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296);\n  const sky = g.createLinearGradient(0, 0, 0, H * 0.75);\n  look.sky.forEach((c, i) => sky.addColorStop(i / (look.sky.length - 1), c));\n  g.fillStyle = sky; g.fillRect(0, 0, W, H);\n  if (look.stars) { for (let i = 0; i < 70; i++) { g.globalAlpha = 0.25 + rnd() * 0.7; g.fillStyle = \"#fff\"; g.fillRect(rnd() * W, rnd() * H * 0.5, dpr * (0.6 + rnd()), dpr * (0.6 + rnd())); } g.globalAlpha = 1; }\n  if (look.sun) {\n    const s = look.sun, x = s.x * W, y = s.y * H, r = s.r * W;\n    const gl = g.createRadialGradient(x, y, r * 0.2, x, y, r * 7);\n    gl.addColorStop(0, s.glow + \"dd\"); gl.addColorStop(0.25, s.glow + \"55\"); gl.addColorStop(1, s.glow + \"00\");\n    g.fillStyle = gl; g.fillRect(0, 0, W, H);\n    g.fillStyle = s.c; g.beginPath(); g.arc(x, y, r, 0, 7); g.fill();\n  }\n  // Bandas de nube suaves.\n  for (let i = 0; i < 4; i++) {\n    const cx = rnd() * W, cy = H * (0.18 + rnd() * 0.3), rx = W * (0.25 + rnd() * 0.3);\n    g.save(); g.translate(cx, cy); g.scale(1, 0.12);\n    const cg = g.createRadialGradient(0, 0, 0, 0, 0, rx);\n    cg.addColorStop(0, \"rgba(255,255,255,0.22)\"); cg.addColorStop(1, \"rgba(255,255,255,0)\");\n    g.fillStyle = cg; g.beginPath(); g.arc(0, 0, rx, 0, 7); g.fill(); g.restore();\n  }\n  for (const L of look.layers) {\n    const base = L.y * H, amp = L.amp * H;\n    const fill = g.createLinearGradient(0, base - amp, 0, Math.min(H, base + amp * 1.5));\n    fill.addColorStop(0, L.c2 ?? L.c); fill.addColorStop(1, L.c);\n    g.fillStyle = fill; g.beginPath(); g.moveTo(0, H);\n    if (L.t === \"ridge\" || L.t === \"dunes\") {\n      const k = L.t === \"dunes\" ? 2.4 : 5.5;\n      for (let x = 0; x <= W; x += 2) g.lineTo(x, base - amp * (0.5 + 0.5 * wave((x / W) * k, L.seed, L.t === \"dunes\" ? 2 : 5)));\n      g.lineTo(W, H); g.fill();\n    } else if (L.t === \"pines\") {\n      g.lineTo(0, base);\n      for (let x = 0; x <= W; x += 2) g.lineTo(x, base + amp * 0.3);\n      g.lineTo(W, H); g.fill();\n      for (let x = -10; x < W + 10; ) {\n        const h = amp * (0.55 + 0.6 * rnd()), w = h * 0.3;\n        const by = base + amp * 0.1 + rnd() * amp * 0.5;\n        g.fillStyle = mix(L.c, L.c2 ?? L.c, rnd() * 0.5);\n        for (let tier = 0; tier < 4; tier++) {\n          const ty = by - h * (tier / 4), tw = w * (1 - tier * 0.2);\n          g.beginPath(); g.moveTo(x, ty - h * 0.42); g.lineTo(x + tw, ty); g.lineTo(x - tw, ty); g.closePath(); g.fill();\n        }\n        x += w * (0.8 + rnd() * 0.7);\n      }\n    } else if (L.t === \"city\") {\n      g.lineTo(0, base);\n      for (let x = 0; x < W; ) {\n        const bw = W * (0.04 + rnd() * 0.07), bh = amp * H * (0.3 + rnd() * 0.7);\n        g.fillStyle = L.c; g.fillRect(x, base - bh * 0.9, bw, H);\n        g.fillStyle = L.c2 ?? L.c; g.fillRect(x, base - bh * 0.9, bw * 0.12, H);\n        for (let wy = base - bh * 0.9 + 6 * dpr; wy < base; wy += 7 * dpr) for (let wx = x + 4 * dpr; wx < x + bw - 3 * dpr; wx += 6 * dpr) if (rnd() < 0.22) { g.fillStyle = `rgba(255,${200 + Math.round(rnd() * 40)},${120 + Math.round(rnd() * 60)},${0.5 + rnd() * 0.5})`; g.fillRect(wx, wy, 2.4 * dpr, 3 * dpr); }\n        x += bw + 1;\n      }\n    } else {\n      // Mar: degradado oscuro y estrías de reflejo del astro.\n      g.fillRect(0, base, W, H);\n      const sx = (look.sun?.x ?? 0.5) * W;\n      for (let i = 0; i < 90; i++) {\n        const y = base + (i / 90) * (H - base), spread = W * 0.04 * (1 + (i / 90) * 5), w = (W * 0.04 + rnd() * W * 0.1) * (0.4 + i / 90);\n        g.fillStyle = (L.c2 ?? \"#fff\") + Math.round(60 + rnd() * 120).toString(16).padStart(2, \"0\");\n        g.fillRect(sx + (rnd() - 0.5) * spread * 2 - w / 2, y, w, dpr * (0.8 + rnd() * 1.4));\n      }\n    }\n  }\n  // Viñeta y grano de película.\n  const vg = g.createRadialGradient(W / 2, H / 2, W * 0.3, W / 2, H / 2, W * 0.85);\n  vg.addColorStop(0, \"rgba(0,0,0,0)\"); vg.addColorStop(1, \"rgba(0,0,0,0.38)\");\n  g.fillStyle = vg; g.fillRect(0, 0, W, H);\n  const id = g.getImageData(0, 0, W, H), d = id.data;\n  for (let i = 0; i < d.length; i += 4) { const n = (rnd() - 0.5) * 22; d[i] += n; d[i + 1] += n; d[i + 2] += n; }\n  g.putImageData(id, 0, 0);\n}\n\n/** Pila de fotos impresas: se reparten en abanico al pasar el cursor y la de arriba pasa al fondo con un clic. */\nexport function PhotoStack({\n  count = 5,\n  size = 172,\n  fan = 12,\n  paperColor = \"#f6f2ea\",\n  fanOnHover = true,\n  className = \"\",\n  style,\n}: PhotoStackProps) {\n  const n = Math.max(2, Math.min(5, Math.round(count)));\n  const [order, setOrder] = useState<number[]>(() => Array.from({ length: n }, (_, i) => i));\n  const [leaving, setLeaving] = useState<number | null>(null);\n  const [open, setOpen] = useState(!fanOnHover);\n  const touch = useRef(false);\n  const timer = useRef(0);\n  const canvases = useRef<(HTMLCanvasElement | null)[]>([]);\n\n  useEffect(() => { setOrder(Array.from({ length: n }, (_, i) => i)); }, [n]);\n  useEffect(() => { setOpen(!fanOnHover); }, [fanOnHover]);\n  useEffect(() => {\n    const draw = () => canvases.current.forEach((cv, i) => cv && paint(cv, LOOKS[i % LOOKS.length], i + 3));\n    draw();\n    const ro = new ResizeObserver(draw);\n    const first = canvases.current[0];\n    if (first) ro.observe(first);\n    return () => ro.disconnect();\n  }, [size, n]);\n  useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  const next = () => {\n    if (leaving !== null) return;\n    const top = order[0];\n    setLeaving(top);\n    timer.current = window.setTimeout(() => { setOrder((o) => [...o.slice(1), o[0]]); setLeaving(null); }, 300);\n  };\n\n  const c = (n - 1) / 2;\n  const vars = { \"--ps-w\": `${size}px`, \"--ps-paper\": paperColor, \"--ps-fan\": `${fan}deg`, \"--ps-n\": n } as CSSProperties;\n  return (\n    <div\n      className={`furin-photo-stack ${open ? \"is-open\" : \"\"} ${className}`}\n      style={{ ...vars, ...style }}\n      onPointerEnter={(e) => { touch.current = e.pointerType !== \"mouse\"; if (fanOnHover && e.pointerType === \"mouse\") setOpen(true); }}\n      onPointerLeave={(e) => { if (fanOnHover && e.pointerType === \"mouse\") setOpen(false); }}\n      onPointerDown={(e) => { touch.current = e.pointerType !== \"mouse\"; }}\n      onClick={() => { if (touch.current && fanOnHover && !open) { setOpen(true); return; } next(); }}\n      role=\"button\"\n      tabIndex={0}\n      aria-label=\"Photo stack: click to send the top one to the back\"\n      onKeyDown={(e) => { if (e.key === \"Enter\" || e.key === \" \") { e.preventDefault(); next(); } }}\n      onFocus={() => fanOnHover && setOpen(true)}\n      onBlur={() => fanOnHover && setOpen(false)}\n    >\n      <div className=\"furin-photo-stack__deck\">\n        {Array.from({ length: n }, (_, id) => {\n          const pos = order.indexOf(id); // 0 = arriba\n          const p = n - 1 - pos; // 0 = izquierda/fondo\n          const out = leaving === id;\n          const st = { \"--p\": p, \"--pa\": (p - c).toFixed(2), \"--z\": out ? n + 2 : n - pos } as CSSProperties;\n          return (\n            <figure key={id} className={`furin-photo-stack__card ${out ? \"is-leaving\" : \"\"}`} style={st}>\n              <canvas ref={(el) => { canvases.current[id] = el; }} />\n              <span className=\"furin-photo-stack__gloss\" aria-hidden />\n            </figure>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\nexport default PhotoStack;\n",
      "type": "registry:component",
      "target": "components/furin/photo-stack/PhotoStack.tsx"
    },
    {
      "path": "registry/photo-stack/PhotoStack.css",
      "content": ".furin-photo-stack {\n  --ps-w: 172px; --ps-paper: #f6f2ea; --ps-fan: 14deg;\n  --ps-grain: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 .4 0 0 0 0 .38 0 0 0 0 .34 0 0 0 .55 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E\");\n  position: relative;\n  display: grid;\n  place-items: center;\n  width: 100%;\n  height: 100%;\n  min-height: 360px;\n  cursor: pointer;\n  outline: none;\n  -webkit-tap-highlight-color: transparent;\n  user-select: none;\n}\n.furin-photo-stack__deck { position: relative; width: var(--ps-w); aspect-ratio: 4 / 5; }\n.furin-photo-stack__card {\n  --r: calc(var(--pa) * 2.6deg);\n  position: absolute;\n  inset: 0;\n  margin: 0;\n  padding: calc(var(--ps-w) * 0.045);\n  padding-bottom: calc(var(--ps-w) * 0.13);\n  border-radius: 3px;\n  background: var(--ps-grain), linear-gradient(160deg, color-mix(in srgb, var(--ps-paper) 80%, #fff), var(--ps-paper) 60%, color-mix(in srgb, var(--ps-paper) 90%, #8a7f6e));\n  background-blend-mode: multiply, normal;\n  z-index: var(--z);\n  transform-origin: 50% 130%;\n  transform: translate(calc(var(--pa) * 3px), calc(var(--p) * -1px)) rotate(var(--r));\n  transition: transform 760ms cubic-bezier(0.2, 1.2, 0.32, 1) calc(var(--p) * 14ms), box-shadow 400ms ease;\n  box-shadow: 0 1px 1px rgba(0, 0, 0, 0.35), 0 6px 14px -4px rgba(0, 0, 0, 0.55), 0 22px 36px -14px rgba(0, 0, 0, 0.6);\n  will-change: transform;\n}\n.furin-photo-stack__card canvas { display: block; width: 100%; height: 100%; border-radius: 1px; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.18); }\n.furin-photo-stack__gloss {\n  position: absolute; inset: 0; border-radius: inherit; pointer-events: none;\n  background: linear-gradient(115deg, rgba(255, 255, 255, 0.0) 35%, rgba(255, 255, 255, 0.16) 48%, rgba(255, 255, 255, 0) 60%);\n  mix-blend-mode: soft-light;\n}\n.furin-photo-stack.is-open .furin-photo-stack__card {\n  transform: translate(calc(var(--pa) * 34px), calc(var(--pa) * var(--pa) * 5px - 14px)) rotate(calc(var(--pa) * var(--ps-fan)));\n}\n.furin-photo-stack.is-open .furin-photo-stack__card:not(.is-leaving):hover {\n  transform: translate(calc(var(--pa) * 34px), calc(var(--pa) * var(--pa) * 5px - 30px)) rotate(calc(var(--pa) * var(--ps-fan)));\n}\n.furin-photo-stack .furin-photo-stack__card.is-leaving {\n  transform: translate(calc(var(--ps-w) * 0.78), calc(var(--ps-w) * -0.12)) rotate(calc(var(--pa) * var(--ps-fan) + 22deg));\n  transition-duration: 300ms;\n  transition-timing-function: cubic-bezier(0.4, 0, 0.9, 0.6);\n  transition-delay: 0ms;\n}\n.furin-photo-stack:focus-visible .furin-photo-stack__deck { outline: 2px solid rgba(255, 255, 255, 0.5); outline-offset: 24px; border-radius: 8px; }\n@media (prefers-reduced-motion: reduce) { .furin-photo-stack__card { transition: none !important; } }\n",
      "type": "registry:file",
      "target": "components/furin/photo-stack/PhotoStack.css"
    }
  ]
}
