The hover preview on the work index crossfades one project into another three pixels at a time, and the entire mechanism is one floor().
A crossfade between two full-bleed images has the same problem a page transition has: mix(a, b, t) is correct, and it reads as nothing happening slightly slowly. Entry 01 answers that with a continuous field — five octaves of fbm warping a front that has structure at every scale and an edge at none of them. This is the opposite answer to the same question, and it is four instructions long.
Quantise the fragment coordinate into squares, hash the square, and every pixel that lands inside it gets the same random number. The comparison against progress then happens once per cell rather than once per pixel, so the picture changes in discrete tiles that pop in a scattered order. Nothing is organic about it and nothing is meant to be.
The floor is the whole technique
floor(f / uCell) is doing all the work. It maps a range of coordinates onto one integer pair, that pair goes into the hash, and the hash therefore returns one threshold for the whole square. Remove the floor and every one of the arithmetic operations below it still runs, still produces a mask, and still animates — it just looks like television static, because a per-pixel threshold has no structure for the eye to lock onto.
// ---- the cell grid ----
// QUANTISING THE COORDINATE IS THE ENTIRE TECHNIQUE. floor(f / cell) gives
// every pixel inside one cell the same coordinate, so they all get the same
// threshold and the cell flips as one square. Take the floor away and this
// is a per-pixel dissolve, which reads as noise rather than as a mechanism.
vec2 cell = floor(f / uCell);
float rnd = hash(cell);
float ord = bayer4(cell);
float threshold = mix(rnd, ord, uOrdered);
// A hard step() would flip each cell in one frame — correct, and it looks
// cheap. Feathering the comparison lets a cell spend a few frames part-way,
// which is what turns a switch into a transition.
float m = smoothstep(uProgress - uFeather, uProgress + uFeather, threshold);
gl_FragColor = vec4(mix(b, a, m), 1.0);
}The cell is measured in device pixels, because gl_FragCoord is. That distinction is not academic: the shipped version multiplies the stripe widths by the device pixel ratio on the way into the uniforms and leaves the cell alone, so on a 2× display a cell is 1.5 CSS pixels across and the grid reads as grain, while on a 1× display it is three and you can see the squares. Both are fine. A cell that changed apparent size between the two would not be.
1 there is no cell and this is a per-pixel dissolve; past about 12 the squares stop being texture and start being tiles0 it is step() and every cell flips inside a single frameWhat ships on the work index
The shipped program is fourteen lines of GLSL, one of which is an interpolated hash function. stripe() is one more — two statements packed onto a single line, which dot the fragment onto a unit direction and step() the fractional part. That is the entire drawing side of the panel: each project carries an angle, a width and two colours, and those four values become the pattern the crossfade runs between.
/** floating-preview dissolve shader (Work index hover preview) */
export const DISSOLVE_VERT = /* glsl */ `
attribute vec2 p;
void main(){ gl_Position = vec4(p, 0.0, 1.0); }
`;
export const DISSOLVE_FRAG = /* glsl */ `
precision highp float;
uniform vec2 uRes; uniform float uProg;
uniform float uAngA, uWA; uniform vec3 uC1A, uC2A;
uniform float uAngB, uWB; uniform vec3 uC1B, uC2B;
float stripe(vec2 f, float a, float w){ vec2 d = vec2(cos(a), sin(a)); return step(0.5, fract(dot(f,d)/(2.0*w))); }
${HASH}
void main(){
vec2 f = gl_FragCoord.xy;
vec3 a = mix(uC1A, uC2A, stripe(f, uAngA, uWA));
vec3 b = mix(uC1B, uC2B, stripe(f, uAngB, uWB));
float n = hash(floor(f/3.0));
float m = smoothstep(uProg-0.13, uProg+0.13, n);
gl_FragColor = vec4(mix(b, a, m), 1.0);
}
`;Driving it is four assignments. The pattern currently on screen is copied from B into A, the newly hovered row’s pattern becomes B, progress is reset to zero, and a flag goes up that the tick reads to advance it. The first hover is the exception and takes the early return: both slots get the same pattern, progress is parked at 1, and the panel appears already resolved rather than dissolving in from whatever happened to be in the buffer.
// first appearance: park at the target instead of lerping in from 0,0
if (first) {
const pos = posRef.current;
pos.tx = pos.x = columnX();
pos.ty = pos.y = columnY(window.scrollY);
}
if (!glReadyRef.current) {
if (canvasRef.current) canvasRef.current.style.background = stripeCss(projects[i].stripe);
return;
}
if (first) {
setPat(aRef.current, i);
setPat(bRef.current, i);
progRef.current = 1;
dissolvingRef.current = false;
renderGL();
return;
}
copyPat(bRef.current, aRef.current);
setPat(bRef.current, i);
progRef.current = 0;
dissolvingRef.current = true;
},A hard edge and a soft one
A bare step(uProg, n) would be correct and would look cheap: each cell would flip in the frame its threshold was crossed, and the whole transition would be a field of instantaneous binary switches. Feathering the comparison to smoothstep(uProg - 0.13, uProg + 0.13, n) gives every cell a window 0.26 of the sweep wide to cross in — about 86 ms of the 330, or five frames at 60 Hz. That is what turns a switch into a transition, and it is the only softness in the effect.
The feather is not free at the ends, though, and the shipped version does not pay for it. Progress sweeps 0 → 1, so at the start every cell that hashed below 0.13 is already part-way across before anything has moved, and at the finish every cell above 0.87 — roughly one in eight — stops before it has arrived.
Random threshold, ordered threshold
The threshold does not have to be random. Swap the hash for the rank of the cell within a repeating 4×4 block and the same mask, at the same cost, produces something entirely different: a regular crosshatch that fills in a fixed order. The demo blends between the two on a uniform, which is the honest way to compare them — one toggle, nothing else changed.
/**
* The ordered 4x4 Bayer matrix, as arithmetic rather than a lookup.
*
* A texture would need a texture; this is the standard bit-interleave that
* produces the same sixteen values, and it costs four instructions. The result
* is the crosshatch a newspaper uses — a REGULAR threshold per cell, which
* dissolves in a visibly mechanical order rather than a random one.
*/
float bayer4(vec2 c) {
vec2 i = floor(mod(c, 4.0));
float x = i.x,
y = i.y;
float v = 0.0;
v += step(2.0, x) * 8.0;
v += step(2.0, y) * 4.0;
v += step(1.0, mod(x, 2.0)) * 2.0;
v += step(1.0, mod(y, 2.0)) * 1.0;
// the classic Bayer ordering is a bit-reversed interleave; this ranking is
// close enough at 4x4 that the eye reads the same even spread
return v / 16.0;
}What each one reads as is the point. The random threshold reads as a dissolve, because there is no order to anticipate — cells resolve in a scatter and the eye gives up trying to predict the next one. The ordered threshold reads as printing: it is the crosshatch of a newspaper halftone, and because the pattern repeats every four cells the viewer sees a texture arriving rather than a picture changing. The work index takes the hash. A four-cell repeat at a 330 ms sweep would put a visible screen door over the one moment the page is asking to be looked at.
