Pixel Sort In
Displaced rows of pixels slide back into register until the image is whole.
The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef } from "react";
/**
* Vibary · Pixel Sort In
*
* A block of image resolves out of displaced rows of pixels.
*
* The technique: each row's displacement wraps modulo the width. A row
* that has slid 60% of the way across is drawn twice — once at its
* offset and once a full width behind it — so the row is always
* completely full. That is the entire difference between "sorting into
* place" and "sliding in from the side": with a wrap, the block is
* whole from the first frame and only its rows are wrong; without one,
* there are empty gutters and the eye reads a slide.
*
* Rows resolve in a scattered order, not top to bottom, from a hash of
* the row index — so the order is stable across a resize.
*
* Self-contained: one canvas plus an offscreen buffer it draws itself.
* Works with zero props; tune via `rows`, `colors`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type PixelSortInProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Pixel rows the block is cut into — the animated unit. */
rows?: number;
/** Pixel columns across the block. */
columns?: number;
/** Dark-to-light ramp the placeholder image is sampled from. */
colors?: string[];
/** Fires once the last row has landed. */
onSettled?: () => void;
};
type VariantConfig = {
/** Starting displacement as a multiple of the block width. */
travel: number;
/** Seconds for one row to travel back to zero. */
rowSeconds: number;
/** Seconds between the first row landing and the last one starting. */
spread: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely displaced — the image is legible the whole way through.
subtle: { travel: 0.4, rowSeconds: 0.8, spread: 0.45 },
// Enough scramble to read as broken, short enough not to annoy.
default: { travel: 0.85, rowSeconds: 0.62, spread: 0.7 },
// Rows wrap more than once before they find their place.
playful: { travel: 1.6, rowSeconds: 0.5, spread: 1 },
};
const DEFAULT_COLORS = ["#141B29", "#2E4661", "#6E97AC", "#B9CBD4", "#E6D9C2"];
/** Deterministic 0–1 from an integer — same row, same delay, every time. */
function hash(index: number) {
const value = Math.sin(index * 127.1 + 0.37) * 43758.5453;
return value - Math.floor(value);
}
function parseHex(hex: string) {
const value = hex.replace("#", "");
return {
r: parseInt(value.slice(0, 2), 16),
g: parseInt(value.slice(2, 4), 16),
b: parseInt(value.slice(4, 6), 16),
};
}
export default function PixelSortIn({
variant = "default",
rows = 32,
columns = 48,
colors = DEFAULT_COLORS,
onSettled,
}: PixelSortInProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// The parent's callback is read through a ref, assigned in an effect
// rather than during render, so an inline arrow can't restart the run.
const settledRef = useRef(onSettled);
useEffect(() => {
settledRef.current = onSettled;
});
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const config = VARIANTS[variant];
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const stops = colors.map(parseHex);
/** Sample the ramp — the placeholder image is duotone, never a rainbow. */
const ramp = (value: number) => {
const clamped = Math.min(0.999, Math.max(0, value));
const scaled = clamped * (stops.length - 1);
const index = Math.floor(scaled);
const t = scaled - index;
const a = stops[index];
const b = stops[Math.min(stops.length - 1, index + 1)];
const r = Math.round(a.r + (b.r - a.r) * t);
const g = Math.round(a.g + (b.g - a.g) * t);
const blue = Math.round(a.b + (b.b - a.b) * t);
return `rgb(${r},${g},${blue})`;
};
// The source image is generated, never loaded: a horizon, a light
// source and a little grain is enough structure for the eye to read
// "photo" and therefore to notice when a row is out of place.
const source = document.createElement("canvas");
source.width = columns;
source.height = rows;
const sourceContext = source.getContext("2d");
if (!sourceContext) return;
for (let row = 0; row < rows; row++) {
const v = (row + 0.5) / rows;
for (let column = 0; column < columns; column++) {
const u = (column + 0.5) / columns;
const ridge = 0.58 + Math.sin(u * 4.3) * 0.05 + Math.sin(u * 9.1 + 2) * 0.02;
const sun = Math.exp(-((u - 0.68) ** 2 * 9 + (v - 0.3) ** 2 * 22));
let value: number;
if (v < ridge) {
value = 0.34 + (v / ridge) * 0.4 + sun * 0.5;
} else {
const depth = (v - ridge) / (1 - ridge);
value =
0.14 +
(1 - depth) * 0.24 +
sun * 0.26 * (1 - depth) +
Math.sin(v * 58) * 0.05 * (1 - depth);
}
sourceContext.fillStyle = ramp(value + (hash(row * 131 + column) - 0.5) * 0.05);
sourceContext.fillRect(column, row, 1, 1);
}
}
let width = 0;
let height = 0;
const resize = () => {
const rect = canvas.getBoundingClientRect();
const ratio = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas.width = Math.max(1, Math.floor(width * ratio));
canvas.height = Math.max(1, Math.floor(height * ratio));
context.setTransform(ratio, 0, 0, ratio, 0, 0);
// Crisp cells: the upscale has to stay blocky or the "pixels" turn
// into a blur and the effect loses its subject.
context.imageSmoothingEnabled = false;
};
resize();
// Delay and direction are pure functions of the row index, so the
// scramble survives a resize without reshuffling.
const delayFor = (row: number) => hash(row) * config.spread;
const directionFor = (row: number) => (hash(row + 91) < 0.5 ? -1 : 1);
const render = (elapsed: number) => {
const rowHeight = height / rows;
let landed = 0;
for (let row = 0; row < rows; row++) {
const progress = Math.min(1, Math.max(0, (elapsed - delayFor(row)) / config.rowSeconds));
// Ease out quartic: a decisive arrival, no drift at the end.
const eased = 1 - Math.pow(1 - progress, 4);
if (progress >= 1) landed++;
const shift = (1 - eased) * config.travel * width * directionFor(row);
// Wrap into [0, width) and draw the row twice. This is the whole
// trick — the row covers the block no matter where it sits.
let offset = shift % width;
if (offset < 0) offset += width;
const y = row * rowHeight;
// A hair of overlap between rows; exact edges leave seams once
// the row height lands on a fractional device pixel.
const drawHeight = rowHeight + 0.6;
context.drawImage(source, 0, row, columns, 1, offset - width, y, width, drawHeight);
context.drawImage(source, 0, row, columns, 1, offset, y, width, drawHeight);
}
return landed === rows;
};
// Reduced motion: the resolved image. The block is the information;
// the scramble was only the way it arrived.
if (reduced) {
render(999);
const onResizeStill = () => {
resize();
render(999);
};
window.addEventListener("resize", onResizeStill);
return () => window.removeEventListener("resize", onResizeStill);
}
let frame = 0;
let elapsed = 0;
let last = performance.now();
let announced = false;
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += delta;
const done = render(elapsed);
if (done) {
if (!announced) {
announced = true;
settledRef.current?.();
}
return;
}
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => {
resize();
render(elapsed);
};
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
};
}, [variant, rows, columns, colors]);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ width: "100%", height: "100%", display: "block" }}
/>
);
}About this effect
A glitch-art entrance, not a loading state. Pixel sorting comes from the datamosh tradition — landscapes shredded into displaced rows — and it reads as an aesthetic statement wherever it appears, so it belongs only in interfaces that already carry that aesthetic: a creative portfolio, a game promo page, an album visual. Ordinary image loading is Image Blur Up's job, and a generating picture is Progressive Image Generation's — reach for this only when the glitch itself is the point. Each row is displaced along its own axis and the displacement wraps modulo the block width, so a row that has slid most of the way across is drawn twice and still covers the block completely. That wrap is the whole difference between an image sorting itself into place and an image sliding in from the side: with it, the block is whole from the first frame and only its rows are wrong. Rows resolve in a scattered order taken from a hash of the row index, which keeps the order stable through a resize.