{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "marquee",
  "type": "registry:component",
  "title": "Marquee",
  "description": "A seamless infinite marquee: the cursor slows it down and nudges it with inertia, rows run in opposite directions, and keyboard focus pauses it.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Navigation"
  ],
  "docs": "Installed in components/furin/marquee/. Docs, live controls and props table: https://furin.dev/components/marquee/",
  "files": [
    {
      "path": "registry/marquee/Marquee.tsx",
      "content": "\"use client\";\nimport { useEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport \"./Marquee.css\";\n\nexport interface MarqueeProps {\n  /** Elementos de la marquesina: textos o cualquier nodo. */\n  items?: ReactNode[];\n  /** Velocidad base, en px por segundo. */\n  speed?: number;\n  /** Dirección del movimiento de la primera fila. */\n  direction?: \"left\" | \"right\";\n  /** Filas apiladas; cada una va en dirección contraria a la anterior. */\n  rows?: number;\n  /** Espacio entre elementos, en px. */\n  gap?: number;\n  /** Velocidad al pasar el cursor, como fracción de la base (0 = frena del todo). */\n  hoverSpeed?: number;\n  /** Cuánto empuja el cursor al moverse sobre la fila (0 = nada). */\n  push?: number;\n  /** Desvanece los bordes. */\n  fade?: boolean;\n  /** Etiqueta accesible de la marquesina. */\n  label?: string;\n  /** Clase extra para el contenedor. */\n  className?: string;\n  /** Estilos extra para el contenedor. */\n  style?: CSSProperties;\n}\n\nconst PALABRAS = [\"Design\", \"Motion\", \"Detail\", \"Code\", \"Texture\", \"Rhythm\"];\n\nconst fila = (items: ReactNode[], r: number) => {\n  const k = items.length ? (r * 2) % items.length : 0;\n  return [...items.slice(k), ...items.slice(0, k)];\n};\n\n/** Marquesina infinita sin saltos: el cursor la frena y la empuja, y el foco del teclado la pausa. */\nexport function Marquee({\n  items = PALABRAS,\n  speed = 70,\n  direction = \"left\",\n  rows = 2,\n  gap = 36,\n  hoverSpeed = 0.14,\n  push = 0.6,\n  fade = true,\n  label = \"Marquee\",\n  className = \"\",\n  style,\n}: MarqueeProps) {\n  const host = useRef<HTMLDivElement>(null);\n  const pistas = useRef<(HTMLDivElement | null)[]>([]);\n  const sets = useRef<(HTMLUListElement | null)[]>([]);\n  const nFilas = Math.max(1, Math.min(rows, 3));\n  const [copias, setCopias] = useState<number[]>(() => Array(nFilas).fill(2));\n  const [quieto, setQuieto] = useState(false);\n  const live = useRef({ speed, direction, hoverSpeed, push, nFilas });\n  live.current = { speed, direction, hoverSpeed, push, nFilas };\n\n  useEffect(() => {\n    const el = host.current;\n    if (!el) return;\n    const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    if (reduce) { setQuieto(true); return; }\n    setQuieto(false);\n    // Estado por fila: desplazamiento, factor de velocidad (freno), empuje del cursor.\n    const st = Array.from({ length: 3 }, () => ({ off: 0, mult: 1, boost: 0, boostT: 0, hover: false, lx: 0, lt: 0, W: 0 }));\n    let foco = false, raf = 0, last = 0, visible = true, cw = 0;\n\n    const medir = () => {\n      cw = el.clientWidth;\n      const next: number[] = [];\n      for (let r = 0; r < live.current.nFilas; r++) {\n        const s = sets.current[r];\n        const w = s ? s.getBoundingClientRect().width : 0;\n        st[r].W = w;\n        next.push(w > 0 ? Math.max(2, Math.ceil(cw / w) + 1) : 2);\n      }\n      setCopias((p) => (p.length === next.length && p.every((v, i) => v === next[i]) ? p : next));\n    };\n\n    const frame = (nowMs: number) => {\n      raf = 0;\n      const dt = Math.min(0.05, Math.max(0.001, (nowMs - last) / 1000));\n      last = nowMs;\n      const L = live.current;\n      for (let r = 0; r < L.nFilas; r++) {\n        const s = st[r];\n        const pista = pistas.current[r];\n        if (!pista || s.W <= 0) continue;\n        const sign = (L.direction === \"left\" ? 1 : -1) * (r % 2 === 0 ? 1 : -1);\n        const objetivo = foco ? 0 : s.hover ? L.hoverSpeed : 1;\n        // Freno y aceleración suaves: el factor se acerca exponencialmente al objetivo.\n        s.mult += (objetivo - s.mult) * (1 - Math.exp(-dt * (objetivo < s.mult ? 5 : 3.2)));\n        s.boostT *= Math.exp(-dt * 5);\n        s.boost += (s.boostT - s.boost) * (1 - Math.exp(-dt * 9));\n        // El empuje se mide en px/s con signo de pantalla: positivo = hacia la derecha.\n        s.off += (sign * L.speed * s.mult - s.boost) * dt;\n        const x = -(((s.off % s.W) + s.W) % s.W);\n        pista.style.transform = `translate3d(${x.toFixed(2)}px, 0, 0)`;\n      }\n      if (visible) raf = requestAnimationFrame(frame);\n    };\n    const wake = () => { if (raf || !visible) return; last = performance.now(); raf = requestAnimationFrame(frame); };\n\n    const hacia = (r: number) => ({\n      enter: () => { st[r].hover = true; },\n      leave: () => { st[r].hover = false; },\n      move: (e: PointerEvent) => {\n        if (e.pointerType === \"touch\") return;\n        const s = st[r];\n        const t = e.timeStamp;\n        if (s.lt && t > s.lt) {\n          const v = ((e.clientX - s.lx) / (t - s.lt)) * 1000;\n          s.boostT = Math.max(-1800, Math.min(1800, s.boostT * 0.5 + v * live.current.push * 0.5));\n        }\n        s.lx = e.clientX; s.lt = t;\n      },\n    });\n    const filas = Array.from(el.querySelectorAll<HTMLElement>(\".furin-marquee__fila\"));\n    const hs = filas.map((f, r) => {\n      const h = hacia(r);\n      const pe = (e: Event) => { if ((e as PointerEvent).pointerType !== \"touch\") h.enter(); };\n      f.addEventListener(\"pointerenter\", pe);\n      f.addEventListener(\"pointerleave\", h.leave);\n      f.addEventListener(\"pointermove\", h.move as EventListener);\n      return () => { f.removeEventListener(\"pointerenter\", pe); f.removeEventListener(\"pointerleave\", h.leave); f.removeEventListener(\"pointermove\", h.move as EventListener); };\n    });\n    const onIn = () => { foco = true; };\n    const onOut = (e: FocusEvent) => { if (!el.contains(e.relatedTarget as Node)) foco = false; };\n    el.addEventListener(\"focusin\", onIn);\n    el.addEventListener(\"focusout\", onOut);\n\n    const io = new IntersectionObserver(([en]) => { visible = en.isIntersecting; if (visible) wake(); else if (raf) { cancelAnimationFrame(raf); raf = 0; } });\n    io.observe(el);\n    const ro = new ResizeObserver(medir);\n    ro.observe(el);\n    sets.current.forEach((s) => s && ro.observe(s));\n    const fnt = (document as Document & { fonts?: FontFaceSet }).fonts;\n    void fnt?.ready.then(medir);\n    medir();\n    wake();\n    return () => {\n      cancelAnimationFrame(raf);\n      hs.forEach((f) => f());\n      el.removeEventListener(\"focusin\", onIn);\n      el.removeEventListener(\"focusout\", onOut);\n      io.disconnect();\n      ro.disconnect();\n    };\n  }, [nFilas, items.length, gap]);\n\n  const vars = { \"--furin-marquee-gap\": `${gap}px` } as CSSProperties;\n  return (\n    <div\n      ref={host}\n      className={`furin-marquee${fade ? \" furin-marquee--fade\" : \"\"}${quieto ? \" furin-marquee--quieto\" : \"\"} ${className}`}\n      style={{ ...vars, ...style }}\n      role=\"group\"\n      aria-roledescription=\"marquesina\"\n      aria-label={label}\n      tabIndex={0}\n    >\n      {Array.from({ length: nFilas }, (_, r) => {\n        const base = fila(items, r);\n        const n = quieto ? 1 : copias[r] ?? 2;\n        return (\n          <div key={r} className={`furin-marquee__fila furin-marquee__fila--${r % 2 ? \"b\" : \"a\"}`}>\n            <div ref={(el) => { pistas.current[r] = el; }} className=\"furin-marquee__pista\">\n              {Array.from({ length: n }, (_, c) => (\n                // Solo la primera copia es accesible; el resto es relleno visual.\n                <ul key={c} ref={c === 0 ? (el) => { sets.current[r] = el; } : undefined} className=\"furin-marquee__set\" aria-hidden={c > 0 ? true : undefined} inert={c > 0}>\n                  {base.map((it, i) => (\n                    <li key={i} className=\"furin-marquee__item\">\n                      <span className=\"furin-marquee__contenido\">{it}</span>\n                      <i className=\"furin-marquee__sep\" aria-hidden=\"true\" />\n                    </li>\n                  ))}\n                </ul>\n              ))}\n            </div>\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n\nexport default Marquee;\n",
      "type": "registry:component",
      "target": "components/furin/marquee/Marquee.tsx"
    },
    {
      "path": "registry/marquee/Marquee.css",
      "content": ".furin-marquee {\n  --furin-marquee-gap: 36px;\n  display: grid;\n  gap: 10px;\n  width: 100%;\n  overflow: hidden;\n  padding: 6px 0;\n  font-family: Geist, system-ui, -apple-system, sans-serif;\n  color: #f4f5f7;\n  outline: none;\n  user-select: none;\n  border-radius: 6px;\n}\n.furin-marquee:focus-visible { box-shadow: inset 0 0 0 2px rgba(142, 162, 255, 0.7); }\n.furin-marquee--fade {\n  -webkit-mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);\n  mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);\n}\n.furin-marquee__fila { overflow: hidden; cursor: default; }\n.furin-marquee__pista { display: flex; width: max-content; will-change: transform; }\n.furin-marquee__set { display: flex; flex: none; margin: 0; padding: 0; list-style: none; }\n.furin-marquee__item { display: flex; align-items: center; flex: none; }\n.furin-marquee__contenido {\n  padding-right: var(--furin-marquee-gap);\n  font-size: clamp(34px, 9vw, 54px);\n  font-weight: 600;\n  line-height: 1.12;\n  letter-spacing: -0.04em;\n  white-space: nowrap;\n}\n/* La segunda fila se dibuja solo con contorno para dar ritmo. */\n.furin-marquee__fila--b .furin-marquee__contenido { color: transparent; -webkit-text-stroke: 1.2px rgba(255, 255, 255, 0.62); }\n/* Separador geométrico: un rombo de borde fino. */\n.furin-marquee__sep { flex: none; width: 9px; height: 9px; margin-right: var(--furin-marquee-gap); border: 1px solid rgba(255, 255, 255, 0.4); transform: rotate(45deg); }\n.furin-marquee__fila--b .furin-marquee__sep { background: rgba(142, 162, 255, 0.8); border-color: transparent; }\n.furin-marquee__fila--a .furin-marquee__item:nth-child(odd) .furin-marquee__sep { background: rgba(255, 255, 255, 0.9); border-color: transparent; }\n\n/* Reduced motion: sin movimiento; se ve una sola copia y se puede desplazar con el dedo o el teclado. */\n.furin-marquee--quieto { overflow-x: auto; -webkit-mask-image: none; mask-image: none; scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.2) transparent; }\n.furin-marquee--quieto .furin-marquee__fila { overflow: visible; }\n",
      "type": "registry:file",
      "target": "components/furin/marquee/Marquee.css"
    }
  ]
}
