Skip to content
← components

Pixelated Marquee

new

An infinite logo/content scroller whose edges disintegrate into graduated pixel blocks — ramping pixel size, opacity, and backdrop blur instead of a flat fade.

Orbit
Prism
Vertex
Loop
Quanta
Flux
Cobalt
Ember

Usage

example.tsx
import { PixelatedMarquee } from "~/components/marquee/pixelated-marquee";
<PixelatedMarquee
duration={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.

bands.ts
// Outwardness t: ~1 at the outer rim → 0 at the interior.
const t = 1 - (k + 0.5) / bands;
const blur = t * maxBlur; // backdrop blur grows
const pixel = base * 2 ** Math.floor(t * 4); // pixel grid grows
const fadePct = Math.pow(t, 1.35) * 96; // surface fade bites near rim
band.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

proptypedefaultdescription
childrenReactNodeItems to scroll. Rendered twice for a seamless loop.
durationnumber32Loop duration in seconds. Lower is faster.
direction"left" | "right""left"Scroll direction.
gapnumber56Gap between items, in px.
edgeWidthnumber130Width of the dissolving edge region, in px.
pixelSizenumber16Largest pixel-block size, reached at the outer edge.
maxBlurnumber6Max backdrop blur at the outer edge, in px.
surfacestring"hsl(var(--background))"Color the edges dissolve into. Match the backdrop.
pauseOnHoverbooleantruePause the scroll while hovered.

The 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, …).

components/marquee/pixelated-marquee.tsx
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 (
<div
className={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>
);
}
app/styles/app.css
/* ─── 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;
}
}