Pixelated Marquee
An infinite logo/content scroller whose edges disintegrate into graduated pixel blocks — ramping pixel size, opacity, and backdrop blur instead of a flat fade.
Usage
import { PixelatedMarquee } from "~/components/marquee/pixelated-marquee";<PixelatedMarqueeduration={32}edgeWidth={150}pixelSize={16}maxBlur={6}surface="hsl(var(--card))">{logos.map((logo) => (<Logo key={logo.id} {...logo} />))}</PixelatedMarquee>
How the edge works
A normal marquee fades its edges with a single linear-gradient mask — one smooth ramp of opacity. This one instead overlays a stack of vertical bands on each edge, and each band ramps three things at once the further out it sits.
Outer bands get a larger pixel grid (the mortar lines that read as blocks), more backdrop blur, and a heavier surface-colored fade. So content doesn't just get faint — it disintegrates into ever-bigger, blurrier, fainter blocks before vanishing into the background.
// Outwardness t: ~1 at the outer rim → 0 at the interior.const t = 1 - (k + 0.5) / bands;const blur = t * maxBlur; // backdrop blur growsconst pixel = base * 2 ** Math.floor(t * 4); // pixel grid growsconst fadePct = Math.pow(t, 1.35) * 96; // surface fade bites near rimband.style.backdropFilter = `blur(${blur}px)`;band.style.backgroundColor =`color-mix(in srgb, ${surface} ${fadePct}%, transparent)`;band.style.backgroundSize = `${pixel}px ${pixel}px`; // mortar grid
The bands are plain absolutely-positioned divs computed once with useMemo — no canvas, no JS per frame — and the scroll respects prefers-reduced-motion. Set surface to match whatever sits behind the marquee.
Props
| prop | type | default | description |
|---|---|---|---|
| children | ReactNode | — | Items to scroll. Rendered twice for a seamless loop. |
| duration | number | 32 | Loop duration in seconds. Lower is faster. |
| direction | "left" | "right" | "left" | Scroll direction. |
| gap | number | 56 | Gap between items, in px. |
| edgeWidth | number | 130 | Width of the dissolving edge region, in px. |
| pixelSize | number | 16 | Largest pixel-block size, reached at the outer edge. |
| maxBlur | number | 6 | Max backdrop blur at the outer edge, in px. |
| surface | string | "hsl(var(--background))" | Color the edges dissolve into. Match the backdrop. |
| pauseOnHover | boolean | true | Pause the scroll while hovered. |
Source
download .zipThe full implementation, across 2 files. Copy it in or grab the zip. It leans on a cn() class helper and the theme tokens (--primary, --border, …).
import { useMemo } from "react";import { cn } from "~/lib/utils";interface Band {key: string;style: React.CSSProperties;}/*** Builds one stack of dissolve bands for a single edge. Each band is a vertical* slice that, the further it sits toward the outer edge, gets a bigger pixel* grid, more backdrop blur, and a heavier surface-color fade — so content* disintegrates into ever-larger, blurrier, fainter blocks instead of a smooth* gradient fade.** Two invariants keep the grid seamless across bands:* - colWidth is a whole multiple of the largest cell, and every band's lattice* starts at its own edge, so the grid phase never jumps between bands.* - pixel sizes are a doubling sequence off `base`, so a big cell's lines* always land on the small-cell lattice — no broken squares at zone edges.*/function buildBands(bands: number,colWidth: number,base: number,maxBlur: number,surface: string,side: "left" | "right"): Band[] {const out: Band[] = [];for (let k = 0; k < bands; k++) {// t: outwardness — ~1 at the outermost slice, →0 toward the solid interior.const t = 1 - (k + 0.5) / bands;const blur = +(t * maxBlur).toFixed(2);const level = Math.min(3, Math.floor(t * 4));const pixel = base * 2 ** level;const fadePct = Math.round(Math.pow(t, 1.35) * 96);const linePct = Math.min(95, Math.round(42 + t * 53));const fade = `color-mix(in srgb, ${surface} ${fadePct}%, transparent)`;const line = `color-mix(in srgb, ${surface} ${linePct}%, transparent)`;out.push({key: `${side}-${k}`,style: {position: "absolute",top: 0,bottom: 0,width: `${colWidth}px`,[side]: `${k * colWidth}px`,pointerEvents: "none",backdropFilter: blur > 0.05 ? `blur(${blur}px)` : undefined,WebkitBackdropFilter: blur > 0.05 ? `blur(${blur}px)` : undefined,backgroundColor: fade,backgroundImage: `linear-gradient(${line} 1px, transparent 1px), linear-gradient(90deg, ${line} 1px, transparent 1px)`,backgroundSize: `${pixel}px ${pixel}px`,// Anchor the lattice to the band edge that hugs the outer rim so the// squares line up the same way on both sides.backgroundPosition: side === "left" ? "left top" : "right top",},});}return out;}export interface PixelatedMarqueeProps {children: React.ReactNode;/** Loop duration in seconds. Lower = faster. */duration?: number;direction?: "left" | "right";/** Gap between items, in px. */gap?: number;/** Width of the dissolving edge region, in px. */edgeWidth?: number;/** Largest pixel-block size, reached at the outer edge, in px. */pixelSize?: number;/** Max backdrop blur, reached at the outer edge, in px. */maxBlur?: number;/** Surface color the edges dissolve into. Should match the backdrop. */surface?: string;pauseOnHover?: boolean;className?: string;}export function PixelatedMarquee({children,duration = 32,direction = "left",gap = 56,edgeWidth = 130,pixelSize = 16,maxBlur = 6,surface = "hsl(var(--background))",pauseOnHover = true,className,}: PixelatedMarqueeProps) {const { left, right } = useMemo(() => {// base doubles up to base*8 (the largest cell). colWidth is snapped to a// whole multiple of that largest cell so the grid lattice stays continuous.const base = Math.max(2, Math.round(pixelSize / 8));const maxCell = base * 8;const colWidth = maxCell;const count = Math.max(4, Math.round(edgeWidth / colWidth));return {left: buildBands(count, colWidth, base, maxBlur, surface, "left"),right: buildBands(count, colWidth, base, maxBlur, surface, "right"),};}, [edgeWidth, pixelSize, maxBlur, surface]);const trackVars = {"--marquee-gap": `${gap}px`,"--marquee-duration": `${duration}s`,"--marquee-direction": direction === "right" ? "reverse" : "normal",} as React.CSSProperties;return (<divclassName={cn("relative w-full overflow-hidden",pauseOnHover && "marquee-paused",className)}><div className="marquee-track" style={trackVars}>{children}<div className="contents" aria-hidden="true">{children}</div></div>{left.map((b) => (<div key={b.key} style={b.style} />))}{right.map((b) => (<div key={b.key} style={b.style} />))}</div>);}
/* ─── Marquee scroll ───────────────────────────────────────────────── */@keyframes marquee-x {from {transform: translateX(0);}to {transform: translateX(calc(-50% - var(--marquee-gap, 0px) / 2));}}.marquee-track {display: flex;width: max-content;flex-shrink: 0;align-items: center;gap: var(--marquee-gap, 2rem);animation: marquee-x var(--marquee-duration, 30s) linear infinite;animation-direction: var(--marquee-direction, normal);}.marquee-paused:hover .marquee-track {animation-play-state: paused;}@media (prefers-reduced-motion: reduce) {.marquee-track {animation: none;}}