A cream hero for an indie game studio with a heavy headline, two square buttons and three game cards with tiny pixel sprites. As the pointer moves, the grid squares it crosses light up and then fade in four hard steps, like an old display. The trail is drawn on a canvas that sits above the content with a difference blend, so each square inverts what is under it: paper turns black and black type turns white, which keeps every word readable. When no pointer is moving, a demo point draws a looping curve. On phones the cards stack and dragging a finger paints the trail.
// Adapted from Fancy Components (https://fancycomponents.dev/docs/components/background/pixel-trail)
// Copyright (c) 2024 Daniel Petho. MIT License: https://we.inc/components/licenses
import { useEffect, useRef } from "react";
import { useInView, useReducedMotion } from "framer-motion";
const PIXEL = 26; // size of one trail square, in CSS pixels
const FADE_MS = 900; // how long a square takes to disappear
const STEPS = 4; // opacity levels while fading: fewer steps look more like old hardware
const IDLE_MS = 1600; // after this long without input, the demo pointer draws on its own
// 7 x 7 sprites: X is a filled pixel.
const games = [
{ title: "Moth Lantern", meta: "Puzzle / 2 hours", color: "#ff5b2e", sprite: ["..X.X..", ".XXXXX.", "XX.X.XX", "XXXXXXX", ".XXXXX.", "..X.X..", ".X...X."] },
{ title: "Tin Orchard", meta: "Farming / endless", color: "#0f9d6c", sprite: ["...X...", "..XXX..", ".XXXXX.", "XXXXXXX", "...X...", "...X...", "..XXX.."] },
{ title: "Slow Comet", meta: "Arcade / 20 minutes", color: "#2743ff", sprite: ["......X", ".....X.", "..XXX..", ".XXXXX.", ".XXXXX.", "..XXX..", "X......"] },
];
function Sprite({ rows, color }: { rows: string[]; color: string }) {
return (
<svg aria-hidden="true" viewBox="0 0 7 7" className="h-12 w-12 shrink-0" shapeRendering="crispEdges">
{rows.flatMap((row, y) => [...row].map((c, x) => (c === "X" ? <rect key={`${x}-${y}`} x={x} y={y} width="1" height="1" fill={color} /> : null)))}
</svg>
);
}
export default function PixelTrailCursorEffectOverHeroContent() {
const reduce = useReducedMotion();
const sectionRef = useRef<HTMLElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const inView = useInView(sectionRef, { amount: 0.1 });
useEffect(() => {
const section = sectionRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!section || !canvas || !ctx || reduce || !inView) return;
let cols = 0;
let rows = 0;
let cells = new Float32Array(0); // brightness of every square, 1 = just touched
let w = 0;
let h = 0;
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
w = section.clientWidth;
h = section.clientHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
cols = Math.ceil(w / PIXEL);
rows = Math.ceil(h / PIXEL);
cells = new Float32Array(cols * rows);
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(section);
const touch = (x: number, y: number) => {
const cx = Math.floor(x / PIXEL);
const cy = Math.floor(y / PIXEL);
if (cx >= 0 && cy >= 0 && cx < cols && cy < rows) cells[cy * cols + cx] = 1;
};
// Light every square between two points, so fast movement leaves no gaps.
const stroke = (from: { x: number; y: number }, to: { x: number; y: number }) => {
const n = Math.max(1, Math.ceil(Math.hypot(to.x - from.x, to.y - from.y) / (PIXEL / 2)));
for (let i = 1; i <= n; i++) touch(from.x + ((to.x - from.x) * i) / n, from.y + ((to.y - from.y) * i) / n);
};
const real = { x: 0, y: 0, at: -Infinity, fresh: true };
const onMove = (e: PointerEvent) => {
const r = section.getBoundingClientRect();
const next = { x: e.clientX - r.left, y: e.clientY - r.top };
if (real.fresh) touch(next.x, next.y);
else stroke(real, next);
real.x = next.x;
real.y = next.y;
real.at = performance.now();
real.fresh = false;
};
const onLeave = () => {
real.fresh = true;
};
section.addEventListener("pointermove", onMove);
section.addEventListener("pointerdown", onMove);
section.addEventListener("pointerleave", onLeave);
const ghost = { x: -1, y: -1 };
let raf = 0;
let last = performance.now();
const loop = (now: number) => {
raf = requestAnimationFrame(loop);
const dt = Math.min(now - last, 60);
last = now;
if (document.hidden) return;
if (now - real.at > IDLE_MS) {
// Demo pointer: a wide looping curve across the section.
const t = now / 1000;
const next = { x: w * (0.5 + 0.42 * Math.sin(t * 1.1)), y: h * (0.5 + 0.36 * Math.sin(t * 2.2 + 0.8)) };
if (ghost.x < 0) touch(next.x, next.y);
else stroke(ghost, next);
ghost.x = next.x;
ghost.y = next.y;
} else ghost.x = -1;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "#ffffff";
const decay = dt / FADE_MS;
for (let i = 0; i < cells.length; i++) {
const v = cells[i];
if (v <= 0) continue;
ctx.globalAlpha = Math.ceil(v * STEPS) / STEPS;
ctx.fillRect((i % cols) * PIXEL, Math.floor(i / cols) * PIXEL, PIXEL, PIXEL);
cells[i] = v - decay;
}
ctx.globalAlpha = 1;
};
raf = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(raf);
observer.disconnect();
section.removeEventListener("pointermove", onMove);
section.removeEventListener("pointerdown", onMove);
section.removeEventListener("pointerleave", onLeave);
ctx.clearRect(0, 0, w, h);
};
}, [reduce, inView]);
return (
<section ref={sectionRef} className="relative overflow-hidden bg-[#f1eee4] px-6 py-16 text-neutral-950 sm:py-20">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{
backgroundImage: "linear-gradient(rgba(10,10,10,0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(10,10,10,0.05) 1px, transparent 1px)",
backgroundSize: `${PIXEL}px ${PIXEL}px`,
}}
/>
<div className="relative mx-auto max-w-6xl">
<div className="flex items-center justify-between font-mono text-xs uppercase tracking-widest">
<span className="flex items-center gap-2 font-bold">
<span className="h-3 w-3 bg-[#ff5b2e]" />
Halftone Arcade
</span>
<span className="text-neutral-700">Est. in a garage, still in a garage</span>
</div>
<h2 className="mt-14 max-w-4xl text-5xl font-black leading-[0.98] tracking-tight sm:text-7xl lg:text-8xl">Small, strange games made by hand.</h2>
<p className="mt-6 max-w-xl text-lg leading-relaxed text-neutral-700">
We are four people who draw every pixel and write every bleep. No ads, no timers, no loot boxes. Buy a game once and keep it.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<a href="#" className="inline-flex h-12 items-center bg-neutral-950 px-7 font-mono text-sm font-bold uppercase tracking-wide text-white transition hover:bg-neutral-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-[#f1eee4]">
Play the demos
</a>
<a href="#" className="inline-flex h-12 items-center border-2 border-neutral-950 px-7 font-mono text-sm font-bold uppercase tracking-wide transition hover:bg-neutral-950 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-[#f1eee4]">
Read the devlog
</a>
</div>
<ul className="mt-14 grid gap-4 sm:grid-cols-3">
{games.map((g) => (
<li key={g.title} className="flex items-center gap-4 border-2 border-neutral-950 bg-[#f1eee4] p-4">
<Sprite rows={g.sprite} color={g.color} />
<div>
<p className="text-lg font-bold leading-tight">{g.title}</p>
<p className="mt-1 font-mono text-xs uppercase tracking-wide text-neutral-700">{g.meta}</p>
</div>
</li>
))}
</ul>
</div>
{/* The trail sits on top and inverts whatever is under it, so text stays readable inside the squares. */}
<canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full mix-blend-difference" />
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/pixel-trail-cursor-effect-over-hero-content.json
A full-width hero at this square size has well over a thousand cells. A canvas keeps them in one typed array and draws only the lit ones each frame, which costs a fraction of a millisecond and adds no DOM nodes.
Pointer events arrive at most once per frame, so a fast move can skip several squares. The component walks the straight line between the previous and current positions in half-square steps and lights every cell on the way.
The canvas draws white squares and has `mix-blend-mode: difference`. White in difference mode subtracts every colour beneath it from white, which inverts it. Partly transparent squares give a partial inversion, which is what makes the stepped fade.
No. The loop starts only while the section is in view, skips frames when the tab is hidden, and is cancelled along with its listeners and resize observer on unmount.
Yes. Put the canvas in a fixed, full-viewport wrapper and listen for `pointermove` on `window` instead of the section. Keep `pointer-events-none` on the canvas.
Adapted from Pixel Trail in Fancy Components, used under its open-source licence. Licences and credits
Related: Dot Matrix Background with Ripples and Pointer Spotlight, Spotlight Cards with Pointer Light, Particle Network Background with Connecting Lines.
More cursor effects or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.