{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pixel-hover-reveal","type":"registry:component","title":"Pixel Hover Reveal","description":"Hover a name and its picture breaks in, pixel by pixel.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/pixel-hover-reveal/pixel-hover-reveal.tsx","type":"registry:component","target":"components/dimaac/pixel-hover-reveal.tsx","content":"\"use client\";\n\nimport { useRef, useState, type FocusEvent, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from \"react\";\nimport { gsap } from \"gsap\";\nimport { useGSAP } from \"@gsap/react\";\n\n// A list of names. Hover one and its picture breaks in cell by cell, like pixels, on a small\n// canvas that follows the cursor. The picture is drawn once into a hidden \"frame\" canvas, cut\n// into a grid of square cells, and every cell fades in on its own, a moment after the last one.\n// Keyboard focus and taps on touch screens open the picture next to the name instead.\n\nexport type PixelHoverRevealItem = {\n  /** The name shown in the list */\n  label: string;\n  /** The picture that breaks in when the name is hovered or focused */\n  src: string;\n  /** Describes the picture for screen readers */\n  alt?: string;\n  /** A short line on the right of the row, e.g. \"Portrait · 2026\" */\n  meta?: string;\n  /** Makes the row a link instead of a button */\n  href?: string;\n};\n\nexport type PixelHoverRevealProps = {\n  /** The rows, top first. Three to six read best. */\n  items: PixelHoverRevealItem[];\n  /** How many cells across the picture. Fewer means bigger, chunkier pixels. */\n  cellsPerRow?: number;\n  /** How long the whole picture takes to break in, in seconds. */\n  duration?: number;\n  /** How many cells fade at once. 0 pops each cell in, 4 is a soft dissolve. */\n  smoothness?: number;\n  /** The order the cells appear in: scattered, line by line, or growing out from the middle. */\n  order?: \"random\" | \"row\" | \"center\";\n  /** Width of the picture, in pixels. It shrinks on small screens. */\n  previewWidth?: number;\n  /** Height of the picture, in pixels. */\n  previewHeight?: number;\n  /** How long the picture takes to catch up with the cursor, in seconds. */\n  follow?: number;\n  /** When the rows open by themselves: on touch screens until the first tap, always (until the pointer arrives) or never. */\n  autoplay?: \"touch\" | \"always\" | \"never\";\n  /** Extra classes for the outer box. It sizes to its rows. */\n  className?: string;\n  /** Extra classes for each row. */\n  rowClassName?: string;\n  /** Extra classes for each name, e.g. its font size. */\n  labelClassName?: string;\n};\n\ntype Cell = { x: number; y: number; width: number; height: number; alpha: number };\n\n// The handlers the rows call. They are made inside the GSAP context, so their tweens are cleaned up with it.\ntype Handlers = {\n  enter: (index: number, event: ReactPointerEvent) => void;\n  leave: (index: number) => void;\n  focus: (index: number, row: HTMLElement) => void;\n  blur: (index: number) => void;\n  tap: (index: number) => void;\n  close: () => void;\n};\n\nconst GAP = 16;\n// How far an open name slides right (the label's motion-safe:translate-x-6), so the picture clears it\nconst SHIFT = 24;\n\nexport function PixelHoverReveal({\n  items,\n  cellsPerRow = 12,\n  duration = 0.8,\n  smoothness = 1,\n  order = \"random\",\n  previewWidth = 360,\n  previewHeight = 540,\n  follow = 0.5,\n  autoplay = \"touch\",\n  className = \"\",\n  rowClassName = \"\",\n  labelClassName = \"text-[clamp(40px,9vw,132px)]\",\n}: PixelHoverRevealProps) {\n  const boxRef = useRef<HTMLDivElement>(null);\n  const previewRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const labelRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const handlers = useRef<Handlers | null>(null);\n  // The type of the last pointer that pressed a row, so a tap can be told from a mouse click\n  const pressedWith = useRef(\"mouse\");\n  // Kept across rebuilds (a prop change rebuilds everything), so the open row plays again with the new values\n  const open = useRef({ index: -1, mode: \"pointer\" as \"pointer\" | \"anchor\", x: 0, y: 0 });\n  const [active, setActive] = useState(-1);\n  // A change of pictures rebuilds, a new array with the same pictures doesn't\n  const sources = items.map((item) => item.src).join(\"\\n\");\n\n  useGSAP(\n    (_context, contextSafe) => {\n      const box = boxRef.current;\n      const preview = previewRef.current;\n      const canvas = canvasRef.current;\n      const context = canvas?.getContext(\"2d\");\n      if (!box || !preview || !canvas || !context || !contextSafe) return;\n\n      const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n      const touchScreen = window.matchMedia(\"(hover: none)\").matches;\n\n      // The picture, drawn to fit (object-fit: cover) into a hidden canvas the cells copy from\n      const frame = document.createElement(\"canvas\");\n      const frameContext = frame.getContext(\"2d\");\n      if (!frameContext) return;\n\n      let cells: Cell[] = [];\n      let width = 0;\n      let height = 0;\n      let current = -1;\n      let hovered = -1;\n      let pointer = { x: 0, y: 0 };\n      // The cursor in window coordinates, so the picture can find it again after the box moves (resize, scroll)\n      let client: { clientX: number; clientY: number } | null = null;\n      let laidOutFor = \"\";\n\n      // Load every picture up front so the first hover doesn't wait on the network\n      const images = items.map((item, index) => {\n        const image = new Image();\n        image.decoding = \"async\";\n        image.onload = () => {\n          if (index !== current) return;\n          paintFrame();\n          draw();\n        };\n        image.src = item.src;\n        return image;\n      });\n\n      const moveX = gsap.quickTo(preview, \"x\", { duration: follow, ease: \"power3\" });\n      const moveY = gsap.quickTo(preview, \"y\", { duration: follow, ease: \"power3\" });\n\n      function draw() {\n        context!.clearRect(0, 0, canvas!.width, canvas!.height);\n        for (const cell of cells) {\n          if (cell.alpha <= 0) continue;\n          context!.globalAlpha = cell.alpha;\n          context!.drawImage(frame, cell.x, cell.y, cell.width, cell.height, cell.x, cell.y, cell.width, cell.height);\n        }\n        context!.globalAlpha = 1;\n      }\n\n      function paintFrame() {\n        frameContext!.clearRect(0, 0, frame.width, frame.height);\n        const image = images[current];\n        if (!image || !image.complete || image.naturalWidth === 0) return;\n        const scale = Math.max(frame.width / image.naturalWidth, frame.height / image.naturalHeight);\n        const drawWidth = image.naturalWidth * scale;\n        const drawHeight = image.naturalHeight * scale;\n        frameContext!.drawImage(image, (frame.width - drawWidth) / 2, (frame.height - drawHeight) / 2, drawWidth, drawHeight);\n      }\n\n      // Size the picture (smaller on phones and short screens) and cut it into square cells. Cell\n      // edges are rounded to whole pixels, so no hairline gaps show between them.\n      function layout() {\n        laidOutFor = `${box!.clientWidth}x${window.innerHeight}`;\n        const ratio = previewHeight / previewWidth;\n        width = Math.round(Math.min(previewWidth, box!.clientWidth * 0.46, (window.innerHeight * 0.66) / ratio));\n        height = Math.round(width * ratio);\n        preview!.style.width = `${width}px`;\n        preview!.style.height = `${height}px`;\n        const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);\n        canvas!.width = frame.width = Math.max(1, Math.round(width * pixelRatio));\n        canvas!.height = frame.height = Math.max(1, Math.round(height * pixelRatio));\n\n        gsap.killTweensOf(cells);\n        const size = canvas!.width / cellsPerRow;\n        const rows = Math.ceil(canvas!.height / size);\n        cells = [];\n        for (let row = 0; row < rows; row++) {\n          for (let column = 0; column < cellsPerRow; column++) {\n            const x = Math.round(column * size);\n            const y = Math.round(row * size);\n            cells.push({ x, y, width: Math.round((column + 1) * size) - x, height: Math.round((row + 1) * size) - y, alpha: current >= 0 ? 1 : 0 });\n          }\n        }\n        paintFrame();\n        draw();\n        if (current >= 0) replace();\n      }\n\n      // The rows reflowed or the box moved under a still cursor: put the open picture back where it belongs\n      function replace() {\n        if (current < 0) return;\n        if (open.current.mode === \"pointer\" && !reduced) {\n          if (!client) return;\n          pointer = pointerFrom(client);\n          place(pointer, false);\n        } else {\n          place(anchorFor(current), false);\n        }\n      }\n\n      // How long each cell waits, in \"steps\": the whole reveal is the last cell's wait plus one fade\n      function waits() {\n        const count = cells.length;\n        if (order === \"row\") return { waits: cells.map((_, index) => index), last: count - 1 };\n        if (order === \"center\") {\n          const rows = Math.ceil(count / cellsPerRow);\n          const middleX = (cellsPerRow - 1) / 2;\n          const middleY = (rows - 1) / 2;\n          const distances = cells.map((_, index) => Math.hypot((index % cellsPerRow) - middleX, Math.floor(index / cellsPerRow) - middleY));\n          return { waits: distances, last: Math.max(1, ...distances) };\n        }\n        const shuffled = gsap.utils.shuffle(cells.map((_, index) => index));\n        const waits: number[] = [];\n        shuffled.forEach((cell, position) => (waits[cell] = position));\n        return { waits, last: count - 1 };\n      }\n\n      function reveal() {\n        paintFrame();\n        if (reduced) {\n          gsap.killTweensOf(cells);\n          cells.forEach((cell) => (cell.alpha = 1));\n          draw();\n          return;\n        }\n        // Spread the reveal so the last cell finishes right at `duration`\n        const { waits: wait, last } = waits();\n        const step = duration / (last + smoothness);\n        // A zero-length tween would jump straight to its end, so \"no fade\" is a fade too short to see\n        const fade = Math.max(smoothness * step, 0.001);\n        gsap.fromTo(\n          cells,\n          { alpha: 0 },\n          { alpha: 1, duration: fade, ease: \"none\", stagger: (index: number) => wait[index]! * step, overwrite: true, onUpdate: draw, onComplete: draw },\n        );\n      }\n\n      // A quick scatter out, so closing feels like the same effect run backwards\n      function conceal() {\n        if (reduced) {\n          gsap.killTweensOf(cells);\n          cells.forEach((cell) => (cell.alpha = 0));\n          draw();\n          return;\n        }\n        gsap.to(cells, { alpha: 0, duration: 0.1, ease: \"none\", stagger: { amount: 0.18, from: \"random\" }, overwrite: true, onUpdate: draw, onComplete: draw });\n      }\n\n      // Where the picture sits when it isn't following the cursor: just after the name, inside the box.\n      // When the name is too long for the picture to fit beside it (phones), the picture moves just\n      // above or below the row instead, at the right edge, so the name it belongs to stays readable.\n      function anchorFor(index: number) {\n        const label = labelRefs.current[index];\n        const row = label?.closest(\"li\");\n        const boxRect = box!.getBoundingClientRect();\n        const rect = label?.getBoundingClientRect() ?? boxRect;\n        const rowRect = row?.getBoundingClientRect() ?? rect;\n        const maxX = Math.max(width / 2 + GAP, box!.clientWidth - width / 2 - GAP);\n        // The name's right edge once it has slid over: its resting edge (the slide taken out, it may be\n        // half way) plus the full slide\n        const sliding = label ? parseFloat(getComputedStyle(label).translate) || 0 : 0;\n        const right = rect.right - sliding + (reduced ? 0 : SHIFT);\n        const besideX = right - boxRect.left + GAP * 2 + width / 2;\n        if (besideX <= maxX) {\n          return {\n            x: Math.max(width / 2 + GAP, besideX),\n            y: gsap.utils.clamp(height / 2 + GAP, Math.max(height / 2 + GAP, box!.clientHeight - height / 2 - GAP), rect.top - boxRect.top + rect.height / 2),\n          };\n        }\n        const top = rowRect.top - boxRect.top;\n        const bottom = rowRect.bottom - boxRect.top;\n        // Rows in the top half show the picture below them, rows in the bottom half above\n        const below = top + (bottom - top) / 2 < box!.clientHeight / 2;\n        return {\n          x: Math.max(width / 2, box!.clientWidth - width / 2),\n          y: below ? bottom + GAP / 2 + height / 2 : top - GAP / 2 - height / 2,\n        };\n      }\n\n      // Moves the picture's middle to a point in the box: a glide if it's already showing, a jump if not\n      function place(point: { x: number; y: number }, glide: boolean) {\n        open.current.x = point.x;\n        open.current.y = point.y;\n        // Kept inside the box sideways, so a hover at the end of a full-width row never adds a\n        // sideways scrollbar to the page (up and down it may pass the box, like the original)\n        const x = gsap.utils.clamp(0, Math.max(0, box!.clientWidth - width), point.x - width / 2);\n        const y = point.y - height / 2;\n        if (glide && !reduced) {\n          moveX(x);\n          moveY(y);\n        } else {\n          // Start and end at the same spot: a jump that also stops any glide still running\n          moveX(x, x);\n          moveY(y, y);\n        }\n      }\n\n      function show(index: number, mode: \"pointer\" | \"anchor\") {\n        const wasOpen = current >= 0;\n        const sameRow = index === current;\n        current = index;\n        open.current.index = index;\n        open.current.mode = mode;\n        setActive(index);\n        // Reduced motion: no chasing the cursor, the picture sits beside the name\n        const point = mode === \"pointer\" && !reduced ? pointer : anchorFor(index);\n        place(point, wasOpen);\n        if (!sameRow || !wasOpen) reveal();\n      }\n\n      function hide() {\n        if (current < 0) return;\n        current = -1;\n        open.current.index = -1;\n        setActive(-1);\n        conceal();\n      }\n\n      const pointerFrom = (event: { clientX: number; clientY: number }) => {\n        const rect = box.getBoundingClientRect();\n        return { x: event.clientX - rect.left, y: event.clientY - rect.top };\n      };\n\n      // Rows open by themselves on touch screens (or always) until someone uses the list\n      let autoplayCall: gsap.core.Tween | null = null;\n      let autoplayIndex = -1;\n      const autoplays = !reduced && items.length > 0 && (autoplay === \"always\" || (autoplay === \"touch\" && touchScreen));\n      const next = contextSafe(() => {\n        autoplayIndex = (autoplayIndex + 1) % items.length;\n        show(autoplayIndex, \"anchor\");\n        autoplayCall = gsap.delayedCall(Math.max(2.2, duration + 1.4), next);\n      });\n      const stopAutoplay = () => {\n        autoplayCall?.kill();\n        autoplayCall = null;\n      };\n\n      handlers.current = {\n        enter: contextSafe((index: number, event: ReactPointerEvent) => {\n          if (event.pointerType === \"touch\") return;\n          stopAutoplay();\n          hovered = index;\n          client = { clientX: event.clientX, clientY: event.clientY };\n          pointer = pointerFrom(client);\n          show(index, \"pointer\");\n        }),\n        leave: contextSafe((index: number) => {\n          if (hovered === index) hovered = -1;\n          if (current === index && open.current.mode === \"pointer\") hide();\n        }),\n        focus: contextSafe((index: number, row: HTMLElement) => {\n          // A mouse click also focuses the row; only keyboard focus moves the picture beside the name\n          if (!row.matches(\":focus-visible\")) return;\n          stopAutoplay();\n          show(index, \"anchor\");\n        }),\n        blur: contextSafe((index: number) => {\n          if (current === index && hovered !== index) hide();\n        }),\n        tap: contextSafe((index: number) => {\n          stopAutoplay();\n          if (current === index) hide();\n          else show(index, \"anchor\");\n        }),\n        close: contextSafe(() => {\n          stopAutoplay();\n          hide();\n        }),\n      };\n\n      const onPointerMove = contextSafe((event: PointerEvent) => {\n        if (event.pointerType === \"touch\") return;\n        client = { clientX: event.clientX, clientY: event.clientY };\n        pointer = pointerFrom(client);\n        if (current >= 0 && open.current.mode === \"pointer\" && !reduced) place(pointer, true);\n      });\n      // A tap anywhere that isn't a row closes the picture\n      const onPointerDown = contextSafe((event: PointerEvent) => {\n        if (event.pointerType !== \"touch\") return;\n        if (!(event.target as Element).closest(\"[data-pixel-row]\")) handlers.current?.close();\n      });\n      box.addEventListener(\"pointermove\", onPointerMove);\n      box.addEventListener(\"pointerdown\", onPointerDown);\n\n      // The observer also fires once on start; only a real change of size cuts the cells again,\n      // or it would cut short the reveal that's playing\n      const onResize = contextSafe(() => {\n        const size = `${box.clientWidth}x${window.innerHeight}`;\n        if (size !== laidOutFor) layout();\n        // Same size but the rows may still have moved (a font loaded, the page above changed)\n        else replace();\n      });\n      const resizeObserver = new ResizeObserver(onResize);\n      layout();\n      resizeObserver.observe(box);\n      // The size cap follows the window's height, which the box alone doesn't report\n      window.addEventListener(\"resize\", onResize);\n      // The picture is placed in the box, so a scroll under a still cursor would leave it behind\n      // (a picture beside a name moves with the box already, so only the cursor's picture needs this)\n      const onScroll = contextSafe(() => {\n        if (open.current.mode === \"pointer\") replace();\n      });\n      window.addEventListener(\"scroll\", onScroll, { capture: true, passive: true });\n\n      // After a rebuild (a prop changed), play the open row again with the new values\n      const reopen = open.current;\n      if (reopen.index >= 0 && reopen.index < items.length) {\n        current = -1;\n        pointer = { x: reopen.x, y: reopen.y };\n        show(reopen.index, reopen.mode);\n      } else if (autoplays) {\n        autoplayCall = gsap.delayedCall(0.8, next);\n      }\n\n      return () => {\n        stopAutoplay();\n        handlers.current = null;\n        resizeObserver.disconnect();\n        window.removeEventListener(\"resize\", onResize);\n        window.removeEventListener(\"scroll\", onScroll, { capture: true });\n        box.removeEventListener(\"pointermove\", onPointerMove);\n        box.removeEventListener(\"pointerdown\", onPointerDown);\n        gsap.killTweensOf(cells);\n        for (const image of images) {\n          image.onload = null;\n          image.src = \"\";\n        }\n      };\n    },\n    { scope: boxRef, dependencies: [sources, cellsPerRow, duration, smoothness, order, previewWidth, previewHeight, follow, autoplay], revertOnUpdate: true },\n  );\n\n  const shown = active >= 0 ? items[active] : undefined;\n\n  return (\n    // No overflow: hidden, so the picture can follow the cursor past the first and last rows\n    <div ref={boxRef} className={`relative w-full ${className}`}>\n      <ul className=\"m-0 flex list-none flex-col border-0 border-t border-solid border-current/15 p-0\">\n        {items.map((item, index) => {\n          const isActive = index === active;\n          const rowClasses = `group flex w-full cursor-pointer items-baseline justify-between gap-6 border-0 border-b border-solid border-current/15 bg-transparent px-0 py-2 text-left md:py-3 text-inherit no-underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current focus-visible:outline-solid ${rowClassName}`;\n          const inside = (\n            <>\n              <span\n                ref={(label) => {\n                  labelRefs.current[index] = label;\n                }}\n                className={`inline-block pt-[0.04em] pb-[0.1em] leading-[0.9] font-extrabold tracking-[-0.04em] transition-[opacity,translate] duration-[350ms] ease-out ${isActive ? \"opacity-35 motion-safe:translate-x-6\" : \"\"} ${labelClassName}`}\n              >\n                {item.label}\n              </span>\n              {item.meta ? (\n                <span className={`shrink-0 text-[11px] tracking-[0.08em] uppercase transition-opacity duration-[350ms] ${isActive ? \"opacity-100\" : \"opacity-55\"}`}>{item.meta}</span>\n              ) : null}\n            </>\n          );\n          const shared = {\n            \"data-pixel-row\": \"\",\n            className: rowClasses,\n            onPointerEnter: (event: ReactPointerEvent) => handlers.current?.enter(index, event),\n            onPointerLeave: () => handlers.current?.leave(index),\n            onPointerDown: (event: ReactPointerEvent) => {\n              pressedWith.current = event.pointerType;\n            },\n            onFocus: (event: FocusEvent<HTMLElement>) => handlers.current?.focus(index, event.currentTarget),\n            onBlur: () => handlers.current?.blur(index),\n            onKeyDown: (event: KeyboardEvent) => {\n              pressedWith.current = \"keyboard\";\n              if (event.key === \"Escape\") handlers.current?.close();\n            },\n          };\n          return (\n            <li key={`${index}-${item.label}`}>\n              {item.href ? (\n                <a href={item.href} {...shared}>\n                  {inside}\n                </a>\n              ) : (\n                <button\n                  type=\"button\"\n                  aria-pressed={isActive}\n                  {...shared}\n                  // A tap or Enter / Space (a click with no detail) toggles the picture, so the toggle button\n                  // really toggles; a mouse click leaves it to hover, which has already opened it\n                  onClick={(event) => {\n                    if (pressedWith.current === \"touch\" || event.detail === 0) handlers.current?.tap(index);\n                  }}\n                >\n                  {inside}\n                </button>\n              )}\n            </li>\n          );\n        })}\n      </ul>\n\n      <div\n        ref={previewRef}\n        role={shown?.alt ? \"img\" : undefined}\n        aria-label={shown?.alt || undefined}\n        aria-hidden={shown?.alt ? undefined : true}\n        className=\"pointer-events-none absolute top-0 left-0 z-10\"\n        style={{ willChange: \"transform\" }}\n      >\n        <canvas ref={canvasRef} aria-hidden=\"true\" className=\"block h-full w-full\" />\n      </div>\n    </div>\n  );\n}\n"}],"categories":["cursor","canvas","gsap","hover","list","image","pixel"],"docs":"import { PixelHoverReveal } from \"@/components/dimaac/pixel-hover-reveal\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/pixel-hover-reveal","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/pixel-hover-reveal","credits":[{"by":"Nika","url":"https://www.lummi.ai/photo/serene-gradient-landscape-80b2l","what":"Gradient sky","source":"Lummi","licence":"Lummi License"},{"by":"rena","url":"https://www.lummi.ai/photo/modern-architectural-design-aozka","what":"Architecture","source":"Lummi","licence":"Lummi License"},{"by":"SHIHO","url":"https://www.lummi.ai/photo/vibrant-cylindrical-arrangement-pj9pp","what":"Cylinders","source":"Lummi","licence":"Lummi License"},{"by":"Shaun Duval","url":"https://www.lummi.ai/photo/portrait-with-neon-sunglasses-gindg","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Cayetano Gros","url":"https://www.lummi.ai/photo/dramatic-coastline-aerial-view-jml-7","what":"Coastline","source":"Lummi","licence":"Lummi License"}]}}