Pixel Trail

A wrapper that paints a trail of square pixels behind the pointer. Every cell snaps to a grid, grows in at the pointer, holds for a moment, then shrinks away in a few hard steps. The trail can cycle through a palette as it's drawn, pick a random colour per cell, or shift each cell through the palette as it ages. Wrap any section in it; the content underneath stays fully interactive.

Move your pointer across the card.

Pixels

Move across the card, then flick fast. Every value is on the panel below the preview. Try Ultramarine with a larger cell size for a single-colour look, or turn on difference so the trail inverts wherever it crosses the text.

Snapping to a grid

The pointer position is divided by the cell size and floored, so a pixel can only ever sit on the grid. Pointer events arrive every frame or so, and a fast flick can skip several cells between two of them. To keep the trail unbroken, each move walks every cell on the line from the last position to the new one (Bresenham's line algorithm) and stamps them all. The result is a clean staircase at any speed. When the pointer leaves, the path is cut, so coming back in on the other side doesn't draw a line across the card.

Grow, hold, decay

Each cell has three phases:

  • Grow. About 60ms from nothing to full size with an ease-out, so the head of the trail looks like it's being drawn rather than stamped.
  • Hold. The cell stays at full size for its lifetime. Jitter randomises each lifetime by up to ±50%, so the tail breaks up into loose pixels instead of vanishing in a clean line.
  • Decay. The cell shrinks (or fades) in three hard steps instead of smoothly. Stepping keeps it looking digital. Set steps to 0 for a smooth shrink.

Crossing an existing cell restamps it, so circling in one spot keeps the cluster alive instead of stacking copies.

Colour

Colours can be any CSS colour, including tokens like var(--foreground). They're resolved against the canvas each time a new trail starts, so a theme switch is picked up on the next stroke. With no colors the trail uses the current text colour, which works in both themes without any accent.

  • Cycle bands the palette along the path, colorRun cells per colour.
  • Random picks a colour per cell.
  • Age walks each cell from the first colour to the last over its life, so the head and tail of the trail are different colours.

Performance

The trail is drawn on one canvas sized to the wrapper at device pixel ratio. The animation loop only runs while there are cells alive and stops as soon as the last one disappears, so an idle page costs nothing. The canvas ignores pointer events and is hidden from assistive tech. With reduced motion turned on, no trail is drawn at all.

Usage

<PixelTrail
  colors={["#FF4D4D", "#FFD60A", "#3A86FF", "#8338EC"]}
  cellSize={16}
  className="rounded-2xl bg-card"
>
  <section>…</section>
</PixelTrail>
components/blocks/effects/pixel-trail.tsx
"use client";

import { useReducedMotion } from "motion/react";
import { useEffect, useLayoutEffect, useRef } from "react";

import { cn } from "@/lib/utils";

export type PixelTrailColorMode = "cycle" | "random" | "age";
export type PixelTrailDecay = "shrink" | "fade" | "both";

export type PixelTrailProps = {
  children?: React.ReactNode;
  className?: string;
  /** Any CSS colour, including `var(--token)`. Defaults to the text colour. */
  colors?: string[];
  /** `cycle` bands colours along the path, `random` picks per cell, `age`
   * walks each cell through the palette as it gets older. */
  colorMode?: PixelTrailColorMode;
  /** Cells per colour band in `cycle` mode. */
  colorRun?: number;
  /** Grid pitch in px. Cells snap to this grid, which is what makes it pixelated. */
  cellSize?: number;
  /** Empty space between neighbouring cells, in px. */
  gap?: number;
  /** Corner radius of each cell, in px. */
  radius?: number;
  /** Time for a new cell to grow to full size, in ms. */
  grow?: number;
  /** Time a cell holds at full size before it starts to decay, in ms. */
  lifetime?: number;
  /** Time a cell takes to disappear, in ms. */
  decay?: number;
  /** 0–1. Randomises each cell's lifetime so the tail breaks up unevenly. */
  jitter?: number;
  decayStyle?: PixelTrailDecay;
  /** Quantise the shrink into this many hard steps. 0 keeps it smooth. */
  steps?: number;
  /** Draw the trail over the content or behind it. */
  layer?: "above" | "below";
  /** Canvas blend mode, e.g. `difference` to invert where it crosses text. */
  blendMode?: React.CSSProperties["mixBlendMode"];
};

type Cell = { x: number; y: number; born: number; life: number; color: number };

const easeOut = (t: number) => 1 - (1 - t) ** 3;

export function PixelTrail({
  children,
  className,
  colors = ["currentColor"],
  colorMode = "cycle",
  colorRun = 3,
  cellSize = 16,
  gap = 2,
  radius = 0,
  grow = 60,
  lifetime = 350,
  decay = 400,
  jitter = 0.5,
  decayStyle = "shrink",
  steps = 3,
  layer = "above",
  blendMode,
}: PixelTrailProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const reduceMotion = useReducedMotion();

  // Read the latest props from the render loop without restarting it.
  const next = { colors, colorMode, colorRun, cellSize, gap, radius, grow, lifetime, decay, jitter, decayStyle, steps };
  const opts = useRef(next);
  useLayoutEffect(() => {
    opts.current = next;
  });

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    const ctx = canvas?.getContext("2d");
    if (!root || !canvas || !ctx || reduceMotion) return;

    const cells = new Map<string, Cell>();
    let resolved: string[] = [];
    let last: { cx: number; cy: number } | null = null;
    let stamped = 0;
    let frame = 0;
    let width = 0;
    let height = 0;

    const resize = () => {
      const dpr = window.devicePixelRatio || 1;
      width = root.clientWidth;
      height = root.clientHeight;
      canvas.width = Math.round(width * dpr);
      canvas.height = Math.round(height * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };
    resize();
    const observer = new ResizeObserver(resize);
    observer.observe(root);

    // Resolve CSS colours (tokens, currentColor) once per burst, so a theme
    // switch is picked up the next time the trail starts.
    const resolveColors = () => {
      resolved = opts.current.colors.map((c) => {
        canvas.style.color = "";
        canvas.style.color = c;
        return getComputedStyle(canvas).color;
      });
      canvas.style.color = "";
      if (resolved.length === 0) resolved = [getComputedStyle(canvas).color];
    };

    const stamp = (cx: number, cy: number, now: number) => {
      const o = opts.current;
      const n = Math.max(resolved.length, 1);
      const color =
        o.colorMode === "random"
          ? Math.floor(Math.random() * n)
          : Math.floor(stamped / Math.max(o.colorRun, 1)) % n;
      stamped++;
      const life = o.lifetime * (1 + o.jitter * (Math.random() * 2 - 1));
      cells.set(`${cx},${cy}`, { x: cx, y: cy, born: now, life: Math.max(life, 0), color });
    };

    const draw = (now: number) => {
      const o = opts.current;
      ctx.clearRect(0, 0, width, height);
      const full = Math.max(o.cellSize - o.gap, 1);
      const n = resolved.length;

      for (const [key, c] of cells) {
        const age = now - c.born;
        const end = c.life + o.decay;
        if (age >= end) {
          cells.delete(key);
          continue;
        }

        let scale = o.grow > 0 ? easeOut(Math.min(age / o.grow, 1)) : 1;
        let alpha = 1;
        if (age > c.life && o.decay > 0) {
          let t = (age - c.life) / o.decay;
          if (o.steps > 0) t = Math.ceil(t * o.steps) / o.steps;
          if (o.decayStyle !== "fade") scale *= 1 - t;
          if (o.decayStyle !== "shrink") alpha = 1 - t;
        }
        if (scale <= 0 || alpha <= 0) continue;

        const color =
          o.colorMode === "age" ? Math.min(Math.floor((age / end) * n), n - 1) : c.color;
        const size = full * scale;
        const px = c.x * o.cellSize + (o.cellSize - size) / 2;
        const py = c.y * o.cellSize + (o.cellSize - size) / 2;

        ctx.globalAlpha = alpha;
        ctx.fillStyle = resolved[color % n];
        ctx.beginPath();
        ctx.roundRect(px, py, size, size, Math.min(o.radius * scale, size / 2));
        ctx.fill();
      }
      ctx.globalAlpha = 1;

      frame = cells.size > 0 ? requestAnimationFrame(draw) : 0;
    };

    const onMove = (e: PointerEvent) => {
      const rect = root.getBoundingClientRect();
      const size = opts.current.cellSize;
      const cx = Math.floor((e.clientX - rect.left) / size);
      const cy = Math.floor((e.clientY - rect.top) / size);
      const now = performance.now();

      if (!frame) resolveColors();

      // Walk every grid cell between the last point and this one (Bresenham),
      // so a fast flick still leaves an unbroken staircase of pixels.
      if (last) {
        let x = last.cx;
        let y = last.cy;
        const dx = Math.abs(cx - x);
        const dy = -Math.abs(cy - y);
        const sx = x < cx ? 1 : -1;
        const sy = y < cy ? 1 : -1;
        let err = dx + dy;
        while (x !== cx || y !== cy) {
          const e2 = 2 * err;
          if (e2 >= dy) {
            err += dy;
            x += sx;
          }
          if (e2 <= dx) {
            err += dx;
            y += sy;
          }
          stamp(x, y, now);
        }
      } else {
        stamp(cx, cy, now);
      }
      last = { cx, cy };

      if (!frame) frame = requestAnimationFrame(draw);
    };

    // Break the path on leave, so re-entering doesn't draw a line across.
    const onLeave = () => {
      last = null;
    };

    root.addEventListener("pointermove", onMove);
    root.addEventListener("pointerleave", onLeave);
    return () => {
      root.removeEventListener("pointermove", onMove);
      root.removeEventListener("pointerleave", onLeave);
      observer.disconnect();
      cancelAnimationFrame(frame);
    };
  }, [reduceMotion]);

  return (
    <div ref={rootRef} className={cn("relative isolate", className)}>
      <canvas
        ref={canvasRef}
        aria-hidden
        className={cn(
          "pointer-events-none absolute inset-0 size-full",
          layer === "above" ? "z-10" : "-z-10"
        )}
        style={{ mixBlendMode: blendMode }}
      />
      {children}
    </div>
  );
}

Search concepts

Search for a command to run...