← Components

Pixel Hover Reveal

Hover a name and its picture breaks in, pixel by pixel.

Cursor · FreeInstall ↓
Open preview ↗
Loading the demoNew tab ↗

Customize

12
0.80 s
1.0
360 px
540 px
0.50 s
Your code
<PixelHoverReveal items={items} />

Changes show live and stay in the address, so a link keeps them.

Code

"use client";

import { useRef, useState, type FocusEvent, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// A list of names. Hover one and its picture breaks in cell by cell, like pixels, on a small
// canvas that follows the cursor. The picture is drawn once into a hidden "frame" canvas, cut
// into a grid of square cells, and every cell fades in on its own, a moment after the last one.
// Keyboard focus and taps on touch screens open the picture next to the name instead.

export type PixelHoverRevealItem = {
  /** The name shown in the list */
  label: string;
  /** The picture that breaks in when the name is hovered or focused */
  src: string;
  /** Describes the picture for screen readers */
  alt?: string;
  /** A short line on the right of the row, e.g. "Portrait · 2026" */
  meta?: string;
  /** Makes the row a link instead of a button */
  href?: string;
};

export type PixelHoverRevealProps = {
  /** The rows, top first. Three to six read best. */
  items: PixelHoverRevealItem[];
  /** How many cells across the picture. Fewer means bigger, chunkier pixels. */
  cellsPerRow?: number;
  /** How long the whole picture takes to break in, in seconds. */
  duration?: number;
  /** How many cells fade at once. 0 pops each cell in, 4 is a soft dissolve. */
  smoothness?: number;
  /** The order the cells appear in: scattered, line by line, or growing out from the middle. */
  order?: "random" | "row" | "center";
  /** Width of the picture, in pixels. It shrinks on small screens. */
  previewWidth?: number;
  /** Height of the picture, in pixels. */
  previewHeight?: number;
  /** How long the picture takes to catch up with the cursor, in seconds. */
  follow?: number;
  /** When the rows open by themselves: on touch screens until the first tap, always (until the pointer arrives) or never. */
  autoplay?: "touch" | "always" | "never";
  /** Extra classes for the outer box. It sizes to its rows. */
  className?: string;
  /** Extra classes for each row. */
  rowClassName?: string;
  /** Extra classes for each name, e.g. its font size. */
  labelClassName?: string;
};

type Cell = { x: number; y: number; width: number; height: number; alpha: number };

// The handlers the rows call. They are made inside the GSAP context, so their tweens are cleaned up with it.
type Handlers = {
  enter: (index: number, event: ReactPointerEvent) => void;
  leave: (index: number) => void;
  focus: (index: number, row: HTMLElement) => void;
  blur: (index: number) => void;
  tap: (index: number) => void;
  close: () => void;
};

const GAP = 16;
// How far an open name slides right (the label's motion-safe:translate-x-6), so the picture clears it
const SHIFT = 24;

export function PixelHoverReveal({
  items,
  cellsPerRow = 12,
  duration = 0.8,
  smoothness = 1,
  order = "random",
  previewWidth = 360,
  previewHeight = 540,
  follow = 0.5,
  autoplay = "touch",
  className = "",
  rowClassName = "",
  labelClassName = "text-[clamp(40px,9vw,132px)]",
}: PixelHoverRevealProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const previewRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const labelRefs = useRef<(HTMLSpanElement | null)[]>([]);
  const handlers = useRef<Handlers | null>(null);
  // The type of the last pointer that pressed a row, so a tap can be told from a mouse click
  const pressedWith = useRef("mouse");
  // Kept across rebuilds (a prop change rebuilds everything), so the open row plays again with the new values
  const open = useRef({ index: -1, mode: "pointer" as "pointer" | "anchor", x: 0, y: 0 });
  const [active, setActive] = useState(-1);
  // A change of pictures rebuilds, a new array with the same pictures doesn't
  const sources = items.map((item) => item.src).join("\n");

  useGSAP(
    (_context, contextSafe) => {
      const box = boxRef.current;
      const preview = previewRef.current;
      const canvas = canvasRef.current;
      const context = canvas?.getContext("2d");
      if (!box || !preview || !canvas || !context || !contextSafe) return;

      const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const touchScreen = window.matchMedia("(hover: none)").matches;

      // The picture, drawn to fit (object-fit: cover) into a hidden canvas the cells copy from
      const frame = document.createElement("canvas");
      const frameContext = frame.getContext("2d");
      if (!frameContext) return;

      let cells: Cell[] = [];
      let width = 0;
      let height = 0;
      let current = -1;
      let hovered = -1;
      let pointer = { x: 0, y: 0 };
      // The cursor in window coordinates, so the picture can find it again after the box moves (resize, scroll)
      let client: { clientX: number; clientY: number } | null = null;
      let laidOutFor = "";

      // Load every picture up front so the first hover doesn't wait on the network
      const images = items.map((item, index) => {
        const image = new Image();
        image.decoding = "async";
        image.onload = () => {
          if (index !== current) return;
          paintFrame();
          draw();
        };
        image.src = item.src;
        return image;
      });

      const moveX = gsap.quickTo(preview, "x", { duration: follow, ease: "power3" });
      const moveY = gsap.quickTo(preview, "y", { duration: follow, ease: "power3" });

      function draw() {
        context!.clearRect(0, 0, canvas!.width, canvas!.height);
        for (const cell of cells) {
          if (cell.alpha <= 0) continue;
          context!.globalAlpha = cell.alpha;
          context!.drawImage(frame, cell.x, cell.y, cell.width, cell.height, cell.x, cell.y, cell.width, cell.height);
        }
        context!.globalAlpha = 1;
      }

      function paintFrame() {
        frameContext!.clearRect(0, 0, frame.width, frame.height);
        const image = images[current];
        if (!image || !image.complete || image.naturalWidth === 0) return;
        const scale = Math.max(frame.width / image.naturalWidth, frame.height / image.naturalHeight);
        const drawWidth = image.naturalWidth * scale;
        const drawHeight = image.naturalHeight * scale;
        frameContext!.drawImage(image, (frame.width - drawWidth) / 2, (frame.height - drawHeight) / 2, drawWidth, drawHeight);
      }

      // Size the picture (smaller on phones and short screens) and cut it into square cells. Cell
      // edges are rounded to whole pixels, so no hairline gaps show between them.
      function layout() {
        laidOutFor = `${box!.clientWidth}x${window.innerHeight}`;
        const ratio = previewHeight / previewWidth;
        width = Math.round(Math.min(previewWidth, box!.clientWidth * 0.46, (window.innerHeight * 0.66) / ratio));
        height = Math.round(width * ratio);
        preview!.style.width = `${width}px`;
        preview!.style.height = `${height}px`;
        const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
        canvas!.width = frame.width = Math.max(1, Math.round(width * pixelRatio));
        canvas!.height = frame.height = Math.max(1, Math.round(height * pixelRatio));

        gsap.killTweensOf(cells);
        const size = canvas!.width / cellsPerRow;
        const rows = Math.ceil(canvas!.height / size);
        cells = [];
        for (let row = 0; row < rows; row++) {
          for (let column = 0; column < cellsPerRow; column++) {
            const x = Math.round(column * size);
            const y = Math.round(row * size);
            cells.push({ x, y, width: Math.round((column + 1) * size) - x, height: Math.round((row + 1) * size) - y, alpha: current >= 0 ? 1 : 0 });
          }
        }
        paintFrame();
        draw();
        if (current >= 0) replace();
      }

      // The rows reflowed or the box moved under a still cursor: put the open picture back where it belongs
      function replace() {
        if (current < 0) return;
        if (open.current.mode === "pointer" && !reduced) {
          if (!client) return;
          pointer = pointerFrom(client);
          place(pointer, false);
        } else {
          place(anchorFor(current), false);
        }
      }

      // How long each cell waits, in "steps": the whole reveal is the last cell's wait plus one fade
      function waits() {
        const count = cells.length;
        if (order === "row") return { waits: cells.map((_, index) => index), last: count - 1 };
        if (order === "center") {
          const rows = Math.ceil(count / cellsPerRow);
          const middleX = (cellsPerRow - 1) / 2;
          const middleY = (rows - 1) / 2;
          const distances = cells.map((_, index) => Math.hypot((index % cellsPerRow) - middleX, Math.floor(index / cellsPerRow) - middleY));
          return { waits: distances, last: Math.max(1, ...distances) };
        }
        const shuffled = gsap.utils.shuffle(cells.map((_, index) => index));
        const waits: number[] = [];
        shuffled.forEach((cell, position) => (waits[cell] = position));
        return { waits, last: count - 1 };
      }

      function reveal() {
        paintFrame();
        if (reduced) {
          gsap.killTweensOf(cells);
          cells.forEach((cell) => (cell.alpha = 1));
          draw();
          return;
        }
        // Spread the reveal so the last cell finishes right at `duration`
        const { waits: wait, last } = waits();
        const step = duration / (last + smoothness);
        // A zero-length tween would jump straight to its end, so "no fade" is a fade too short to see
        const fade = Math.max(smoothness * step, 0.001);
        gsap.fromTo(
          cells,
          { alpha: 0 },
          { alpha: 1, duration: fade, ease: "none", stagger: (index: number) => wait[index]! * step, overwrite: true, onUpdate: draw, onComplete: draw },
        );
      }

      // A quick scatter out, so closing feels like the same effect run backwards
      function conceal() {
        if (reduced) {
          gsap.killTweensOf(cells);
          cells.forEach((cell) => (cell.alpha = 0));
          draw();
          return;
        }
        gsap.to(cells, { alpha: 0, duration: 0.1, ease: "none", stagger: { amount: 0.18, from: "random" }, overwrite: true, onUpdate: draw, onComplete: draw });
      }

      // Where the picture sits when it isn't following the cursor: just after the name, inside the box.
      // When the name is too long for the picture to fit beside it (phones), the picture moves just
      // above or below the row instead, at the right edge, so the name it belongs to stays readable.
      function anchorFor(index: number) {
        const label = labelRefs.current[index];
        const row = label?.closest("li");
        const boxRect = box!.getBoundingClientRect();
        const rect = label?.getBoundingClientRect() ?? boxRect;
        const rowRect = row?.getBoundingClientRect() ?? rect;
        const maxX = Math.max(width / 2 + GAP, box!.clientWidth - width / 2 - GAP);
        // The name's right edge once it has slid over: its resting edge (the slide taken out, it may be
        // half way) plus the full slide
        const sliding = label ? parseFloat(getComputedStyle(label).translate) || 0 : 0;
        const right = rect.right - sliding + (reduced ? 0 : SHIFT);
        const besideX = right - boxRect.left + GAP * 2 + width / 2;
        if (besideX <= maxX) {
          return {
            x: Math.max(width / 2 + GAP, besideX),
            y: gsap.utils.clamp(height / 2 + GAP, Math.max(height / 2 + GAP, box!.clientHeight - height / 2 - GAP), rect.top - boxRect.top + rect.height / 2),
          };
        }
        const top = rowRect.top - boxRect.top;
        const bottom = rowRect.bottom - boxRect.top;
        // Rows in the top half show the picture below them, rows in the bottom half above
        const below = top + (bottom - top) / 2 < box!.clientHeight / 2;
        return {
          x: Math.max(width / 2, box!.clientWidth - width / 2),
          y: below ? bottom + GAP / 2 + height / 2 : top - GAP / 2 - height / 2,
        };
      }

      // Moves the picture's middle to a point in the box: a glide if it's already showing, a jump if not
      function place(point: { x: number; y: number }, glide: boolean) {
        open.current.x = point.x;
        open.current.y = point.y;
        // Kept inside the box sideways, so a hover at the end of a full-width row never adds a
        // sideways scrollbar to the page (up and down it may pass the box, like the original)
        const x = gsap.utils.clamp(0, Math.max(0, box!.clientWidth - width), point.x - width / 2);
        const y = point.y - height / 2;
        if (glide && !reduced) {
          moveX(x);
          moveY(y);
        } else {
          // Start and end at the same spot: a jump that also stops any glide still running
          moveX(x, x);
          moveY(y, y);
        }
      }

      function show(index: number, mode: "pointer" | "anchor") {
        const wasOpen = current >= 0;
        const sameRow = index === current;
        current = index;
        open.current.index = index;
        open.current.mode = mode;
        setActive(index);
        // Reduced motion: no chasing the cursor, the picture sits beside the name
        const point = mode === "pointer" && !reduced ? pointer : anchorFor(index);
        place(point, wasOpen);
        if (!sameRow || !wasOpen) reveal();
      }

      function hide() {
        if (current < 0) return;
        current = -1;
        open.current.index = -1;
        setActive(-1);
        conceal();
      }

      const pointerFrom = (event: { clientX: number; clientY: number }) => {
        const rect = box.getBoundingClientRect();
        return { x: event.clientX - rect.left, y: event.clientY - rect.top };
      };

      // Rows open by themselves on touch screens (or always) until someone uses the list
      let autoplayCall: gsap.core.Tween | null = null;
      let autoplayIndex = -1;
      const autoplays = !reduced && items.length > 0 && (autoplay === "always" || (autoplay === "touch" && touchScreen));
      const next = contextSafe(() => {
        autoplayIndex = (autoplayIndex + 1) % items.length;
        show(autoplayIndex, "anchor");
        autoplayCall = gsap.delayedCall(Math.max(2.2, duration + 1.4), next);
      });
      const stopAutoplay = () => {
        autoplayCall?.kill();
        autoplayCall = null;
      };

      handlers.current = {
        enter: contextSafe((index: number, event: ReactPointerEvent) => {
          if (event.pointerType === "touch") return;
          stopAutoplay();
          hovered = index;
          client = { clientX: event.clientX, clientY: event.clientY };
          pointer = pointerFrom(client);
          show(index, "pointer");
        }),
        leave: contextSafe((index: number) => {
          if (hovered === index) hovered = -1;
          if (current === index && open.current.mode === "pointer") hide();
        }),
        focus: contextSafe((index: number, row: HTMLElement) => {
          // A mouse click also focuses the row; only keyboard focus moves the picture beside the name
          if (!row.matches(":focus-visible")) return;
          stopAutoplay();
          show(index, "anchor");
        }),
        blur: contextSafe((index: number) => {
          if (current === index && hovered !== index) hide();
        }),
        tap: contextSafe((index: number) => {
          stopAutoplay();
          if (current === index) hide();
          else show(index, "anchor");
        }),
        close: contextSafe(() => {
          stopAutoplay();
          hide();
        }),
      };

      const onPointerMove = contextSafe((event: PointerEvent) => {
        if (event.pointerType === "touch") return;
        client = { clientX: event.clientX, clientY: event.clientY };
        pointer = pointerFrom(client);
        if (current >= 0 && open.current.mode === "pointer" && !reduced) place(pointer, true);
      });
      // A tap anywhere that isn't a row closes the picture
      const onPointerDown = contextSafe((event: PointerEvent) => {
        if (event.pointerType !== "touch") return;
        if (!(event.target as Element).closest("[data-pixel-row]")) handlers.current?.close();
      });
      box.addEventListener("pointermove", onPointerMove);
      box.addEventListener("pointerdown", onPointerDown);

      // The observer also fires once on start; only a real change of size cuts the cells again,
      // or it would cut short the reveal that's playing
      const onResize = contextSafe(() => {
        const size = `${box.clientWidth}x${window.innerHeight}`;
        if (size !== laidOutFor) layout();
        // Same size but the rows may still have moved (a font loaded, the page above changed)
        else replace();
      });
      const resizeObserver = new ResizeObserver(onResize);
      layout();
      resizeObserver.observe(box);
      // The size cap follows the window's height, which the box alone doesn't report
      window.addEventListener("resize", onResize);
      // The picture is placed in the box, so a scroll under a still cursor would leave it behind
      // (a picture beside a name moves with the box already, so only the cursor's picture needs this)
      const onScroll = contextSafe(() => {
        if (open.current.mode === "pointer") replace();
      });
      window.addEventListener("scroll", onScroll, { capture: true, passive: true });

      // After a rebuild (a prop changed), play the open row again with the new values
      const reopen = open.current;
      if (reopen.index >= 0 && reopen.index < items.length) {
        current = -1;
        pointer = { x: reopen.x, y: reopen.y };
        show(reopen.index, reopen.mode);
      } else if (autoplays) {
        autoplayCall = gsap.delayedCall(0.8, next);
      }

      return () => {
        stopAutoplay();
        handlers.current = null;
        resizeObserver.disconnect();
        window.removeEventListener("resize", onResize);
        window.removeEventListener("scroll", onScroll, { capture: true });
        box.removeEventListener("pointermove", onPointerMove);
        box.removeEventListener("pointerdown", onPointerDown);
        gsap.killTweensOf(cells);
        for (const image of images) {
          image.onload = null;
          image.src = "";
        }
      };
    },
    { scope: boxRef, dependencies: [sources, cellsPerRow, duration, smoothness, order, previewWidth, previewHeight, follow, autoplay], revertOnUpdate: true },
  );

  const shown = active >= 0 ? items[active] : undefined;

  return (
    // No overflow: hidden, so the picture can follow the cursor past the first and last rows
    <div ref={boxRef} className={`relative w-full ${className}`}>
      <ul className="m-0 flex list-none flex-col border-0 border-t border-solid border-current/15 p-0">
        {items.map((item, index) => {
          const isActive = index === active;
          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}`;
          const inside = (
            <>
              <span
                ref={(label) => {
                  labelRefs.current[index] = label;
                }}
                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}`}
              >
                {item.label}
              </span>
              {item.meta ? (
                <span className={`shrink-0 text-[11px] tracking-[0.08em] uppercase transition-opacity duration-[350ms] ${isActive ? "opacity-100" : "opacity-55"}`}>{item.meta}</span>
              ) : null}
            </>
          );
          const shared = {
            "data-pixel-row": "",
            className: rowClasses,
            onPointerEnter: (event: ReactPointerEvent) => handlers.current?.enter(index, event),
            onPointerLeave: () => handlers.current?.leave(index),
            onPointerDown: (event: ReactPointerEvent) => {
              pressedWith.current = event.pointerType;
            },
            onFocus: (event: FocusEvent<HTMLElement>) => handlers.current?.focus(index, event.currentTarget),
            onBlur: () => handlers.current?.blur(index),
            onKeyDown: (event: KeyboardEvent) => {
              pressedWith.current = "keyboard";
              if (event.key === "Escape") handlers.current?.close();
            },
          };
          return (
            <li key={`${index}-${item.label}`}>
              {item.href ? (
                <a href={item.href} {...shared}>
                  {inside}
                </a>
              ) : (
                <button
                  type="button"
                  aria-pressed={isActive}
                  {...shared}
                  // A tap or Enter / Space (a click with no detail) toggles the picture, so the toggle button
                  // really toggles; a mouse click leaves it to hover, which has already opened it
                  onClick={(event) => {
                    if (pressedWith.current === "touch" || event.detail === 0) handlers.current?.tap(index);
                  }}
                >
                  {inside}
                </button>
              )}
            </li>
          );
        })}
      </ul>

      <div
        ref={previewRef}
        role={shown?.alt ? "img" : undefined}
        aria-label={shown?.alt || undefined}
        aria-hidden={shown?.alt ? undefined : true}
        className="pointer-events-none absolute top-0 left-0 z-10"
        style={{ willChange: "transform" }}
      >
        <canvas ref={canvasRef} aria-hidden="true" className="block h-full w-full" />
      </div>
    </div>
  );
}

The component. Installs to components/dimaac/pixel-hover-reveal.tsx.

Install

With the shadcn CLI

npx shadcn@latest add https://ui.dimaac.com/r/pixel-hover-reveal.json

It adds components/dimaac/pixel-hover-reveal.tsx and installs what it imports. With the @dimaac namespace in components.json (Docs) it's just npx shadcn@latest add @dimaac/pixel-hover-reveal.

By hand

npm install gsap @gsap/react
  1. Install the packages it uses: gsap, @gsap/react.
  2. Copy the component file below into components/dimaac/pixel-hover-reveal.tsx.
  3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
  4. Import it with import { PixelHoverReveal } from "@/components/dimaac/pixel-hover-reveal" and give its parent a size: it fills it.

Props

PropTypeDefaultDescription
items (required)PixelHoverRevealItem[]-The rows, top first. Three to six read best.
cellsPerRownumber12How many cells across the picture. Fewer means bigger, chunkier pixels.
durationnumber0.8How long the whole picture takes to break in, in seconds.
smoothnessnumber1How many cells fade at once. 0 pops each cell in, 4 is a soft dissolve.
order"random" | "row" | "center""random"The order the cells appear in: scattered, line by line, or growing out from the middle.
previewWidthnumber360Width of the picture, in pixels. It shrinks on small screens.
previewHeightnumber540Height of the picture, in pixels.
follownumber0.5How long the picture takes to catch up with the cursor, in seconds.
autoplay"touch" | "always" | "never""touch"When the rows open by themselves: on touch screens until the first tap, always (until the pointer arrives) or never.
classNamestring""Extra classes for the outer box. It sizes to its rows.
rowClassNamestring""Extra classes for each row.
labelClassNamestring"text-[clamp(40px,9vw,132px)]"Extra classes for each name, e.g. its font size.

Use with AI

Agent prompt

Paste it into Claude Code, Cursor or any coding agent, or open it straight in a chat. It carries your Customize settings.

Prompt
Add the Pixel Hover Reveal component (<PixelHoverReveal />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/pixel-hover-reveal
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/pixel-hover-reveal.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/pixel-hover-reveal.json

Then use it like this:
import { PixelHoverReveal } from "@/components/dimaac/pixel-hover-reveal";
<PixelHoverReveal items={items} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/pixel-hover-reveal.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt

Files agents read

llms.txt
Every component and how to install it
pixel-hover-reveal.md
This page as Markdown: install, props and the full source
pixel-hover-reveal.json
The shadcn registry item

About

A list of big names. Hover one and its picture breaks in cell by cell on a small canvas that glides after the cursor; leave and the cells scatter out again. The picture is cut into a grid of square cells that fade in on their own, scattered, line by line or growing out from the middle, and the whole reveal always lasts the duration you set. Keyboard focus opens the picture beside the name, a tap opens it on touch screens (which also play through the list by themselves until the first tap), and with reduced motion the picture appears at once, beside the name, without chasing the cursor. One 2D canvas and GSAP, no WebGL.

Installs to
components/dimaac/pixel-hover-reveal.tsx
Uses
gsap, @gsap/react
Tags
canvas, gsap, cursor, hover, list, image, pixel

Gradient sky by Nika on Lummi (Lummi License). Architecture by rena on Lummi (Lummi License). Cylinders by SHIHO on Lummi (Lummi License). Portrait by Shaun Duval on Lummi (Lummi License). Coastline by Cayetano Gros on Lummi (Lummi License).