{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "mist-text",
  "title": "Mist Text",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Texto que sai da névoa por palavra ou letra (desfoque, opacidade e subida mínima): entra uma vez ao chegar na tela, ou acompanha a rolagem ida e volta com mola que não erra a faixa dentro de sticky; cinco entradas por variant: mist, rise, mask, drift e settle.",
  "dependencies": [
    "framer-motion"
  ],
  "registryDependencies": [
    "utils",
    "@visant/motion"
  ],
  "files": [
    {
      "path": "registry/visant/ui/mist-text.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  motion,\n  useReducedMotion,\n  useScroll,\n  useSpring,\n  useTransform,\n  type MotionValue,\n} from \"framer-motion\";\n\nimport { EASE_OUT } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Mist Text — o texto que sai da névoa, palavra por palavra (ou letra).\n *\n * Duas leituras do mesmo gesto, num componente só:\n *\n * · `mode=\"view\"` — entra UMA vez quando chega na tela: desfoque + opacidade +\n *   uma subida mínima (0.35em). É o \"soft blur in\". Não desliza: a frase não vem\n *   de lugar nenhum, ela aparece onde já estava, como coisa que a neblina solta.\n * · `mode=\"scroll\"` — amarrado à rolagem e REVERSÍVEL: rola pra baixo, a frase\n *   clareia; volta, ela some de novo na névoa. Pra frase-manifesto que a pessoa\n *   lê no ritmo do próprio dedo.\n *\n * ── As armadilhas ──────────────────────────────────────────────────────────\n *\n * 1. **`aria-label` em `<p>` e `<span>` não é lido.** O ARIA proíbe nome de\n *    autor em papel genérico e em parágrafo, e leitor de tela ignora — a frase\n *    fatiada viraria vinte palavras soltas ou silêncio. O texto inteiro vai\n *    numa cópia `sr-only`, e as fatias visuais ficam `aria-hidden`. Funciona\n *    igual em `h2` e em `span`, que é o que se quer de uma primitiva.\n * 2. **Letra solta em `inline-block` quebra no meio da palavra.** Com\n *    `by=\"letter\"` cada palavra vira uma caixa `nowrap` com as letras dentro; a\n *    quebra de linha continua entre palavras, como num texto normal.\n * 3. **No modo scroll, a mola não é enfeite.** `useScroll` tenta usar o\n *    ScrollTimeline nativo quando pode, e dentro de um ancestral `sticky` o\n *    navegador calcula a faixa errada — a frase clareava inteira antes de entrar\n *    ou nunca terminava. Passar o progresso por `useSpring` força o caminho JS\n *    (que mede certo) e de quebra tira o degrau da roda do mouse.\n * 4. **A última palavra tem que chegar a 1.** A faixa de cada palavra se\n *    sobrepõe à seguinte (1.5 fatia), e na origem isso estourava o fim: a última\n *    parava em ~70% de opacidade com a rolagem já no fim. As faixas são\n *    normalizadas por `n + 0.5`, então a última termina exatamente em 1.\n * 5. **`prefers-reduced-motion` tira o movimento, não a névoa.** Fica a\n *    opacidade (informação de \"isto acabou de chegar\"); saem a subida e o\n *    desfoque.\n *\n * ── As transições (`variant`, só no modo `view`) ───────────────────────────\n *\n * O report pediu opções de transição \"conforme o que já tem na casa\". As cinco\n * são as mesmas famílias que o `fold-motion` e o `line-reveal` já usam, aqui\n * aplicadas por fatia:\n *\n * | variant  | o que cada fatia faz                                 |\n * |----------|------------------------------------------------------|\n * | `mist`   | sai da névoa: desfoque + opacidade + subida mínima (padrão) |\n * | `rise`   | sobe 0.6em e aparece, nítida, sem desfoque           |\n * | `mask`   | sobe de dentro de um corte (`clip-path`), como o `mask` do line-reveal |\n * | `drift`  | entra da esquerda com um desfoque curto              |\n * | `settle` | assenta de `scale(1.25)` até o tamanho, com desfoque leve |\n *\n * O modo `scroll` não muda com o `variant`: ali o gesto é a névoa ida e volta,\n * e uma fatia deslizando amarrada à rolagem leria como texto tremendo. Sob\n * `prefers-reduced-motion` todas viram só opacidade.\n *\n * Sem cor própria: as fatias herdam `currentColor` do elemento.\n */\n\n// Duração de cena, texto entrando na leitura: acima do teto de UI de propósito.\nconst DUR_REVEAL = 0.9;\n// Cascata curta, com teto: frase de quarenta palavras não pode virar fila.\nconst STAGGER = { word: 0.035, letter: 0.03, cap: 0.6 } as const;\n// Mola do modo scroll: amortecida o bastante pra não ter overshoot de opacidade.\nconst SCROLL_SPRING = { stiffness: 140, damping: 30, restDelta: 0.0005 } as const;\n// Opacidade mínima no modo scroll: a frase ainda se adivinha dentro da névoa.\nconst SCROLL_FLOOR = 0.12;\n\ntype MistTag = \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"p\" | \"span\";\n\nexport type MistTextVariant = \"mist\" | \"rise\" | \"mask\" | \"drift\" | \"settle\";\n\n// O corte do `mask` folga 0.25em pra cima e pra baixo: sem a folga, acento e\n// descendente ficariam aparados no estado final.\nconst CORTE_ABERTO = \"inset(-0.25em -0.1em -0.25em -0.1em)\";\nconst CORTE_FECHADO = \"inset(100% -0.1em -0.25em -0.1em)\";\n\n/** O estado escondido de cada transição. O mostrado é sempre o natural. */\nconst ESCONDIDO: Record<MistTextVariant, Record<string, string | number>> = {\n  mist: { opacity: 0, y: \"0.35em\", filter: \"blur(10px)\" },\n  rise: { opacity: 0, y: \"0.6em\", filter: \"blur(0px)\" },\n  mask: { opacity: 1, y: \"0.7em\", filter: \"blur(0px)\", clipPath: CORTE_FECHADO },\n  drift: { opacity: 0, x: \"-0.5em\", filter: \"blur(4px)\" },\n  settle: { opacity: 0, scale: 1.25, filter: \"blur(3px)\" },\n};\n\nexport interface MistTextProps {\n  /** A frase. Fatiada por espaço (palavra) ou por caractere (letra). */\n  text: string;\n  /** Elemento raiz. `span` para usar dentro de outra linha. */\n  as?: MistTag;\n  /** `word` para frase, `letter` para título curto. */\n  by?: \"word\" | \"letter\";\n  /** `view` entra uma vez; `scroll` acompanha a rolagem, ida e volta. */\n  mode?: \"view\" | \"scroll\";\n  /** Atraso antes da primeira fatia, em segundos. Só no modo `view`. */\n  delay?: number;\n  /** Como cada fatia entra no modo `view`: `mist` (padrão), `rise`, `mask`, `drift`, `settle`. */\n  variant?: MistTextVariant;\n  className?: string;\n}\n\n/** Palavras, e — no modo letra — as letras de cada palavra, com índice global. */\nfunction fatiar(text: string, by: \"word\" | \"letter\") {\n  const palavras = text.split(\" \");\n  let n = 0;\n  const grupos = palavras.map((p, wi) => {\n    const sufixo = wi < palavras.length - 1 ? \" \" : \"\";\n    // No modo letra o espaço NÃO é fatia: ele fica fora da caixa da palavra,\n    // como espaço de verdade, que é onde a linha pode quebrar.\n    const partes = by === \"word\" ? [p + sufixo] : Array.from(p);\n    return partes.map((s) => ({ s, i: n++ }));\n  });\n  return { grupos, total: n };\n}\n\nexport function MistText({\n  text,\n  as = \"p\",\n  by = \"word\",\n  mode = \"view\",\n  delay = 0,\n  variant = \"mist\",\n  className,\n}: MistTextProps) {\n  const { grupos, total } = React.useMemo(() => fatiar(text, by), [text, by]);\n  const Corpo = mode === \"scroll\" ? CorpoScroll : CorpoView;\n  return (\n    <Corpo\n      as={as}\n      by={by}\n      delay={delay}\n      variant={variant}\n      className={className}\n      text={text}\n      grupos={grupos}\n      total={total}\n    />\n  );\n}\n\ntype Grupos = ReturnType<typeof fatiar>[\"grupos\"];\n\ninterface CorpoProps {\n  as: MistTag;\n  by: \"word\" | \"letter\";\n  delay: number;\n  variant: MistTextVariant;\n  className?: string;\n  text: string;\n  grupos: Grupos;\n  total: number;\n}\n\n/**\n * O esqueleto comum aos dois modos: cópia acessível + fatias visuais. No modo\n * letra, a palavra é uma caixa `nowrap` (armadilha 2).\n */\nfunction Fatias({\n  by,\n  grupos,\n  render,\n}: {\n  by: \"word\" | \"letter\";\n  grupos: Grupos;\n  render: (s: string, i: number) => React.ReactNode;\n}) {\n  if (by === \"word\") return <>{grupos.map((g) => g.map(({ s, i }) => render(s, i)))}</>;\n  return (\n    <>\n      {grupos.map((g, gi) => (\n        <React.Fragment key={gi}>\n          <span className=\"inline-block whitespace-nowrap\">{g.map(({ s, i }) => render(s, i))}</span>\n          {gi < grupos.length - 1 ? \" \" : null}\n        </React.Fragment>\n      ))}\n    </>\n  );\n}\n\nfunction CorpoView({ as, by, delay, variant, className, text, grupos }: CorpoProps) {\n  const reduce = useReducedMotion();\n  const Tag = motion[as] as React.ElementType;\n  const passo = by === \"word\" ? STAGGER.word : STAGGER.letter;\n\n  const variants = (i: number) => ({\n    hidden: reduce ? { opacity: 0 } : ESCONDIDO[variant],\n    show: {\n      opacity: 1,\n      x: 0,\n      y: 0,\n      scale: 1,\n      filter: \"blur(0px)\",\n      ...(variant === \"mask\" && !reduce ? { clipPath: CORTE_ABERTO } : null),\n      transition: { duration: DUR_REVEAL, ease: EASE_OUT, delay: delay + Math.min(i * passo, STAGGER.cap) },\n    },\n  });\n\n  return (\n    <Tag\n      className={className}\n      initial=\"hidden\"\n      whileInView=\"show\"\n      // Margem negativa: entra quando a frase já está DENTRO da tela, não no\n      // primeiro pixel — senão a cascata acontece no rodapé, fora do olho.\n      viewport={{ once: true, margin: \"-10% 0px\" }}\n    >\n      <span className=\"sr-only\">{text}</span>\n      <Fatias\n        by={by}\n        grupos={grupos}\n        render={(s, i) => (\n          <motion.span key={i} aria-hidden className=\"inline-block whitespace-pre\" variants={variants(i)}>\n            {s}\n          </motion.span>\n        )}\n      />\n    </Tag>\n  );\n}\n\nfunction CorpoScroll({ as, by, className, text, grupos, total }: CorpoProps) {\n  const ref = React.useRef<HTMLElement>(null);\n  const Tag = as as React.ElementType;\n  const { scrollYProgress } = useScroll({ target: ref, offset: [\"start 85%\", \"end 45%\"] });\n  // Mola: suaviza a roda e evita o ScrollTimeline nativo, que mede errado\n  // dentro de `sticky` (armadilha 3). Não troque por `scrollYProgress` direto.\n  const prog = useSpring(scrollYProgress, SCROLL_SPRING);\n  const span = total + 0.5; // armadilha 4: a última faixa fecha em 1\n\n  return (\n    <Tag ref={ref} className={className}>\n      <span className=\"sr-only\">{text}</span>\n      <Fatias\n        by={by}\n        grupos={grupos}\n        render={(s, i) => (\n          <FatiaScroll key={i} prog={prog} ini={i / span} fim={(i + 1.5) / span}>\n            {s}\n          </FatiaScroll>\n        )}\n      />\n    </Tag>\n  );\n}\n\nfunction FatiaScroll({\n  prog,\n  ini,\n  fim,\n  children,\n}: {\n  prog: MotionValue<number>;\n  ini: number;\n  fim: number;\n  children: string;\n}) {\n  const reduce = useReducedMotion();\n  const opacity = useTransform(prog, [ini, fim], [SCROLL_FLOOR, 1]);\n  const blur = useTransform(prog, [ini, fim], reduce ? [0, 0] : [8, 0]);\n  const filter = useTransform(blur, (b) => `blur(${b}px)`);\n  return (\n    <motion.span aria-hidden className=\"inline-block whitespace-pre\" style={{ opacity, filter }}>\n      {children}\n    </motion.span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Texto que sai da névoa por palavra ou letra, uma vez ou acompanhando a rolagem",
      "evite": "Título que sobe linha por linha: line-reveal; troca de palavra em rótulo, text-morph"
    }
  },
  "categories": [
    "texto",
    "blur",
    "nevoa",
    "reveal",
    "scroll",
    "palavra",
    "letra",
    "tipografia"
  ],
  "type": "registry:ui"
}