{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"glitch-text-hover","type":"registry:component","title":"Glitch Text Hover","description":"A restrained RGB-split glitch: the word rests with a faint chromatic fringe, throws a micro-glitch every few seconds, and on hover, focus or tap shears into offset bands with separated red and cyan channels and a quick flicker.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/glitch-text-hover.tsx","content":"\"use client\";\n\n/*\n * GlitchText: a restrained RGB-split glitch for any word or line.\n *\n * Layers (all but the real text are aria-hidden copies): a red and a cyan\n * channel behind the text, and two horizontal \"tear\" slices on top. At rest the\n * channels sit a hair apart, so the word already carries a faint chromatic\n * fringe. Every few seconds it throws one short micro-glitch; on hover, focus\n * or tap it runs the full sequence: bands of the word shear sideways, the\n * channels separate, and a frame or two flicker. Reduced motion skips the\n * sequence and just opens the channels a little. Pure CSS keyframes, no deps.\n */\n\nimport { useEffect, useRef, type CSSProperties, type ReactNode } from \"react\";\n\n/** Per keyframe: band 1 [top, bottom] %, shift 1, band 2 [top, bottom] %, shift 2, red x, cyan x, red y (px), text opacity.\n *  Shifts are multiples of the glitch distance. A shift of 0 hides that band. */\nconst FRAMES: number[][] = [\n  [50, 50, 0, 50, 50, 0, -1.2, 1.2, 0, 1],\n  [14, 30, 2.6, 50, 50, 0, -2.2, 2.4, 0, 1],\n  [14, 30, -1.8, 62, 74, 3.2, -1.4, 1, 1, 1],\n  [50, 50, 0, 50, 50, 0, 2, -2, 0, 0.88],\n  [38, 47, 4, 70, 88, -2.4, -3, 3, -1, 1],\n  [50, 50, 0, 50, 50, 0, -0.8, 0.8, 0, 1],\n  [8, 22, -3.2, 50, 50, 0, -2, 2, 0, 1],\n  [50, 50, 0, 56, 66, 2, 1.2, -1.2, 1, 1],\n  [50, 50, 0, 50, 50, 0, 0, 0, 0, 0.55],\n  [26, 40, 1.6, 72, 80, -3, -2.6, 2.6, 0, 1],\n  [50, 50, 0, 50, 50, 0, -1, 1, 0, 1],\n  [50, 50, 0, 50, 50, 0, -1.2, 1.2, 0, 1],\n];\n\nconst D = \"calc(var(--gth-d) * var(--gth-k, 1) * \";\nconst pct = (i: number) => +((i / FRAMES.length) * 100).toFixed(3);\nconst KEEP = \"animation-timing-function:step-end;\";\nconst hole = (a1: number, b1: number, a2: number, b2: number) =>\n  `polygon(-40% -20%,140% -20%,140% ${a1}%,-40% ${a1}%,-40% ${b1}%,140% ${b1}%,140% ${a2}%,-40% ${a2}%,-40% ${b2}%,140% ${b2}%,140% 120%,-40% 120%)`;\nconst sliceFrames = (name: string, band: 0 | 1) =>\n  `@keyframes ${name}{` +\n  FRAMES.map((f, i) => {\n    const a = f[band * 3] ?? 50;\n    const b = f[band * 3 + 1] ?? 50;\n    const x = f[band * 3 + 2] ?? 0;\n    return `${pct(i)}%{${KEEP}opacity:${x === 0 ? 0 : 1};clip-path:inset(${a}% -40% ${100 - b}% -40%);transform:translateX(${D}${x}));}`;\n  }).join(\"\") +\n  \"}\";\nconst chanFrames = (name: string, col: 6 | 7) =>\n  `@keyframes ${name}{` +\n  FRAMES.map((f, i) => `${pct(i)}%{${KEEP}transform:translate(${D}${f[col] ?? 0}),${col === 6 ? f[8] ?? 0 : -(f[8] ?? 0)}px);}`).join(\"\") +\n  \"}\";\nconst baseFrames =\n  \"@keyframes gth-base{\" +\n  FRAMES.map(\n    (f, i) => `${pct(i)}%{${KEEP}opacity:${f[9] ?? 1};clip-path:${hole(f[0] ?? 50, f[1] ?? 50, f[3] ?? 50, f[4] ?? 50)};}`,\n  ).join(\"\") +\n  \"}\";\n\nconst PLAY = \"560ms linear infinite\";\nconst CSS = [\n  \".gth{position:relative;display:inline-block;isolation:isolate;--gth-d:max(var(--gth-i,3px),0.03em);--gth-rs:0.4;--gth-fx:360ms;border-radius:3px}\",\n  \".gth:focus-visible{outline:2px solid #3b82f6;outline-offset:6px;border-radius:4px}\",\n  \".gth-t{position:relative;z-index:1}\",\n  \".gth-l{position:absolute;inset:0;pointer-events:none;user-select:none;white-space:inherit}\",\n  \".gth-r,.gth-c{z-index:0;opacity:.9;will-change:transform}\",\n  \".gth-r{color:#ff2d55;transform:translate(calc(var(--gth-d) * var(--gth-rs) * -1),0)}\",\n  \".gth-c{color:#17d8ff;transform:translate(calc(var(--gth-d) * var(--gth-rs)),0)}\",\n  \".gth-s{z-index:2;opacity:0}\",\n  \".gth[data-tear] .gth-t{clip-path:\" + hole(60, 68, 100, 100) + \"}\",\n  \".gth[data-tear] .gth-s1{opacity:1;clip-path:inset(60% -40% 32% -40%);transform:translateX(calc(var(--gth-d) * 1.1))}\",\n  \".gth:hover .gth-t,.gth:focus-within .gth-t,.gth[data-on=true] .gth-t,.gth[data-fx] .gth-t{animation:gth-base \" + PLAY + \"}\",\n  \".gth:hover .gth-s1,.gth:focus-within .gth-s1,.gth[data-on=true] .gth-s1,.gth[data-fx] .gth-s1{animation:gth-s1 \" + PLAY + \"}\",\n  \".gth:hover .gth-s2,.gth:focus-within .gth-s2,.gth[data-on=true] .gth-s2,.gth[data-fx] .gth-s2{animation:gth-s2 \" + PLAY + \"}\",\n  \".gth:hover .gth-r,.gth:focus-within .gth-r,.gth[data-on=true] .gth-r,.gth[data-fx] .gth-r{animation:gth-r \" + PLAY + \"}\",\n  \".gth:hover .gth-c,.gth:focus-within .gth-c,.gth[data-on=true] .gth-c,.gth[data-fx] .gth-c{animation:gth-c \" + PLAY + \"}\",\n  \".gth[data-fx]:not(:hover):not(:focus-within):not([data-on=true]){--gth-k:.6}\",\n  \".gth[data-fx]:not(:hover):not(:focus-within):not([data-on=true]) .gth-l,.gth[data-fx]:not(:hover):not(:focus-within):not([data-on=true]) .gth-t{animation-duration:var(--gth-fx);animation-iteration-count:1}\",\n  baseFrames,\n  sliceFrames(\"gth-s1\", 0),\n  sliceFrames(\"gth-s2\", 1),\n  chanFrames(\"gth-r\", 6),\n  chanFrames(\"gth-c\", 7),\n  \"@media (prefers-reduced-motion:reduce){.gth-l,.gth-t{animation:none!important;transition:transform 200ms ease}.gth:hover .gth-r,.gth:focus-within .gth-r,.gth[data-on=true] .gth-r{transform:translate(calc(var(--gth-d) * -1.6),0)}.gth:hover .gth-c,.gth:focus-within .gth-c,.gth[data-on=true] .gth-c{transform:translate(calc(var(--gth-d) * 1.6),0)}.gth:hover .gth-s,.gth:focus-within .gth-s,.gth[data-on=true] .gth-s{opacity:0}}\",\n].join(\"\");\n\nconst IDLE_MIN = 2400;\nconst IDLE_SPAN = 2800;\nconst IDLE_MS = 360;\n\nexport function GlitchText({\n  text = \"GLITCH\",\n  intensity = 3,\n  idle = true,\n  split = 0.4,\n  tear = false,\n  active,\n  focusable = false,\n  className = \"\",\n}: {\n  text?: string;\n  /** Glitch distance in px (never less than 0.03em, so large type still reads as split). */\n  intensity?: number;\n  /** Throw a brief micro-glitch every few seconds while visible. */\n  idle?: boolean;\n  /** Resting channel separation as a fraction of the glitch distance (0 = none). */\n  split?: number;\n  /** Hold one thin sheared slice at rest. */\n  tear?: boolean;\n  /** Force the full glitch from outside (for example from a parent link). */\n  active?: boolean;\n  /** Make the word tab-focusable and glitch on keyboard focus. */\n  focusable?: boolean;\n  className?: string;\n}): ReactNode {\n  const ref = useRef<HTMLSpanElement>(null);\n  const px = Number.isFinite(intensity) ? Math.min(24, Math.max(0, intensity)) : 3;\n  const rs = Number.isFinite(split) ? Math.min(2, Math.max(0, split)) : 0.4;\n\n  // Idle micro-glitch: one short pass at random intervals, only while on screen and the tab is visible.\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || !idle) return;\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n    let visible = true;\n    let timer = 0;\n    let off = 0;\n    const io = new IntersectionObserver((es) => {\n      visible = es.some((e) => e.isIntersecting);\n    });\n    io.observe(el);\n    const fire = () => {\n      if (visible && !document.hidden) {\n        el.setAttribute(\"data-fx\", \"\");\n        off = window.setTimeout(() => el.removeAttribute(\"data-fx\"), IDLE_MS + 40);\n      }\n      timer = window.setTimeout(fire, IDLE_MIN + Math.random() * IDLE_SPAN);\n    };\n    timer = window.setTimeout(fire, 900);\n    return () => {\n      io.disconnect();\n      window.clearTimeout(timer);\n      window.clearTimeout(off);\n      el.removeAttribute(\"data-fx\");\n    };\n  }, [idle]);\n\n  // Touch has no hover: a tap throws the sequence once.\n  const tap = (e: { pointerType: string }) => {\n    const el = ref.current;\n    if (!el || e.pointerType === \"mouse\") return;\n    el.setAttribute(\"data-fx\", \"\");\n    window.setTimeout(() => el.removeAttribute(\"data-fx\"), 640);\n  };\n\n  return (\n    <span\n      ref={ref}\n      className={\"gth font-black tracking-tight \" + className}\n      data-on={active ? \"true\" : undefined}\n      data-tear={tear ? \"\" : undefined}\n      tabIndex={focusable ? 0 : undefined}\n      onPointerDown={tap}\n      style={{ \"--gth-i\": px + \"px\", \"--gth-rs\": rs } as CSSProperties}\n    >\n      <style>{CSS}</style>\n      <span aria-hidden=\"true\" className=\"gth-l gth-r\">\n        {text}\n      </span>\n      <span aria-hidden=\"true\" className=\"gth-l gth-c\">\n        {text}\n      </span>\n      <span className=\"gth-t\">{text}</span>\n      <span aria-hidden=\"true\" className=\"gth-l gth-s gth-s1\">\n        {text}\n      </span>\n      <span aria-hidden=\"true\" className=\"gth-l gth-s gth-s2\">\n        {text}\n      </span>\n    </span>\n  );\n}\n","type":"registry:component","target":"components/viberdy/glitch-text-hover.tsx"}],"meta":{"tier":"free","tags":["text","glitch","hover","rgb-split","chromatic-aberration","focus","idle","css-only"],"href":"https://viberdy.dev/library/glitch-text-hover","aiPrompt":"Create a React + Tailwind \"RGB-split glitch text\" component named GlitchText. One self-contained \"use client\" file, React only (useEffect, useRef), no animation library and no styled-jsx. A single static CSS string is rendered in a <style> tag inside the root span.\n\nProps (all optional): text (string, default \"GLITCH\"); intensity (glitch distance in px, default 3, clamped 0..24); idle (boolean, default true); split (resting channel separation as a fraction of the distance, default 0.4, clamped 0..2); tear (boolean, default false, holds one thin sheared slice at rest); active (boolean, forces the full effect); focusable (boolean, default false, tabIndex 0); className (appended to \"font-black tracking-tight\").\n\nStructure: root <span> class \"gth\": relative, inline-block, isolation isolate, custom properties --gth-i (the intensity in px) and --gth-rs (split) set inline, and --gth-d: max(var(--gth-i), 0.03em) so the effect scales with big type. Children in order: (1) aria-hidden red copy (#ff2d55) and (2) aria-hidden cyan copy (#17d8ff), both absolute inset-0, z-index 0, opacity .9, at rest translated by -d*split and +d*split horizontally; (3) the real text, relative, z-index 1; (4) and (5) two aria-hidden slice copies in currentColor, absolute inset-0, z-index 2, opacity 0, each clipped to a horizontal band with clip-path: inset(top% -40% bottom% -40%). All copies are pointer-events none and user-select none. Colour is inherited so it works on light and dark pages.\n\nSequence: twelve hard-stepped keyframes (animation-timing-function: step-end in every keyframe), 560ms linear infinite, played while the root is :hover, :focus-within, [data-on=true] or [data-fx]. Per frame: slice 1 band and slice 2 band (top and bottom percentages), each with a signed horizontal shift in multiples of d (for example band 14-30% shifted +2.6d, then -1.8d; band 62-74% +3.2d; band 38-47% +4d with 70-88% -2.4d; band 8-22% -3.2d; 26-40% +1.6d with 72-80% -3d), a shift of 0 meaning that slice is hidden (opacity 0); red channel x from -3d to +2d and cyan the mirror (swap sides on frame 4), with a 1px vertical offset on two frames; and the real text opacity, 1 except 0.88 on frame 4 and 0.55 on frame 9 (the flicker). While a band is displaced the real text is cut out of it with an animated clip-path polygon (two rectangular holes), so no doubled letters show. Tear at rest: one slice at 60-68% shifted +1.1d, with the real text cut out of the same band.\n\nIdle: in an effect, when idle and not prefers-reduced-motion, set a data-fx attribute on the root for 360ms at random intervals of 2.4 to 5.2s (first at 0.9s), only while an IntersectionObserver says it is visible and document.hidden is false; while data-fx is set and the user is not hovering, run the same keyframes once at 360ms and distance scale --gth-k 0.6. Clear timers, the observer and the attribute on unmount.\n\nTouch: pointerdown with a non-mouse pointer sets data-fx for 640ms (one full pass).\n\nAccessibility: only the real text is exposed, copies are aria-hidden. focusable roots get a visible focus ring: outline 2px solid #3b82f6, offset 6px. prefers-reduced-motion: no animations; hover, focus or active only eases the red and cyan channels to +-1.6d over 200ms and keeps slices hidden.","license":"https://viberdy.dev/license"}}