{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "drip-word",
  "title": "Drip Word",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Uma palavra de tinta viscosa em shader: dos pés das letras descem fios que soltam a gota, e arrastar de lado inclina a gravidade.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "@visant/css-color",
    "@visant/use-tilt"
  ],
  "files": [
    {
      "path": "registry/visant/ui/drip-word.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useReducedMotion } from \"motion/react\";\n\nimport { useTilt } from \"@/hooks/use-tilt\";\nimport { onThemeChange, resolveCssColor } from \"@/lib/css-color\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface DripWordProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** A palavra que escorre. Curta: é desenhada grande. */\n  word?: string;\n  /** A tinta. Qualquer cor CSS ou token. */\n  ink?: string;\n  /**\n   * Quantos fios escorrem.\n   * @min 0\n   * @max 7\n   * @step 1\n   */\n  drips?: number;\n  /**\n   * Velocidade da gota.\n   * @min 0\n   * @max 100\n   * @step 1\n   */\n  speed?: number;\n}\n\nconst MAX_FIOS = 7;\nconst VERT = `attribute vec2 a_pos;void main(){gl_Position=vec4(a_pos,0.0,1.0);}`;\n\nconst FRAG = `\n#extension GL_OES_standard_derivatives : enable\n#ifdef GL_FRAGMENT_PRECISION_HIGH\nprecision highp float;\n#else\nprecision mediump float;\n#endif\nuniform sampler2D uMask;\nuniform vec2 uRes;\nuniform vec2 uTexel;\nuniform float uAlcance;\nuniform vec3 uInk;\nuniform vec3 uLuz;\nuniform float uTime;\nuniform vec4 uFio[${MAX_FIOS}];\nuniform vec2 uRaio[${MAX_FIOS}];\n\nfloat smin(float a,float b,float k){float h=clamp(.5+.5*(b-a)/k,0.,1.);return mix(b,a,h)-k*h*(1.-h);}\n\nfloat fio(vec2 p,vec2 a,vec2 b,float rRaiz,float rGota){\n  vec2 pa=p-a,ba=b-a;\n  float compr=length(ba);\n  float h=clamp(dot(pa,ba)/max(dot(ba,ba),1e-5),0.,1.);\n  // O afinamento conta em distância, não em fração: fio curto e fio longo têm o mesmo pescoço.\n  float r=mix(rRaiz,rGota*.3,smoothstep(0.,.075,h*compr));\n  float pescoco=length(pa-ba*h)-r;\n  float gota=length(p-b)-rGota;\n  return smin(pescoco,gota,.022);\n}\n\nfloat campo(vec2 q){\n  return (texture2D(uMask,q).a-.5)*uAlcance;\n}\n\nvoid main(){\n  float asp=uRes.x/uRes.y;\n  vec2 uv=vec2(gl_FragCoord.x/uRes.x,1.-gl_FragCoord.y/uRes.y);\n  vec2 p=vec2(uv.x*asp,uv.y);\n  vec2 q=uv;\n  q.x+=sin(uv.y*18.+uTime*.8)*.0011;\n  q.y+=sin(uv.x*26.-uTime*.6)*.0014;\n  // Cinco amostras do campo arredondam os cantos: tinta não tem quina de vetor.\n  float d=campo(q)*.28;\n  d+=campo(q+vec2(uTexel.x,0.))*.18;\n  d+=campo(q-vec2(uTexel.x,0.))*.18;\n  d+=campo(q+vec2(0.,uTexel.y))*.18;\n  d+=campo(q-vec2(0.,uTexel.y))*.18;\n  for(int i=0;i<${MAX_FIOS};i++){\n    if(uRaio[i].y<=0.)continue;\n    d=smin(d,fio(p,uFio[i].xy,uFio[i].zw,uRaio[i].x,uRaio[i].y),.03);\n  }\n#ifdef GL_OES_standard_derivatives\n  float aa=max(fwidth(d),1e-4);\n  vec2 n=vec2(dFdx(d),dFdy(d))/aa;\n  float rim=smoothstep(-.01,-.002,d);\n  float brilho=pow(max(dot(normalize(n+1e-5),normalize(vec2(-.55,.8))),0.),3.)*rim;\n#else\n  float aa=1.5/uRes.y;\n  float brilho=0.;\n#endif\n  float a=1.-smoothstep(-aa,aa,d);\n  vec3 c=mix(uInk,uLuz,brilho*.2);\n  gl_FragColor=vec4(c*a,a);\n}\n`;\n\nconst fracao = (v: number) => v - Math.floor(v);\nconst liso = (a: number, b: number, v: number) => {\n  const t = Math.max(0, Math.min(1, (v - a) / (b - a)));\n  return t * t * (3 - 2 * t);\n};\n\ntype Pe = { x: number; y: number };\n\nconst INF = 1e12;\n\n/** Uma linha da transformada de distância (Felzenszwalb): quadrado da distância ao ponto mais perto. */\nfunction linha(f: Float64Array, n: number, d: Float64Array, v: Int32Array, z: Float64Array) {\n  let k = 0;\n  v[0] = 0;\n  z[0] = -INF;\n  z[1] = INF;\n  for (let q = 1; q < n; q++) {\n    let s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);\n    while (s <= z[k]) {\n      k--;\n      s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);\n    }\n    k++;\n    v[k] = q;\n    z[k] = s;\n    z[k + 1] = INF;\n  }\n  k = 0;\n  for (let q = 0; q < n; q++) {\n    while (z[k + 1] < q) k++;\n    d[q] = (q - v[k]) * (q - v[k]) + f[v[k]];\n  }\n}\n\n/** Distância até os pixels marcados, nas duas direções da grade. */\nfunction distancias(marcado: Uint8Array, w: number, h: number) {\n  const m = Math.max(w, h);\n  const f = new Float64Array(m);\n  const d = new Float64Array(m);\n  const v = new Int32Array(m);\n  const z = new Float64Array(m + 1);\n  const saida = new Float64Array(w * h);\n  for (let i = 0; i < w * h; i++) saida[i] = marcado[i] ? 0 : INF;\n  for (let x = 0; x < w; x++) {\n    for (let y = 0; y < h; y++) f[y] = saida[y * w + x];\n    linha(f, h, d, v, z);\n    for (let y = 0; y < h; y++) saida[y * w + x] = d[y];\n  }\n  for (let y = 0; y < h; y++) {\n    for (let x = 0; x < w; x++) f[x] = saida[y * w + x];\n    linha(f, w, d, v, z);\n    for (let x = 0; x < w; x++) saida[y * w + x] = Math.sqrt(d[x]);\n  }\n  return saida;\n}\n\n/** O campo de distância da palavra em 8 bits: 0,5 é a borda, e `alcance` são os px cobertos. */\nfunction campoDaPalavra(dado: ImageData, alcance: number) {\n  const { width: w, height: h, data } = dado;\n  const dentro = new Uint8Array(w * h);\n  const fora = new Uint8Array(w * h);\n  for (let i = 0; i < w * h; i++) {\n    const tinta = data[i * 4 + 3] > 127;\n    dentro[i] = tinta ? 1 : 0;\n    fora[i] = tinta ? 0 : 1;\n  }\n  const ateTinta = distancias(dentro, w, h);\n  const ateFundo = distancias(fora, w, h);\n  const campo = new Uint8Array(w * h);\n  for (let i = 0; i < w * h; i++) {\n    const assinada = ateTinta[i] - ateFundo[i];\n    campo[i] = Math.max(0, Math.min(255, Math.round((0.5 + assinada / alcance) * 255)));\n  }\n  return campo;\n}\n\n/** Os pés das letras: colunas onde a tinta encosta na linha de base, de onde a gota nasce. */\nfunction acharPes(dado: ImageData, quantos: number): Pe[] {\n  const { width: w, height: h, data } = dado;\n  const fundo: number[] = [];\n  for (let x = 0; x < w; x++) {\n    let y = -1;\n    for (let l = h - 1; l >= 0; l--) {\n      if (data[(l * w + x) * 4 + 3] > 128) {\n        y = l;\n        break;\n      }\n    }\n    fundo.push(y);\n  }\n  const base = Math.max(...fundo);\n  if (base < 0) return [];\n  const tolerancia = h * 0.018;\n  const trechos: Pe[] = [];\n  let ini = -1;\n  for (let x = 0; x <= w; x++) {\n    const no = x < w && fundo[x] >= base - tolerancia;\n    if (no && ini < 0) ini = x;\n    if (!no && ini >= 0) {\n      trechos.push({ x: (ini + x - 1) / 2 / w, y: base / h });\n      ini = -1;\n    }\n  }\n  if (trechos.length <= quantos) return trechos;\n  return Array.from({ length: quantos }, (_, i) => trechos[Math.round((i * (trechos.length - 1)) / Math.max(quantos - 1, 1))]);\n}\n\n/**\n * Drip Word: a palavra é tinta viscosa. Dos pés das letras nascem fios que descem, afinam\n * no pescoço, soltam a gota e recolhem, e arrastar de lado inclina a gravidade.\n *\n * - **Shader, não contorno animado.** A palavra vira um campo de distância, calculado uma\n *   vez por tamanho, e o desenho é esse campo cortado num limiar. Os fios entram no mesmo campo com união suave,\n *   então a raiz funde com a letra sem emenda nenhuma. É isso que lê como líquido.\n * - **Os fios nascem onde há tinta.** A máscara é lida uma vez e os pés saem da linha de\n *   base de verdade: funciona com qualquer palavra e qualquer fonte.\n * - **Minimal de propósito.** Uma tinta só, e um brilho curto na borda misturando a tinta\n *   com o fundo, sem cor nova. O brilho vem de `fwidth`, sem amostrar o campo três vezes,\n *   para caber no celular. A troca de tema relê os tokens em vez de repintar por quadro.\n * - **Um contexto só, e devolvido.** Canvas criado no efeito, contexto perdido de propósito\n *   no unmount, laço parado fora da tela. Sem WebGL, a palavra aparece em texto.\n * - **`pan-y`.** Arrastar de lado inclina; rolar em pé é da página. No celular, o primeiro\n *   toque pede o giroscópio, e inclinar o aparelho vira a gravidade.\n * - **Movimento reduzido:** um quadro parado com os fios já descidos, sem laço. O arrasto\n *   ainda inclina, sem balanço.\n */\nexport function DripWord({ word = \"fluxo\", ink = \"var(--foreground)\", drips = 6, speed = 40, className, ...rest }: DripWordProps) {\n  const reduz = useReducedMotion();\n  const caixa = React.useRef<HTMLDivElement>(null);\n  const [semGl, setSemGl] = React.useState(false);\n  const entrada = React.useRef({ alvo: 0, sensor: null as number | null });\n  const redesenhar = React.useRef<() => void>(() => {});\n  const preso = React.useRef<{ x: number; de: number; larg: number } | null>(null);\n\n  const giro = useTilt((g) => {\n    const v = Math.max(-1, Math.min(1, g / 28));\n    entrada.current.sensor = v;\n    if (!preso.current) entrada.current.alvo = v;\n    redesenhar.current();\n  });\n\n  React.useEffect(() => {\n    const host = caixa.current;\n    if (!host) return;\n    const canvas = document.createElement(\"canvas\");\n    canvas.className = \"absolute inset-0 size-full\";\n    canvas.setAttribute(\"aria-hidden\", \"true\");\n    host.appendChild(canvas);\n    const gl = canvas.getContext(\"webgl\", { alpha: true, premultipliedAlpha: true, antialias: false });\n    if (!gl) {\n      canvas.remove();\n      setSemGl(true);\n      return;\n    }\n    gl.getExtension(\"OES_standard_derivatives\");\n    const compilar = (tipo: number, src: string) => {\n      const s = gl.createShader(tipo)!;\n      gl.shaderSource(s, src);\n      gl.compileShader(s);\n      return s;\n    };\n    const prog = gl.createProgram()!;\n    gl.attachShader(prog, compilar(gl.VERTEX_SHADER, VERT));\n    gl.attachShader(prog, compilar(gl.FRAGMENT_SHADER, FRAG));\n    gl.linkProgram(prog);\n    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {\n      canvas.remove();\n      setSemGl(true);\n      return;\n    }\n    gl.useProgram(prog);\n    gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());\n    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);\n    const aPos = gl.getAttribLocation(prog, \"a_pos\");\n    gl.enableVertexAttribArray(aPos);\n    gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);\n    const u = (n: string) => gl.getUniformLocation(prog, n);\n    const uRes = u(\"uRes\");\n    const uTexel = u(\"uTexel\");\n    const uAlcance = u(\"uAlcance\");\n    const uInk = u(\"uInk\");\n    const uLuz = u(\"uLuz\");\n    const uTime = u(\"uTime\");\n    const uFio = u(\"uFio\");\n    const uRaio = u(\"uRaio\");\n    gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n\n    let asp = 1;\n    let pes: Pe[] = [];\n\n    const lerCores = () => {\n      gl.uniform3fv(uInk, resolveCssColor(ink, host));\n      gl.uniform3fv(uLuz, resolveCssColor(\"var(--background)\", host));\n    };\n\n    const montar = () => {\n      const larg = host.clientWidth;\n      const alt = host.clientHeight;\n      if (!larg || !alt) return;\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.round(larg * dpr);\n      canvas.height = Math.round(alt * dpr);\n      gl.viewport(0, 0, canvas.width, canvas.height);\n      gl.uniform2f(uRes, canvas.width, canvas.height);\n      asp = larg / alt;\n\n      const m = document.createElement(\"canvas\");\n      // Sempre o dobro do CSS: a borda do campo sai de um limiar, e em 1x ela serrilha.\n      const escala = 2;\n      m.width = Math.round(larg * escala);\n      m.height = Math.round(alt * escala);\n      const c = m.getContext(\"2d\", { willReadFrequently: true });\n      if (!c) return;\n      c.scale(escala, escala);\n      const estilo = getComputedStyle(host);\n      const familia = estilo.getPropertyValue(\"--font-display\").trim() || estilo.fontFamily;\n      // Letra por letra, com um fio de ar: sem isso, a ligadura e o kerning colam o \"fl\", e o\n      // limiar do campo transforma o encosto numa massa só.\n      const letras = [...word];\n      const AR = 0.02;\n      c.font = `600 100px ${familia}`;\n      const em100 = letras.reduce((s, l) => s + c.measureText(l).width / 100 + AR, -AR);\n      const corpo = Math.min((0.88 * larg) / (em100 || 1), alt * 0.5);\n      c.font = `600 ${corpo}px ${familia}`;\n      c.textAlign = \"left\";\n      c.textBaseline = \"alphabetic\";\n      let xLetra = (larg - em100 * corpo) / 2;\n      for (const l of letras) {\n        c.fillText(l, xLetra, alt * 0.5);\n        xLetra += c.measureText(l).width + AR * corpo;\n      }\n      const dado = c.getImageData(0, 0, m.width, m.height);\n      const alcance = 28 * escala; // pouco alcance dá mais degraus dos 8 bits perto da borda\n      gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);\n      gl.texImage2D(gl.TEXTURE_2D, 0, gl.ALPHA, m.width, m.height, 0, gl.ALPHA, gl.UNSIGNED_BYTE, campoDaPalavra(dado, alcance));\n      gl.uniform2f(uTexel, 1.25 / larg, 1.25 / alt);\n      gl.uniform1f(uAlcance, alcance / m.height); // o campo em unidades de altura, como os fios\n      pes = acharPes(dado, Math.min(drips, MAX_FIOS));\n    };\n\n    const fios = new Float32Array(MAX_FIOS * 4);\n    const raios = new Float32Array(MAX_FIOS * 2);\n    let inclina = 0;\n    let velocidade = 0;\n\n    const pintar = (t: number) => {\n      gl.uniform1f(uTime, t);\n      fios.fill(0);\n      raios.fill(0);\n      pes.forEach((pe, i) => {\n        const semente = fracao(Math.sin((i + 1) * 12.9898) * 43758.5453);\n        const periodo = 8 + semente * 7;\n        // Movimento reduzido: um quadro só, sempre com a gota ainda presa ao fio.\n        const ciclo = fracao(t / periodo + semente);\n        const fase = reduz ? Math.max(0.3, Math.min(ciclo, 0.62)) : ciclo;\n        const alcance = 0.1 + semente * 0.24;\n        const cresce = liso(0, 0.68, fase) * alcance;\n        const solta = Math.max(0, fase - 0.68) / 0.32;\n        const queda = solta * solta * 1.1;\n        const ponta = cresce + queda;\n        // Solta o fio depressa: a raiz corre atrás da gota, ou a queda esticaria uma linha até o chão.\n        const raiz = solta > 0 ? ponta - Math.max(cresce * (1 - liso(0, 0.22, solta)), 0.012) : 0;\n        // A gravidade é uma direção: o fio inteiro aponta para ela, em vez de só desviar a ponta.\n        const tomba = inclina * 0.72;\n        const gx = Math.sin(tomba);\n        const gy = Math.cos(tomba);\n        const x = pe.x * asp;\n        const yPonta = pe.y + ponta * gy;\n        fios[i * 4] = x + raiz * gx;\n        fios[i * 4 + 1] = pe.y + raiz * gy;\n        fios[i * 4 + 2] = x + ponta * gx;\n        fios[i * 4 + 3] = yPonta;\n        // Fio curto nasce fino, senão vira um toco achatado no pé da letra; perto do chão a\n        // gota some antes das bordas, para não sair cortada reta.\n        const xPonta = x + ponta * gx;\n        const corpo = liso(0, 0.035, cresce) * (1 - liso(0.84, 0.97, yPonta)) * liso(0.01, 0.06, Math.min(xPonta, asp - xPonta));\n        raios[i * 2] = 0.02 * (1 - solta * 0.85) * corpo;\n        raios[i * 2 + 1] = (0.013 + semente * 0.01) * corpo;\n      });\n      gl.uniform4fv(uFio, fios);\n      gl.uniform2fv(uRaio, raios);\n      gl.drawArrays(gl.TRIANGLES, 0, 3);\n    };\n\n    const taxa = 0.3 + (speed / 100) * 1.6;\n    let raf = 0;\n    let rodando = false;\n    let relogio = 7; // neste ponto do ciclo os fios já desceram e nenhum está no começo\n    let antes = 0;\n    const quadro = (agora: number) => {\n      const dt = Math.min((agora - antes) / 1000, 0.05);\n      antes = agora;\n      relogio += dt * taxa;\n      const balanco = entrada.current.sensor === null ? Math.sin(relogio * 0.4) * 0.05 : 0;\n      velocidade += (entrada.current.alvo + balanco - inclina) * 60 * dt;\n      velocidade *= Math.exp(-5 * dt);\n      inclina += velocidade * dt;\n      pintar(relogio);\n      if (rodando) raf = requestAnimationFrame(quadro);\n    };\n    const ligar = () => {\n      if (rodando || reduz) return;\n      rodando = true;\n      antes = performance.now();\n      raf = requestAnimationFrame(quadro);\n    };\n    const desligar = () => {\n      rodando = false;\n      cancelAnimationFrame(raf);\n    };\n    redesenhar.current = () => {\n      if (!reduz) return;\n      inclina = entrada.current.alvo;\n      pintar(relogio);\n    };\n\n    lerCores();\n    montar();\n    pintar(relogio);\n\n    const ro = new ResizeObserver(() => {\n      montar();\n      pintar(relogio);\n    });\n    ro.observe(host);\n    const io = new IntersectionObserver(([e]) => (e.isIntersecting ? ligar() : desligar()));\n    io.observe(host);\n    const soltarTema = onThemeChange(() => {\n      lerCores();\n      pintar(relogio);\n    });\n    let vivo = true;\n    document.fonts?.ready.then(() => {\n      if (!vivo) return;\n      montar();\n      pintar(relogio);\n    });\n\n    return () => {\n      vivo = false;\n      desligar();\n      ro.disconnect();\n      io.disconnect();\n      soltarTema();\n      redesenhar.current = () => {};\n      gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n      canvas.remove();\n    };\n  }, [word, ink, drips, speed, reduz]);\n\n  return (\n    <div\n      ref={caixa}\n      role=\"img\"\n      aria-label={word}\n      onPointerDown={(e) => {\n        if (e.pointerType === \"touch\") giro.enable();\n        e.currentTarget.setPointerCapture(e.pointerId);\n        preso.current = { x: e.clientX, de: entrada.current.alvo, larg: e.currentTarget.getBoundingClientRect().width || 1 };\n      }}\n      onPointerMove={(e) => {\n        if (!preso.current) return;\n        const d = (e.clientX - preso.current.x) / preso.current.larg;\n        entrada.current.alvo = Math.max(-1, Math.min(1, preso.current.de + d * 2.4));\n        redesenhar.current();\n      }}\n      onPointerUp={() => {\n        preso.current = null;\n        entrada.current.alvo = entrada.current.sensor ?? 0;\n        redesenhar.current();\n      }}\n      onPointerCancel={() => {\n        preso.current = null;\n        entrada.current.alvo = entrada.current.sensor ?? 0;\n        redesenhar.current();\n      }}\n      className={cn(\"relative aspect-[3/2] w-[min(100%,36rem)] cursor-grab touch-pan-y select-none text-foreground active:cursor-grabbing\", className)}\n      {...rest}\n    >\n      {semGl ? <span className=\"absolute inset-0 grid place-items-center text-6xl font-semibold [font-family:var(--font-display,inherit)]\">{word}</span> : null}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Uma palavra de abertura com presença, em dobra de marca ou tela de entrada",
      "evite": "Texto que derrete sob o cursor: heat-word; texto corrido ou título longo: line-reveal"
    }
  },
  "categories": [
    "texto",
    "shader",
    "webgl",
    "liquido",
    "gota",
    "palavra",
    "tipografia"
  ],
  "type": "registry:ui"
}