{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "masked-text",
  "type": "registry:ui",
  "title": "Masked Text",
  "description": "Display type used as a window onto a moving texture — gradient, grain, stripes or your image.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/masked-text.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type MaskedTextMode = \"gradient\" | \"noise\" | \"stripes\" | \"image\";\n\nexport interface MaskedTextProps extends React.HTMLAttributes<HTMLElement> {\n  /** Element to render. */\n  as?: \"h1\" | \"h2\" | \"h3\" | \"p\" | \"span\" | \"div\";\n  /** Texture shown through the letterforms. */\n  mode?: MaskedTextMode;\n  /** Image URL for `mode=\"image\"` (falls back to the gradient without one). */\n  src?: string;\n  /** First texture colour. */\n  accent?: string;\n  /** Second texture colour. */\n  secondary?: string;\n  /** Texture drift speed multiplier; 0 keeps it still. */\n  speed?: number;\n  /** Gradient / stripe angle in degrees. */\n  angle?: number;\n  /** How far the texture follows a fine pointer, in px; 0 disables parallax. */\n  parallax?: number;\n}\n\n/* --------------------------- reduced-motion hook --------------------------- */\n\nconst REDUCED_QUERY = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribeReducedMotion(onChange: () => void) {\n  const mq = window.matchMedia(REDUCED_QUERY);\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(REDUCED_QUERY).matches,\n    () => false,\n  );\n}\n\n/* -------------------------------- textures -------------------------------- */\n\n// Monochrome fractal noise with a soft alpha, tiled; blended over the colour layer for grain.\nconst GRAIN =\n  \"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.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1.1 -0.25'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E\\\")\";\nconst GRAIN_TILE = 160;\nconst STRIPE_TILE = 22;\n\ninterface Texture {\n  image: string;\n  size: string;\n  blend?: string;\n  /** Horizontal period of the moving layer in px, or \"pct\" for a 200%-wide gradient. */\n  period: number | \"pct\";\n}\n\nfunction buildTexture(mode: MaskedTextMode, src: string | undefined, angle: number): Texture {\n  const ink = \"var(--mt-ink)\";\n  const a = \"var(--mt-accent)\";\n  const b = \"var(--mt-b)\";\n  // A periodic gradient (same colour at both ends) that tiles seamlessly at 200% width.\n  const flow = `linear-gradient(${angle}deg, ${ink} 0%, ${a} 22%, ${b} 50%, ${a} 78%, ${ink} 100%)`;\n  switch (mode) {\n    case \"stripes\":\n      return {\n        image: `linear-gradient(45deg, ${a} 25%, ${ink} 25%, ${ink} 50%, ${a} 50%, ${a} 75%, ${ink} 75%)`,\n        size: `${STRIPE_TILE}px ${STRIPE_TILE}px`,\n        period: STRIPE_TILE,\n      };\n    case \"noise\":\n      return {\n        image: `${GRAIN}, linear-gradient(${angle}deg, ${a}, ${b})`,\n        size: `${GRAIN_TILE}px ${GRAIN_TILE}px, 100% 100%`,\n        blend: \"multiply, normal\",\n        period: \"pct\",\n      };\n    case \"image\":\n      if (src) {\n        // Oversized so the drift and parallax never reveal an edge.\n        return { image: `url(${JSON.stringify(src)})`, size: \"130% auto\", period: \"pct\" };\n      }\n      return { image: flow, size: \"200% 100%\", period: \"pct\" };\n    default:\n      return { image: flow, size: \"200% 100%\", period: \"pct\" };\n  }\n}\n\n/* -------------------------------- component -------------------------------- */\n\n/**\n * Display type used as a mask over a moving texture (background-clip: text). The text is real,\n * selectable and read normally by assistive tech; the texture drifts from one rAF loop that\n * pauses offscreen and in hidden tabs, and follows a fine pointer for a slight parallax.\n * Browsers without background-clip: text get solid ink-coloured text instead.\n */\nexport function MaskedText({\n  as = \"h2\",\n  mode = \"gradient\",\n  src,\n  accent = \"#FF7448\",\n  secondary = \"#B6F3D1\",\n  speed = 1,\n  angle = 100,\n  parallax = 24,\n  className = \"\",\n  style,\n  children,\n  onPointerMove,\n  onPointerLeave,\n  ...props\n}: MaskedTextProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const ref = React.useRef<HTMLElement>(null);\n  const pointerRef = React.useRef({ x: 0, y: 0 });\n  const wakeRef = React.useRef<(() => void) | null>(null);\n  const Tag = as as React.ElementType;\n\n  const texture = buildTexture(mode, src, angle);\n  const { period } = texture;\n\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const drift = reduceMotion ? 0 : speed;\n    const follow = reduceMotion ? 0 : parallax;\n\n    let frame = 0;\n    let visible = true;\n    let last = performance.now();\n    let travel = 0; // accumulated drift in px\n    const shown = { x: 0, y: 0 }; // smoothed parallax offset\n    let grainStep = 0;\n    let grain = { x: 0, y: 0 };\n\n    const write = () => {\n      const width = el.offsetWidth || 1;\n      const x = travel + shown.x;\n      let base: string;\n      if (period === \"pct\") {\n        // 200%-wide image: one full period is twice the element width.\n        const span = width * 2;\n        const pos = ((x % span) + span) % span;\n        if (mode === \"image\" && src) {\n          // Photos do not tile, so they sway within their 30% overscan instead of scrolling.\n          const sway = Math.sin(travel / 160) * Math.min(width * 0.08, 48);\n          base = `calc(50% + ${sway + shown.x}px) calc(50% + ${shown.y}px)`;\n        } else {\n          base = `${-pos}px ${shown.y}px`;\n        }\n      } else {\n        base = `${x % period}px ${shown.y}px`;\n      }\n      el.style.backgroundPosition = mode === \"noise\" ? `${grain.x}px ${grain.y}px, ${base}` : base;\n    };\n\n    const tick = (now: number) => {\n      const dt = Math.min(64, now - last);\n      last = now;\n      travel += drift * dt * 0.04; // ~40px/s at speed 1\n      shown.x += (pointerRef.current.x * follow - shown.x) * 0.08;\n      shown.y += (pointerRef.current.y * follow * 0.5 - shown.y) * 0.08;\n      if (mode === \"noise\" && drift > 0) {\n        // Grain re-seeds in steps (~12 per second at speed 1) to read as film grain, not smear.\n        grainStep += dt * drift;\n        if (grainStep > 83) {\n          grainStep = 0;\n          grain = { x: Math.round(Math.random() * GRAIN_TILE), y: Math.round(Math.random() * GRAIN_TILE) };\n        }\n      }\n      write();\n      const settled =\n        drift === 0 &&\n        Math.abs(pointerRef.current.x * follow - shown.x) < 0.1 &&\n        Math.abs(pointerRef.current.y * follow * 0.5 - shown.y) < 0.1;\n      frame = settled || !visible || document.hidden ? 0 : requestAnimationFrame(tick);\n    };\n\n    const wake = () => {\n      if (frame || !visible || document.hidden) return;\n      last = performance.now();\n      frame = requestAnimationFrame(tick);\n    };\n    wakeRef.current = wake;\n\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      if (visible) wake();\n    });\n    io.observe(el);\n    const onVisibility = () => wake();\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    write();\n    wake();\n\n    return () => {\n      cancelAnimationFrame(frame);\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      wakeRef.current = null;\n      el.style.backgroundPosition = \"\";\n    };\n  }, [mode, src, period, speed, parallax, reduceMotion]);\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLElement>) => {\n    onPointerMove?.(event);\n    // Parallax only for a real mouse/trackpad; touch scrolling should not drag the texture.\n    if (event.pointerType !== \"mouse\" || parallax <= 0) return;\n    const rect = event.currentTarget.getBoundingClientRect();\n    pointerRef.current = {\n      x: ((event.clientX - rect.left) / rect.width - 0.5) * 2,\n      y: ((event.clientY - rect.top) / rect.height - 0.5) * 2,\n    };\n    wakeRef.current?.();\n  };\n\n  const handlePointerLeave = (event: React.PointerEvent<HTMLElement>) => {\n    onPointerLeave?.(event);\n    pointerRef.current = { x: 0, y: 0 };\n    wakeRef.current?.();\n  };\n\n  return (\n    <Tag\n      ref={ref}\n      className={[\n        \"w-fit max-w-full py-[0.06em] font-semibold tracking-[-0.04em] [box-decoration-break:clone] [-webkit-box-decoration-break:clone]\",\n        // Fallback: solid ink. Only where text clipping exists do we paint the texture and hide the fill.\n        \"text-[color:var(--mt-ink)] [--mt-ink:#18181b] dark:[--mt-ink:#fafafa]\",\n        // On light pages a pale secondary is pulled 40% towards the ink so the letters stay legible.\n        \"[--mt-b:color-mix(in_oklab,var(--mt-secondary)_60%,var(--mt-ink))] dark:[--mt-b:var(--mt-secondary)]\",\n        \"supports-[(background-clip:text)_or_(-webkit-background-clip:text)]:bg-clip-text\",\n        \"supports-[(background-clip:text)_or_(-webkit-background-clip:text)]:text-transparent\",\n        \"supports-[(background-clip:text)_or_(-webkit-background-clip:text)]:[background-image:var(--mt-image)]\",\n        \"supports-[(background-clip:text)_or_(-webkit-background-clip:text)]:[background-size:var(--mt-size)]\",\n        \"supports-[(background-clip:text)_or_(-webkit-background-clip:text)]:[background-blend-mode:var(--mt-blend)]\",\n        \"selection:bg-[var(--mt-accent)] selection:text-zinc-950\",\n        \"forced-colors:bg-none forced-colors:text-[CanvasText]\",\n        className,\n      ].join(\" \")}\n      style={{\n        [\"--mt-accent\" as string]: accent,\n        [\"--mt-secondary\" as string]: secondary,\n        [\"--mt-image\" as string]: texture.image,\n        [\"--mt-size\" as string]: texture.size,\n        [\"--mt-blend\" as string]: texture.blend ?? \"normal\",\n        backgroundRepeat: \"repeat\",\n        ...style,\n      }}\n      onPointerMove={handlePointerMove}\n      onPointerLeave={handlePointerLeave}\n      {...props}\n    >\n      {children}\n    </Tag>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/masked-text"
}
