{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "MaskedHeading-JS-CSS",
	"title": "MaskedHeading",
	"description": "A large headline with a drifting colour mesh or image showing through the glyphs, revealed word by word.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:file",
			"path": "MaskedHeading.css",
			"target": "@components/MaskedHeading.css",
			"content": ".masked-heading {\n  position: relative;\n  width: 100%;\n  margin: 0;\n  padding: 0;\n  text-wrap: balance;\n  -webkit-font-smoothing: antialiased;\n}\n\n.masked-heading__measure {\n  color: transparent;\n}\n\n.masked-heading__word {\n  display: inline-block;\n  white-space: pre;\n}\n\n.masked-heading__word:not(:last-child)::after {\n  content: ' ';\n}\n\n.masked-heading__baseline {\n  display: inline-block;\n  width: 0;\n  height: 0;\n}\n\n.masked-heading__defs {\n  position: absolute;\n  width: 0;\n  height: 0;\n  overflow: hidden;\n}\n\n.masked-heading__reveal {\n  position: absolute;\n  inset: 0;\n  display: block;\n  pointer-events: none;\n}\n\n.masked-heading__clip {\n  position: absolute;\n  inset: 0;\n  display: block;\n}\n\n.masked-heading__media {\n  position: absolute;\n  inset: 0;\n  display: block;\n  will-change: transform, filter;\n}\n\n.masked-heading__source {\n  display: block;\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  user-select: none;\n  -webkit-user-drag: none;\n}\n"
		},
		{
			"type": "registry:component",
			"path": "MaskedHeading.jsx",
			"content": "import { useCallback, useEffect, useId, useMemo, useRef } from 'react';\nimport { gsap } from 'gsap';\n\nimport './MaskedHeading.css';\n\nconst clamp = (v, a, b) => (v < a ? a : v > b ? b : v);\n\nconst MaskedHeading = ({\n  text = 'Designed in the details',\n  tag = 'h2',\n  mediaType = 'image',\n  src = '',\n  poster = '',\n  fillScale = 1.25,\n  parallax = 26,\n  drift = 18,\n  brightness = 1,\n  saturation = 1,\n  grayscale = false,\n  reveal = 'rise',\n  duration = 1.1,\n  stagger = 0.09,\n  trigger = 'view',\n  align = 'center',\n  weight = 700,\n  tracking = -0.03,\n  lineHeight = 1.06,\n  textScale = 0.115,\n  className = '',\n  style,\n  ...rest\n}) => {\n  const rootRef = useRef(null);\n  const measureRef = useRef(null);\n  const revealRef = useRef(null);\n  const mediaRef = useRef(null);\n  const wordRefs = useRef([]);\n  const baseRefs = useRef([]);\n  const glyphRefs = useRef([]);\n  const tweenRef = useRef(null);\n  const offsetRef = useRef({ x: 0, y: 0, tx: 0, ty: 0 });\n\n  const clipId = `mh-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`;\n  const words = useMemo(() => String(text).split(/\\s+/).filter(Boolean), [text]);\n\n  const settingsRef = useRef({});\n  settingsRef.current = { fillScale, parallax, drift, brightness, saturation, grayscale, textScale };\n\n  const place = useCallback(() => {\n    const root = rootRef.current;\n    const media = mediaRef.current;\n    if (!root || !media) return;\n    const s = settingsRef.current;\n    const W = root.clientWidth;\n    const H = root.clientHeight;\n    const off = offsetRef.current;\n\n    const maxX = Math.max(0, ((s.fillScale - 1) / 2) * W);\n    const maxY = Math.max(0, ((s.fillScale - 1) / 2) * H);\n\n    media.style.transform = `translate3d(${clamp(off.x, -maxX, maxX).toFixed(2)}px, ${clamp(off.y, -maxY, maxY).toFixed(2)}px, 0) scale(${s.fillScale})`;\n    media.style.filter = `brightness(${s.brightness}) saturate(${s.saturation})${s.grayscale ? ' grayscale(1)' : ''}`;\n  }, []);\n\n  const sync = useCallback(() => {\n    const root = rootRef.current;\n    const measure = measureRef.current;\n    if (!root || !measure) return;\n    const s = settingsRef.current;\n\n    root.style.fontSize = `${clamp(root.clientWidth * s.textScale, 20, 200).toFixed(1)}px`;\n\n    const cs = window.getComputedStyle(measure);\n    for (let i = 0; i < wordRefs.current.length; i += 1) {\n      const box = wordRefs.current[i];\n      const base = baseRefs.current[i];\n      const glyph = glyphRefs.current[i];\n      if (!box || !base || !glyph) continue;\n      glyph.setAttribute('x', `${box.offsetLeft}`);\n      glyph.setAttribute('y', `${base.offsetTop}`);\n      glyph.style.fontFamily = cs.fontFamily;\n      glyph.style.fontSize = cs.fontSize;\n      glyph.style.fontWeight = cs.fontWeight;\n      glyph.style.fontStyle = cs.fontStyle;\n      glyph.style.letterSpacing = cs.letterSpacing;\n    }\n    place();\n  }, [place]);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n\n    sync();\n    const ro = new ResizeObserver(sync);\n    ro.observe(root);\n    if (document.fonts?.ready) document.fonts.ready.then(sync).catch(() => {});\n\n    let raf = 0;\n    let last = performance.now();\n    let clock = 0;\n\n    const frame = now => {\n      const dt = Math.min(0.05, (now - last) / 1000);\n      last = now;\n      clock += dt;\n      const s = settingsRef.current;\n      const off = offsetRef.current;\n\n      const dx = Math.sin(clock * 0.21) * s.drift;\n      const dy = Math.cos(clock * 0.17) * s.drift * 0.6;\n\n      const ease = 1 - Math.exp(-dt / 0.18);\n      off.x += (off.tx + dx - off.x) * ease;\n      off.y += (off.ty + dy - off.y) * ease;\n\n      place();\n      raf = requestAnimationFrame(frame);\n    };\n\n    const onMove = e => {\n      const s = settingsRef.current;\n      if (s.parallax <= 0) return;\n      const r = root.getBoundingClientRect();\n      const nx = ((e.clientX - r.left) / (r.width || 1)) * 2 - 1;\n      const ny = ((e.clientY - r.top) / (r.height || 1)) * 2 - 1;\n      offsetRef.current.tx = clamp(nx, -1, 1) * -s.parallax;\n      offsetRef.current.ty = clamp(ny, -1, 1) * -s.parallax;\n    };\n\n    const onLeave = () => {\n      offsetRef.current.tx = 0;\n      offsetRef.current.ty = 0;\n    };\n\n    root.addEventListener('pointermove', onMove);\n    root.addEventListener('pointerleave', onLeave);\n    raf = requestAnimationFrame(frame);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      root.removeEventListener('pointermove', onMove);\n      root.removeEventListener('pointerleave', onLeave);\n    };\n  }, [place, sync]);\n\n  useEffect(() => {\n    sync();\n  }, [sync, words, tag, align, weight, tracking, lineHeight, textScale]);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const layer = revealRef.current;\n    if (!root || !layer) return;\n    const glyphs = glyphRefs.current.filter(Boolean);\n    if (!glyphs.length) return;\n\n    const riseDistance = () => (parseFloat(window.getComputedStyle(root).fontSize) || 48) * 1.15;\n\n    const settle = () => {\n      gsap.set(glyphs, { y: 0 });\n      gsap.set(layer, { opacity: 1, scale: 1, clipPath: 'inset(0% 0% 0% 0%)' });\n    };\n\n    const rest = () => {\n      if (reveal === 'rise') {\n        gsap.set(glyphs, { y: riseDistance() });\n      } else if (reveal === 'wipe') {\n        gsap.set(layer, { clipPath: 'inset(0% 100% 0% 0%)' });\n      } else if (reveal === 'fade') {\n        gsap.set(layer, { opacity: 0, scale: 1.08 });\n      }\n    };\n\n    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n    if (reveal === 'none' || reduce) {\n      settle();\n      return;\n    }\n\n    const play = () => {\n      tweenRef.current?.kill();\n      if (reveal === 'rise') {\n        gsap.set(layer, { opacity: 1, scale: 1, clipPath: 'inset(0% 0% 0% 0%)' });\n        tweenRef.current = gsap.fromTo(\n          glyphs,\n          { y: riseDistance() },\n          { y: 0, duration, stagger, ease: 'power4.out', overwrite: 'auto' }\n        );\n      } else if (reveal === 'wipe') {\n        gsap.set(glyphs, { y: 0 });\n        const state = { p: 100 };\n        tweenRef.current = gsap.to(state, {\n          p: 0,\n          duration,\n          ease: 'power3.inOut',\n          overwrite: 'auto',\n          onUpdate: () => {\n            layer.style.clipPath = `inset(0% ${state.p}% 0% 0%)`;\n          }\n        });\n      } else {\n        gsap.set(glyphs, { y: 0 });\n        tweenRef.current = gsap.fromTo(\n          layer,\n          { opacity: 0, scale: 1.08 },\n          { opacity: 1, scale: 1, duration, ease: 'power3.out', overwrite: 'auto' }\n        );\n      }\n    };\n\n    if (trigger === 'hover') {\n      settle();\n      root.addEventListener('pointerenter', play);\n      return () => {\n        root.removeEventListener('pointerenter', play);\n        tweenRef.current?.kill();\n      };\n    }\n\n    if (trigger === 'view') {\n      settle();\n      rest();\n      const io = new IntersectionObserver(\n        entries => {\n          if (entries.some(e => e.isIntersecting)) {\n            play();\n            io.disconnect();\n          }\n        },\n        { threshold: 0.25 }\n      );\n      io.observe(root);\n      return () => {\n        io.disconnect();\n        tweenRef.current?.kill();\n      };\n    }\n\n    play();\n    return () => tweenRef.current?.kill();\n  }, [reveal, trigger, duration, stagger, words]);\n\n  const Tag = tag;\n\n  return (\n    <Tag\n      ref={rootRef}\n      className={`masked-heading ${className}`.trim()}\n      style={{\n        textAlign: align,\n        fontWeight: weight,\n        letterSpacing: `${tracking}em`,\n        lineHeight,\n        ...style\n      }}\n      {...rest}\n    >\n      <span ref={measureRef} className=\"masked-heading__measure\">\n        {words.map((word, i) => (\n          <span\n            key={`${word}-${i}`}\n            ref={el => {\n              wordRefs.current[i] = el;\n            }}\n            className=\"masked-heading__word\"\n          >\n            {word}\n            <i\n              ref={el => {\n                baseRefs.current[i] = el;\n              }}\n              className=\"masked-heading__baseline\"\n            />\n          </span>\n        ))}\n      </span>\n\n      <svg className=\"masked-heading__defs\" aria-hidden=\"true\" focusable=\"false\">\n        <defs>\n          <clipPath id={clipId} clipPathUnits=\"userSpaceOnUse\">\n            {words.map((word, i) => (\n              <text\n                key={`${word}-${i}`}\n                ref={el => {\n                  glyphRefs.current[i] = el;\n                }}\n              >\n                {word}\n              </text>\n            ))}\n          </clipPath>\n        </defs>\n      </svg>\n\n      <span ref={revealRef} className=\"masked-heading__reveal\">\n        <span className=\"masked-heading__clip\" style={{ clipPath: `url(#${clipId})` }}>\n          <span ref={mediaRef} className=\"masked-heading__media\">\n            {mediaType === 'video' ? (\n              <video className=\"masked-heading__source\" src={src} poster={poster} autoPlay muted loop playsInline />\n            ) : (\n              <img className=\"masked-heading__source\" src={src} alt=\"\" draggable={false} />\n            )}\n          </span>\n        </span>\n      </span>\n    </Tag>\n  );\n};\n\nexport default MaskedHeading;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"gsap@^3.13.0"
	]
}