{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pixel-fill-button",
  "type": "registry:ui",
  "title": "Pixel Fill Button",
  "description": "An outlined mono button split by a dashed rule. Hover and teal pixels bloom out from the exact point the cursor came in, each flashing lime before it settles, so a lime edge rides the wave; leave and they drain out through the point you left by. The label rolls up and the arrow slides through as it fills.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/pixel-fill-button.tsx",
      "type": "registry:component",
      "target": "components/tweenui/pixel-fill-button.tsx",
      "content": "'use client';\n\nimport { useRef, useState, type ComponentPropsWithRef, type CSSProperties } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { CustomEase } from 'gsap/CustomEase';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, CustomEase);\n\nconst CELL = 8;\nconst SPREAD = 0.42;\nconst DRAIN = 0.28;\nconst SCATTER = 0.08;\nconst STEP = 0.09;\nconst EDGE = 0.07;\nconst TEAL = '#045f64';\nconst LIME = '#c6f56f';\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst isFocusVisible = (el: Element) => {\n  try {\n    return el.matches(':focus-visible');\n  } catch {\n    return true;\n  }\n};\n\nconst Arrow = () => (\n  <svg viewBox=\"0 0 20 20\" fill=\"none\" aria-hidden=\"true\" className=\"size-5\">\n    <path\n      d=\"M11.875 14.375 16.25 10l-4.375-4.375M16.25 10H2.5\"\n      stroke=\"currentColor\"\n      strokeWidth={1.5}\n    />\n  </svg>\n);\n\nexport interface PixelFillButtonProps extends ComponentPropsWithRef<'button'> {\n  children: string;\n}\n\nconst PixelFillButton = ({\n  children,\n  className,\n  type = 'button',\n  ref,\n  ...props\n}: PixelFillButtonProps) => {\n  const btnRef = useRef<HTMLButtonElement>(null);\n  const [grid, setGrid] = useState({ cols: 0, rows: 0 });\n\n  const setRef = (node: HTMLButtonElement | null) => {\n    btnRef.current = node;\n    if (typeof ref === 'function') ref(node);\n    else if (ref) ref.current = node;\n  };\n\n  useGSAP(\n    () => {\n      const btn = btnRef.current;\n      if (!btn) return;\n\n      const measure = () => {\n        const cols = Math.ceil(btn.offsetWidth / CELL);\n        const rows = Math.ceil(btn.offsetHeight / CELL);\n        setGrid((prev) => (prev.cols === cols && prev.rows === rows ? prev : { cols, rows }));\n      };\n      measure();\n      const observer = new ResizeObserver(measure);\n      observer.observe(btn);\n      return () => observer.disconnect();\n    },\n    { scope: btnRef }\n  );\n\n  useGSAP(\n    () => {\n      CustomEase.create('pixel-roll', '0.625, 0.05, 0, 1');\n      const btn = btnRef.current;\n      if (!btn) return;\n      const cells = gsap.utils.toArray<HTMLElement>('[data-pixel]', btn);\n      const rolls = gsap.utils.toArray<HTMLElement>('[data-roll]', btn);\n      const slides = gsap.utils.toArray<HTMLElement>('[data-slide]', btn);\n      const label = btn.querySelector<HTMLElement>('[data-label]');\n      const roll = { duration: 0.48, ease: 'pixel-roll', overwrite: true };\n      const pending: (gsap.core.Tween | null)[] = cells.map(() => null);\n\n      // The wave is timed by each cell's distance from a point: where the\n      // pointer came in when filling, where it went out when draining. Filling\n      // runs nearest first, so the teal blooms out of the cursor; draining runs\n      // farthest first, so the last pixels leave through the exit. Every cell\n      // lights lime for a beat before it settles, which gives the wave its edge.\n      const wave = (x: number, y: number, filling: boolean) => {\n        const width = btn.offsetWidth;\n        const height = btn.offsetHeight;\n        const far = Math.max(Math.hypot(Math.max(x, width - x), Math.max(y, height - y)), 1);\n        const reachOf = (px: number, py: number) => Math.hypot(px - x, py - y) / far;\n        const order = (reach: number) => (filling ? reach : 1 - reach);\n        const reaches = cells.map((_, index) =>\n          reachOf(((index % grid.cols) + 0.5) * CELL, (Math.floor(index / grid.cols) + 0.5) * CELL)\n        );\n        const lits = cells.map((cell) => Number(gsap.getProperty(cell, 'opacity')) > 0);\n\n        // The drain is timed across the lit cells only, starting from the first\n        // of them, so it begins the moment the pointer leaves. A quick pass\n        // lights a few cells right by the exit; timed across the whole button\n        // they'd sit out most of the sweep before clearing.\n        const start = filling ? 0 : Math.min(1, ...reaches.filter((_, i) => lits[i]).map(order));\n        const timing = (reach: number) =>\n          Math.max(0, order(reach) - start) * (filling ? SPREAD : DRAIN);\n        const hold = filling ? EDGE : EDGE / 2;\n\n        cells.forEach((cell, index) => {\n          const delay = timing(reaches[index]) + Math.random() * SCATTER;\n          const lit = lits[index];\n          // Overwriting doesn't stop a keyframe tween, so each cell's last\n          // tween is killed by hand. Without it, a fast exit leaves fills still\n          // waiting on their delay, and they switch on after the pointer is gone.\n          pending[index]?.kill();\n          pending[index] = null;\n          if (filling && lit) {\n            pending[index] = gsap.to(cell, {\n              opacity: 1,\n              backgroundColor: TEAL,\n              duration: STEP,\n              ease: 'steps(2)',\n              delay,\n            });\n          } else if (filling) {\n            pending[index] = gsap.to(cell, {\n              keyframes: [\n                { backgroundColor: LIME, duration: 0 },\n                { opacity: 1, duration: STEP, ease: 'steps(2)' },\n                { backgroundColor: TEAL, duration: 0, delay: EDGE },\n              ],\n              delay,\n            });\n          } else if (lit) {\n            pending[index] = gsap.to(cell, {\n              keyframes: [\n                { backgroundColor: LIME, duration: 0 },\n                { opacity: 0, duration: STEP, ease: 'steps(2)', delay: hold },\n              ],\n              delay,\n            });\n          } else {\n            gsap.set(cell, { opacity: 0, backgroundColor: TEAL });\n          }\n        });\n\n        // The label flips colour once the wave is over its middle, so the\n        // text never reads lime on white or teal on teal for long.\n        const middle = label ? reachOf(label.offsetLeft + label.offsetWidth / 2, height / 2) : 0.5;\n        gsap.to(btn, {\n          color: filling ? LIME : TEAL,\n          duration: 0.16,\n          delay: timing(middle) + STEP,\n          overwrite: true,\n        });\n      };\n\n      const enter = (x: number, y: number) => {\n        btn.dataset.active = 'true';\n        if (prefersReducedMotion()) return;\n        wave(x, y, true);\n        gsap.to(rolls, { yPercent: -100, ...roll });\n        gsap.to(slides, { xPercent: 100, ...roll });\n      };\n\n      const leave = (x: number, y: number) => {\n        btn.dataset.active = 'false';\n        if (prefersReducedMotion()) return;\n        wave(x, y, false);\n        gsap.to(rolls, { yPercent: 0, ...roll });\n        gsap.to(slides, { xPercent: 0, ...roll });\n      };\n\n      const pointOf = (event: PointerEvent) => {\n        const box = btn.getBoundingClientRect();\n        return [event.clientX - box.left, event.clientY - box.top] as const;\n      };\n\n      const onPointerEnter = (event: PointerEvent) => {\n        if (event.pointerType !== 'touch') enter(...pointOf(event));\n      };\n      const onPointerLeave = (event: PointerEvent) => {\n        if (event.pointerType !== 'touch' && document.activeElement !== btn) {\n          leave(...pointOf(event));\n        }\n      };\n      const onFocus = () => {\n        if (isFocusVisible(btn)) enter(0, btn.offsetHeight / 2);\n      };\n      const onBlur = () => {\n        if (!btn.matches(':hover')) leave(0, btn.offsetHeight / 2);\n      };\n\n      if (cells.length) gsap.set(cells, { opacity: 0 });\n      if (btn.dataset.active === 'true' && !prefersReducedMotion()) {\n        gsap.set(cells, { opacity: 1, backgroundColor: TEAL });\n        gsap.set(btn, { color: LIME });\n        gsap.set(rolls, { yPercent: -100 });\n        gsap.set(slides, { xPercent: 100 });\n      }\n      btn.addEventListener('pointerenter', onPointerEnter);\n      btn.addEventListener('pointerleave', onPointerLeave);\n      btn.addEventListener('focus', onFocus);\n      btn.addEventListener('blur', onBlur);\n      return () => {\n        btn.removeEventListener('pointerenter', onPointerEnter);\n        btn.removeEventListener('pointerleave', onPointerLeave);\n        btn.removeEventListener('focus', onFocus);\n        btn.removeEventListener('blur', onBlur);\n      };\n    },\n    { scope: btnRef, dependencies: [grid.cols, grid.rows], revertOnUpdate: true }\n  );\n\n  return (\n    <button\n      ref={setRef}\n      type={type}\n      data-active=\"false\"\n      className={cn(\n        'group relative inline-flex cursor-pointer items-stretch overflow-hidden border border-[#045f64]/25 bg-white p-0 font-mono text-sm tracking-[0.04em] text-[#045f64] uppercase transition-[border-color,scale] duration-[280ms] ease-[cubic-bezier(0.32,0.72,0,1)] outline-none select-none hover:border-[#045f64] focus-visible:border-[#045f64] focus-visible:ring-2 focus-visible:ring-[#045f64] focus-visible:ring-offset-2 not-disabled:active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none motion-reduce:active:scale-100 motion-reduce:data-[active=true]:bg-[#045f64] motion-reduce:data-[active=true]:text-[#c6f56f]',\n        className\n      )}\n      {...props}\n    >\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute top-0 left-0 grid motion-reduce:hidden\"\n        style={\n          {\n            gridTemplateColumns: `repeat(${grid.cols}, ${CELL}px)`,\n            gridAutoRows: `${CELL}px`,\n          } as CSSProperties\n        }\n      >\n        {Array.from({ length: grid.cols * grid.rows }, (_, index) => (\n          <span key={index} data-pixel className=\"size-[9px] bg-[#045f64] opacity-0\" />\n        ))}\n      </span>\n      <span data-label className=\"relative flex items-center px-6 py-4\">\n        <span className=\"relative block overflow-hidden leading-5\">\n          <span data-roll className=\"block\">\n            {children}\n          </span>\n          <span data-roll aria-hidden=\"true\" className=\"absolute top-full left-0 block\">\n            {children}\n          </span>\n        </span>\n      </span>\n      <span\n        aria-hidden=\"true\"\n        className=\"relative my-2 w-px bg-[repeating-linear-gradient(to_bottom,currentColor_0_4px,transparent_4px_8px)] opacity-35\"\n      />\n      <span aria-hidden=\"true\" className=\"relative flex overflow-hidden\">\n        <span data-slide className=\"flex items-center px-4\">\n          <Arrow />\n        </span>\n        <span data-slide className=\"absolute inset-y-0 right-full flex items-center px-4\">\n          <Arrow />\n        </span>\n      </span>\n    </button>\n  );\n};\n\nexport default PixelFillButton;\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/component/pixel-fill-button\nGSAP plugins (CustomEase) are registered inside the component, so there is nothing to set up."
}