{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "glitch-text",
  "type": "registry:ui",
  "title": "Glitch Text",
  "description": "A tasteful RGB-split glitch: colour channels jump out in clipped slices, capped at three bursts a second.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/glitch-text.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type GlitchTrigger = \"hover\" | \"always\" | \"interval\";\n\nexport interface GlitchTextProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  /** The text to render. */\n  text?: string;\n  /** Element to render. */\n  as?: \"span\" | \"p\" | \"h1\" | \"h2\" | \"h3\" | \"div\";\n  /** Strength of the split and slice offsets, 0–1. */\n  intensity?: number;\n  /** When bursts happen: while hovered/focused, continuously at random, or on a fixed interval. */\n  trigger?: GlitchTrigger;\n  /** Colour of the first split channel (shifted left). */\n  colorA?: string;\n  /** Colour of the second split channel (shifted right). */\n  colorB?: string;\n  /** Length of one glitch burst, in ms. */\n  duration?: number;\n  /** Time between bursts for `trigger=\"interval\"`, in ms (never below the flash cap). */\n  interval?: 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/* ------------------------------ flash safety ------------------------------ */\n\n/**\n * Photosensitivity guard. A burst (colour layers appearing, then disappearing) counts as one\n * flash, and bursts never start less than 1000/3 ms apart — at most three per second, inside\n * the WCAG 2.3.1 \"three flashes\" threshold. Within a burst the slices move every FRAME_MS but\n * stay visible, and only thin bands of the glyphs change.\n */\nconst MIN_BURST_GAP = Math.ceil(1000 / 3);\nconst FRAME_MS = 70;\n\nconst rand = (min: number, max: number) => min + Math.random() * (max - min);\n\n/** A random horizontal band of the text, as an inset() clip path. */\nfunction slice(intensity: number) {\n  const height = rand(6, 18 + 22 * intensity);\n  const top = rand(0, 100 - height);\n  return `inset(${top.toFixed(1)}% 0 ${(100 - top - height).toFixed(1)}% 0)`;\n}\n\n/* -------------------------------- component -------------------------------- */\n\n/**\n * Text with a tasteful RGB-split glitch. Two aria-hidden copies in the channel colours sit behind\n * the real text; during a burst they jump sideways in clipped horizontal slices while the main\n * text jitters slightly. Bursts are capped at three per second and the effect is off entirely\n * with reduced motion.\n */\nexport function GlitchText({\n  text = \"Signal lost\",\n  as = \"span\",\n  intensity = 0.5,\n  trigger = \"hover\",\n  colorA = \"#FF7448\",\n  colorB = \"#2BB3C0\",\n  duration = 420,\n  interval = 2400,\n  className = \"\",\n  style,\n  onPointerEnter,\n  onPointerLeave,\n  onFocus,\n  onBlur,\n  ...props\n}: GlitchTextProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLElement>(null);\n  const mainRef = React.useRef<HTMLSpanElement>(null);\n  const aRef = React.useRef<HTMLSpanElement>(null);\n  const bRef = React.useRef<HTMLSpanElement>(null);\n  const hoverRef = React.useRef<((active: boolean) => void) | null>(null);\n  const Tag = as as React.ElementType;\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const main = mainRef.current;\n    const a = aRef.current;\n    const b = bRef.current;\n    if (reduceMotion || !root || !main || !a || !b) return;\n\n    const k = Math.min(1, Math.max(0, intensity));\n    const burstLength = Math.max(FRAME_MS, duration);\n    let timer = 0;\n    let bursting = false;\n    let lastStart = -Infinity;\n    let hovering = false;\n    let visible = true;\n\n    const clear = () => {\n      a.style.opacity = \"0\";\n      b.style.opacity = \"0\";\n      main.style.transform = \"\";\n    };\n\n    const frame = () => {\n      // Offsets scale with the font size so the effect looks the same at any size.\n      const em = parseFloat(getComputedStyle(root).fontSize) || 16;\n      const shift = em * (0.03 + 0.09 * k);\n      a.style.opacity = \"1\";\n      b.style.opacity = \"1\";\n      a.style.clipPath = slice(k);\n      b.style.clipPath = slice(k);\n      a.style.transform = `translate3d(${-rand(0.4, 1) * shift}px, 0, 0)`;\n      b.style.transform = `translate3d(${rand(0.4, 1) * shift}px, 0, 0)`;\n      main.style.transform = Math.random() < 0.35 * k ? `translate3d(${rand(-0.25, 0.25) * shift}px, 0, 0)` : \"\";\n    };\n\n    const canRun = () => visible && !document.hidden;\n\n    const nextGap = () => {\n      if (trigger === \"interval\") return Math.max(MIN_BURST_GAP, interval);\n      if (trigger === \"always\") return rand(500, 2200) * (1.4 - 0.6 * k);\n      return 650; // hover: repeat while the pointer stays\n    };\n\n    const schedule = (delay: number) => {\n      window.clearTimeout(timer);\n      timer = window.setTimeout(burst, Math.max(0, delay));\n    };\n\n    function burst() {\n      if (!canRun() || (trigger === \"hover\" && !hovering)) return;\n      const now = performance.now();\n      // Flash cap: never start a burst sooner than MIN_BURST_GAP after the previous one.\n      if (now - lastStart < MIN_BURST_GAP) {\n        schedule(MIN_BURST_GAP - (now - lastStart));\n        return;\n      }\n      lastStart = now;\n      bursting = true;\n      const end = now + burstLength;\n      const step = () => {\n        if (!canRun() || performance.now() >= end) {\n          bursting = false;\n          clear();\n          if (trigger !== \"hover\" || hovering) schedule(nextGap());\n          return;\n        }\n        frame();\n        timer = window.setTimeout(step, FRAME_MS);\n      };\n      step();\n    }\n\n    hoverRef.current = (active) => {\n      if (trigger !== \"hover\") return;\n      hovering = active;\n      if (active && !bursting) schedule(0);\n    };\n\n    const resume = () => {\n      window.clearTimeout(timer);\n      bursting = false;\n      clear();\n      if (!canRun()) return;\n      if (trigger === \"always\") schedule(rand(200, 900));\n      else if (trigger === \"interval\") schedule(Math.max(MIN_BURST_GAP, interval));\n      else if (hovering) schedule(0);\n    };\n\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      resume();\n    });\n    io.observe(root);\n    document.addEventListener(\"visibilitychange\", resume);\n\n    // Inside a link or button, keyboard focus on that control also triggers the hover glitch.\n    const host = root.parentElement?.closest<HTMLElement>(\"a[href], button, [tabindex]\") ?? null;\n    const onHostFocus = () => hoverRef.current?.(true);\n    const onHostBlur = () => hoverRef.current?.(false);\n    host?.addEventListener(\"focus\", onHostFocus);\n    host?.addEventListener(\"blur\", onHostBlur);\n\n    return () => {\n      window.clearTimeout(timer);\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", resume);\n      host?.removeEventListener(\"focus\", onHostFocus);\n      host?.removeEventListener(\"blur\", onHostBlur);\n      hoverRef.current = null;\n      clear();\n    };\n  }, [reduceMotion, intensity, trigger, duration, interval]);\n\n  const layer =\n    \"pointer-events-none absolute inset-0 -z-10 whitespace-pre-wrap opacity-0 select-none [clip-path:inset(50%_0_50%_0)]\";\n\n  return (\n    <Tag\n      ref={rootRef}\n      className={`relative isolate inline-block ${className}`}\n      style={{ [\"--gt-a\" as string]: colorA, [\"--gt-b\" as string]: colorB, ...style }}\n      onPointerEnter={(event: React.PointerEvent<HTMLElement>) => {\n        onPointerEnter?.(event);\n        hoverRef.current?.(true);\n      }}\n      onPointerLeave={(event: React.PointerEvent<HTMLElement>) => {\n        onPointerLeave?.(event);\n        hoverRef.current?.(false);\n      }}\n      onFocus={(event: React.FocusEvent<HTMLElement>) => {\n        onFocus?.(event);\n        hoverRef.current?.(true);\n      }}\n      onBlur={(event: React.FocusEvent<HTMLElement>) => {\n        onBlur?.(event);\n        hoverRef.current?.(false);\n      }}\n      {...props}\n    >\n      <span ref={mainRef} className=\"relative inline-block whitespace-pre-wrap\">\n        {text}\n      </span>\n      <span ref={aRef} aria-hidden=\"true\" className={`${layer} text-[var(--gt-a)]`}>\n        {text}\n      </span>\n      <span ref={bRef} aria-hidden=\"true\" className={`${layer} text-[var(--gt-b)]`}>\n        {text}\n      </span>\n    </Tag>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/glitch-text"
}
