{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "marquee",
  "title": "Marquee",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Faixa infinita em GSAP com três modos de scroll: empurrar o ritmo, virar esteira do scroll ou amarrar a posição à rolagem.",
  "dependencies": [
    "gsap"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/visant/ui/marquee.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport gsap from \"gsap\";\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\";\nimport { cn } from \"@/lib/utils\";\n\n// Peso da esteira no gesto: inércia, não transição de UI.\nconst INERCIA_S = 0.5;\nconst EMPURRAO_S = 0.4;\n\ngsap.registerPlugin(ScrollTrigger);\n\n/**\n * Marquee — faixa contínua, dirigida pelo scroll.\n *\n * Colhida de um concept de e-commerce, onde ela nasceu com API em vez de virar\n * um `@keyframes` solto. Nenhuma cor mora aqui: `band` pinta com `--brand` e o\n * texto com `--brand-foreground`, então a mesma faixa sai lime numa marca e\n * âmbar noutra sem fork.\n *\n * O motor é GSAP + ScrollTrigger porque um `@keyframes` não sabe onde a página\n * está. Cinco decisões que valem mais que o código:\n *\n * 1. **`ease: \"none\"`, sempre.** Qualquer easing num loop infinito cria uma\n *    pulsação que o olho lê como defeito.\n * 2. **A lista é duplicada e o track anda `metade + gap/2`.** Metade da largura\n *    seco erra por meio vão — entre as duas cópias existe um `gap` a mais que\n *    ninguém conta, e o erro aparece como um tranco por volta. A cópia vai com\n *    `aria-hidden`: ela é artefato de animação, não conteúdo, e um leitor de\n *    tela não deve ouvir tudo duas vezes.\n * 3. **A distância é função, não número.** `invalidate()` no resize e no\n *    `fonts.ready` remede sem reiniciar o loop — a faixa não pisca quando a\n *    fonte da marca troca a webfont pela real.\n * 4. **Três formas de o scroll entrar, e elas não são a mesma.** `\"boost\"` toca\n *    o `timeScale`: a faixa tem ritmo próprio e o scroll empurra e inverte.\n *    `\"drive\"` toca o playhead com sentido travado: sem scroll a faixa está\n *    parada, e rolar para cima ou para baixo faz ela avançar igual. `\"scrub\"`\n *    amarra a posição à barra de rolagem: rolar de volta traz a faixa de volta.\n *    Escolher entre eles é escolher o que a faixa DIZ sobre a página.\n * 5. **Fora da tela ela para.** O próprio ScrollTrigger dá o `onToggle`; sem\n *    isso o compositor fica ocupado a página inteira, e num telefone isso é\n *    bateria gasta em pixel que ninguém vê.\n *\n * `prefers-reduced-motion` desliga tudo via `gsap.matchMedia` e o CSS do item\n * reflui a faixa em linhas — o conteúdo continua legível, sem movimento.\n *\n * Requer `gsap` no projeto.\n */\n\nexport interface MarqueeProps extends React.HTMLAttributes<HTMLDivElement> {\n  items: React.ReactNode[];\n  /** Segundos para uma volta completa no ritmo base. Mais itens pedem mais tempo. */\n  speed?: number;\n  /** Some nas bordas em vez de ser cortado a faca. */\n  fade?: boolean;\n  /** Faixa de cor sólida com a cor da marca. */\n  band?: boolean;\n  /** Inclina a faixa. Exige um pai com `overflow: hidden`. */\n  tilt?: number;\n  /** Anda para a direita. Duas faixas em sentidos opostos leem como sistema. */\n  reverse?: boolean;\n  gap?: number;\n  /**\n   * Como o scroll entra na faixa.\n   *\n   * - `\"boost\"` (padrão): ela anda sozinha; o scroll acelera e inverte o sentido.\n   * - `\"drive\"`: só anda com o scroll, sempre para o mesmo lado. Parou de rolar,\n   *   parou a faixa — esteira ligada ao movimento da página.\n   * - `\"scrub\"`: a posição É o scroll. Rolar de volta traz a faixa de volta.\n   * - `false`: loop puro, indiferente à página.\n   */\n  scroll?: \"boost\" | \"drive\" | \"scrub\" | false;\n  /** Teto do empurrão em `\"boost\"`. 4 = até 4× o ritmo base no scroll bruto. */\n  boost?: number;\n  /** Voltas percorridas na travessia da dobra em `\"drive\"` e `\"scrub\"`. */\n  laps?: number;\n  /** Pausa no hover/foco. Desligue em faixa puramente decorativa. */\n  pauseOnHover?: boolean;\n  /**\n   * Quantos px a faixa sangra pra fora do container, de cada lado.\n   *\n   * Faixa contida por um `px-6` de página não é faixa: o corte nas duas pontas\n   * denuncia a caixa e mata a ilusão de que o texto vem de fora e vai pra fora.\n   * Passe o mesmo respiro do pai (`px-6` → `bleed={24}`) e ela encosta na borda.\n   *\n   * Isto não é conveniência: hoje quem consome resolve por fora e resolve mal.\n   * Na sala \"Página que vende\" a faixa precisou de um `className=\"px-0 py-1\n   * sm:px-0\"` no wrapper só pra desfazer o respiro do bloco — e isso deixa de\n   * funcionar no dia em que o bloco mudar de respiro. Aqui a peça sabe sozinha.\n   *\n   * `false` (padrão) é a faixa contida de sempre.\n   */\n  bleed?: number | false;\n}\n\nconst useIsomorphicLayoutEffect =\n  typeof window !== \"undefined\" ? React.useLayoutEffect : React.useEffect;\n\nexport function Marquee({\n  items,\n  speed = 46,\n  fade = false,\n  band = false,\n  tilt = 0,\n  reverse = false,\n  gap = 56,\n  scroll = \"boost\",\n  boost = 4,\n  laps = 1,\n  pauseOnHover = true,\n  bleed = false,\n  className,\n  style,\n  ...props\n}: MarqueeProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const trackRef = React.useRef<HTMLUListElement>(null);\n\n  useIsomorphicLayoutEffect(() => {\n    const root = rootRef.current;\n    const track = trackRef.current;\n    if (!root || !track) return;\n\n    const ctx = gsap.context(() => {\n      const mm = gsap.matchMedia();\n\n      mm.add(\"(prefers-reduced-motion: no-preference)\", () => {\n        // A emenda: metade do track mais meio vão. Ver decisão 2.\n        const lap = () => track.scrollWidth / 2 + gap / 2;\n        const base = reverse ? -1 : 1;\n\n        // Remede sem reiniciar: webfont que troca, container que muda de\n        // largura, item que entra depois. `invalidate` relê a função de destino\n        // mantendo o playhead onde está.\n        const remeasure = () => {\n          gsap.getTweensOf(track).forEach((t) => t.invalidate());\n        };\n        const ro = new ResizeObserver(remeasure);\n        ro.observe(track);\n        if (typeof document !== \"undefined\" && \"fonts\" in document) {\n          document.fonts.ready.then(remeasure).catch(() => {});\n        }\n\n        /*\n         * ── Por que a janela do ScrollTrigger tem uma tela de folga de cada lado\n         *\n         * O ScrollTrigger guarda `start`/`end` em cache e só recalcula no\n         * `refresh`. Numa página que MONTA conteúdo enquanto a pessoa rola — e a\n         * vitrine é exatamente isso — cada card que nasce acima empurra este\n         * aqui para baixo, e o cache passa a apontar para um trecho de página\n         * que não é mais o nosso. O sintoma é cruel de depurar: a faixa fica\n         * surda ao scroll, ou pior, continua correndo fora da tela.\n         *\n         * A folga de 100% da viewport nos dois lados absorve esse deslocamento.\n         * E quem decide de fato se a faixa está visível NÃO é ele — é o\n         * IntersectionObserver abaixo, que é sempre ao vivo e não guarda cache.\n         */\n        const JANELA = {\n          trigger: root,\n          start: \"top bottom+=100%\",\n          end: \"bottom top-=100%\",\n        } as const;\n\n        // ── modo scrub: a posição É o scroll ──────────────────────────────\n        if (scroll === \"scrub\") {\n          const tween = gsap.fromTo(\n            track,\n            { x: 0 },\n            {\n              x: () => -lap() * laps * base,\n              ease: \"none\",\n              // O modificador prende o x na faixa `[-1 volta, 0)`: é o único\n              // intervalo em que as duas cópias cobrem a caixa inteira. Sem ele,\n              // `laps > 1` — ou qualquer volta em `reverse` — arrastaria o track\n              // para fora da emenda e abriria buraco na borda.\n              modifiers: {\n                x: (value: string) =>\n                  `${gsap.utils.wrap(-lap(), 0, parseFloat(value))}px`,\n              },\n              scrollTrigger: {\n                trigger: root,\n                start: \"top bottom\",\n                end: \"bottom top\",\n                // 0.6s de inércia: o scrub cru gruda a faixa no dedo e fica\n                // nervoso; a inércia devolve peso sem descolar do scroll.\n                scrub: 0.6,\n                invalidateOnRefresh: true,\n              },\n            },\n          );\n          return () => {\n            ro.disconnect();\n            tween.scrollTrigger?.kill();\n            tween.kill();\n          };\n        }\n\n        // ── modo loop: ritmo próprio ──────────────────────────────────────\n        const tl = gsap.timeline({\n          repeat: -1,\n          // O truque oficial do GSAP para loop infinito TAMBÉM ao contrário:\n          // um `repeat: -1` reproduzido em timeScale negativo pararia no zero.\n          // Empurrar o playhead 10 durações à frente mantém o caminho aberto.\n          onReverseComplete() {\n            this.totalTime(this.rawTime() + this.duration() * 10);\n          },\n        });\n        tl.fromTo(\n          track,\n          { x: 0 },\n          { x: () => -lap(), duration: speed, ease: \"none\" },\n        );\n        tl.timeScale(base);\n\n        // ── modo drive: o scroll é o motor, o sentido é travado ───────────\n        //\n        // A diferença para o `scrub`: ali a faixa VOLTA quando o scroll volta,\n        // porque a posição dela É a barra de rolagem. Aqui só o *quanto* do\n        // scroll conta, nunca o *para onde* — a faixa sempre avança, e parar de\n        // rolar é o freio. Lê como esteira ligada ao movimento da página, e é o\n        // que a maioria das pessoas quer dizer quando pede \"marquee no scroll\".\n        if (scroll === \"drive\") {\n          tl.pause();\n\n          // O playhead anda por tween, não por atribuição direta: `totalTime`\n          // cru gruda no dedo e treme junto com o delta do wheel. O `quickTo`\n          // dá 0,5s de inércia — a esteira ganha peso e para de deslizar.\n          const toTime = gsap.quickTo(tl, \"totalTime\", {\n            duration: INERCIA_S,\n            ease: \"power3.out\",\n          });\n\n          let alvo = 0;\n          let anterior: number | null = null;\n\n          const st = ScrollTrigger.create({\n            ...JANELA,\n            onUpdate: (self) => {\n              // `self.scroll()` é a posição real do scroller AGORA — não passa\n              // pelo `start`/`end` em cache, então nenhum deslocamento de layout\n              // a desalinha. `progress` passaria, e a esteira travaria sozinha.\n              const agora = self.scroll();\n              if (anterior === null) {\n                anterior = agora;\n                return;\n              }\n              const passo = Math.abs(agora - anterior);\n              anterior = agora;\n\n              // `laps` voltas por TELA rolada: a unidade que a pessoa sente. Em\n              // fração da viewport, e não em pixels, o efeito é o mesmo no\n              // celular e no monitor de 4K.\n              alvo += (passo / window.innerHeight) * laps * speed;\n              toTime(alvo);\n            },\n          });\n\n          return () => {\n            ro.disconnect();\n            st.kill();\n            tl.kill();\n          };\n        }\n\n        let hovering = false;\n        let onscreen = true;\n        const sync = () => tl.paused(hovering || !onscreen);\n\n        /*\n         * Quem manda no \"está na tela?\" é o IntersectionObserver.\n         *\n         * O `onToggle` do ScrollTrigger responderia a mesma pergunta, e responde\n         * errado numa página que cresce embaixo dele (ver a nota da JANELA).\n         * O IO não guarda geometria: o navegador recalcula, e uma faixa que\n         * saiu de vista para de queimar compositor no mesmo quadro.\n         */\n        const io = new IntersectionObserver(([e]) => {\n          if (!e) return;\n          onscreen = e.isIntersecting;\n          sync();\n        });\n        io.observe(root);\n\n        const enter = () => {\n          if (!pauseOnHover) return;\n          hovering = true;\n          sync();\n        };\n        const leave = () => {\n          if (!pauseOnHover) return;\n          hovering = false;\n          sync();\n        };\n        track.addEventListener(\"pointerenter\", enter);\n        track.addEventListener(\"pointerleave\", leave);\n        track.addEventListener(\"focusin\", enter);\n        track.addEventListener(\"focusout\", leave);\n\n        // O sinal do timeScale carrega o sentido; o módulo carrega o ritmo.\n        // Guardamos os dois separados para o scroll poder inverter um sem\n        // perder o outro.\n        let sign = base;\n        let settle = 0;\n\n        // UM `quickTo` para empurrar E para acomodar — nunca dois tweens na\n        // mesma propriedade. Um `gsap.to(..., {overwrite: true})` de acomodação\n        // MATA o tween interno que o `quickTo` reaproveita, e a partir daí o\n        // empurrão morre em silêncio: a faixa continua andando, só que surda ao\n        // scroll. Custou uma sessão de debug — fica registrado.\n        const toScale = gsap.quickTo(tl, \"timeScale\", {\n          duration: EMPURRAO_S,\n          ease: \"power2.out\",\n        });\n\n        const st = ScrollTrigger.create({\n          ...JANELA,\n          onUpdate: (self) => {\n            if (scroll !== \"boost\" || !onscreen || hovering) return;\n\n            const v = self.getVelocity();\n            if (!v) return;\n\n            // Rolar para baixo mantém o sentido natural da faixa; para cima\n            // inverte. A faixa vira um instrumento do scroll, não um enfeite\n            // que acontece de estar ali.\n            sign = v > 0 ? base : -base;\n\n            // 1400 px/s ≈ um flick forte de trackpad. Acima disso o teto de\n            // `boost` segura: faixa a 10× vira borrão ilegível, e o texto da\n            // promoção existe para ser lido.\n            const factor = gsap.utils.clamp(1, boost, 1 + Math.abs(v) / 1400);\n            toScale(sign * factor);\n\n            // Parou de rolar → volta ao ritmo base, no sentido em que ficou.\n            clearTimeout(settle);\n            settle = window.setTimeout(() => toScale(sign), 180);\n          },\n        });\n\n        return () => {\n          clearTimeout(settle);\n          ro.disconnect();\n          io.disconnect();\n          track.removeEventListener(\"pointerenter\", enter);\n          track.removeEventListener(\"pointerleave\", leave);\n          track.removeEventListener(\"focusin\", enter);\n          track.removeEventListener(\"focusout\", leave);\n          st.kill();\n          tl.kill();\n        };\n      });\n\n      return () => mm.revert();\n    }, root);\n\n    return () => ctx.revert();\n  }, [speed, reverse, gap, scroll, boost, laps, pauseOnHover, items.length]);\n\n  const render = (hidden: boolean) =>\n    items.map((item, i) => (\n      <li\n        key={`${hidden ? \"b\" : \"a\"}-${i}`}\n        className=\"whitespace-nowrap\"\n        {...(hidden ? { \"aria-hidden\": true } : {})}\n      >\n        {item}\n      </li>\n    ));\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\n        \"overflow-hidden\",\n        band && \"bg-[var(--brand)] py-3.5 text-[var(--brand-foreground)]\",\n        className,\n      )}\n      style={{\n        ...(fade\n          ? {\n              maskImage:\n                \"linear-gradient(to right, transparent, black 5%, black 95%, transparent)\",\n            }\n          : null),\n        /*\n         * Sangria, e a ordem aqui importa.\n         *\n         * `tilt` sempre teve a sangria dele cravada em `width: 108%` +\n         * `marginLeft: -4%`: caixa girada mostra canto vazio nas pontas, e os 4%\n         * são o mínimo pra cobrir. Isso é sangria por necessidade geométrica, e\n         * é medida em % porque acompanha a largura.\n         *\n         * `bleed` é sangria por decisão de layout, e é medida em px porque casa\n         * com o respiro do pai, que também é px. Quando os dois existem, o\n         * `bleed` vem depois e manda no eixo horizontal — quem escreveu um\n         * número explícito quer aquele número.\n         */\n        ...(tilt\n          ? { rotate: `${tilt}deg`, width: \"108%\", marginLeft: \"-4%\" }\n          : null),\n        ...(typeof bleed === \"number\" && bleed > 0\n          ? { marginInline: `-${bleed}px`, width: `calc(100% + ${bleed * 2}px)` }\n          : null),\n        ...style,\n      }}\n      {...props}\n    >\n      <ul\n        ref={trackRef}\n        className=\"marquee-track flex w-max items-center will-change-transform\"\n        style={{ gap: `${gap}px` }}\n      >\n        {render(false)}\n        {render(true)}\n      </ul>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "css": {
    "@media (prefers-reduced-motion: reduce)": {
      ".marquee-track": {
        "transform": "none",
        "flex-wrap": "wrap",
        "width": "auto"
      },
      ".marquee-track > [aria-hidden=\"true\"]": {
        "display": "none"
      }
    }
  },
  "meta": {
    "uso": {
      "quando": "Faixa infinita de texto ou itens numa landing, com ritmo empurrado ou amarrado ao scroll",
      "evite": "Faixa de logos de clientes: use logo-wall, que já normaliza tamanho e cor"
    }
  },
  "categories": [
    "conteudo",
    "faixa",
    "loop",
    "scroll",
    "gsap",
    "vitrine"
  ],
  "type": "registry:ui"
}