Setup lists the required files and packages. Usage shows how to add the component.
LiquidDitherCard.jsx
"use client";
import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
import "./liquid-dither-card.css";
export const LIQUID_DITHER_DEFAULTS = Object.freeze({
spacing: 2.4,
dotRadius: 0.65,
interactionRadius: 100,
strength: 16,
stiffness: 0.08,
damping: 0.77,
falloff: 2,
});
const SETTINGS_LIMITS = Object.freeze({
spacing: [2.4, 10],
dotRadius: [0.6, 1.5],
interactionRadius: [36, 200],
strength: [0, 80],
stiffness: [0.03, 0.26],
damping: [0.7, 0.96],
falloff: [1, 5],
});
/** Accept partial settings; ignore unknown keys and replace invalid numbers. */
export function normalizeLiquidDitherSettings(settings) {
return Object.fromEntries(
Object.entries(LIQUID_DITHER_DEFAULTS).map(([key, fallback]) => {
const supplied = settings?.[key];
const [minimum, maximum] = SETTINGS_LIMITS[key];
return [
key,
Number.isFinite(supplied)
? Math.min(maximum, Math.max(minimum, supplied))
: fallback,
];
}),
);
}
const MARK_SOURCE_SIZE = 512;
const MARK_FILL = 0.62;
const IDLE_DELAY_MS = 4500;
const IDLE_GAIN = 0.55;
// Portfolio-only artwork; standalone usage needs no external asset.
export const MASK_ARTWORK_URL = "/assets/vault/liquid-dither/cube-tone.png";
const MASK_SAMPLE_RADIUS = 7;
const TONE_LEVELS = 16;
const MAX_RADIUS_RATIO = 1.5;
const MIN_DOT_RADIUS = 0.3;
const TILE = Object.freeze({ x: 12, y: 12, size: 76, radius: 13 });
const CUBE = Object.freeze({
apex: Object.freeze([50, 22.6]),
left: Object.freeze([26.8, 35.7]),
right: Object.freeze([73.2, 35.7]),
center: Object.freeze([50, 48.3]),
leftBottom: Object.freeze([26.8, 63.7]),
rightBottom: Object.freeze([73.2, 63.7]),
bottomApex: Object.freeze([50, 77.5]),
});
const CARVE_WIDTH = 2.6;
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeToReducedMotion(onChange) {
if (typeof window === "undefined" || !window.matchMedia) {
return () => {};
}
const query = window.matchMedia(REDUCED_MOTION_QUERY);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}
function getReducedMotionSnapshot() {
return typeof window !== "undefined"
&& Boolean(window.matchMedia?.(REDUCED_MOTION_QUERY).matches);
}
function getServerReducedMotionSnapshot() {
return false;
}
function useCombinedReducedMotion(reducedMotion) {
const prefersReducedMotion = useSyncExternalStore(
subscribeToReducedMotion,
getReducedMotionSnapshot,
getServerReducedMotionSnapshot,
);
return Boolean(reducedMotion || prefersReducedMotion);
}
function roundedRectPath(context, x, y, width, height, radius) {
context.beginPath();
context.moveTo(x + radius, y);
context.arcTo(x + width, y, x + width, y + height, radius);
context.arcTo(x + width, y + height, x, y + height, radius);
context.arcTo(x, y + height, x, y, radius);
context.arcTo(x, y, x + width, y, radius);
context.closePath();
}
function fillPolygon(context, points, unit) {
context.beginPath();
points.forEach(([x, y], index) => {
if (index === 0) {
context.moveTo(x * unit, y * unit);
} else {
context.lineTo(x * unit, y * unit);
}
});
context.closePath();
context.fill();
}
function drawArtworkMask(context, image, size) {
context.clearRect(0, 0, size, size);
context.fillStyle = "#fff";
context.fillRect(0, 0, size, size);
context.drawImage(image, 0, 0, size, size);
}
function drawVectorFallback(context, size) {
const unit = size / 100;
context.clearRect(0, 0, size, size);
context.fillStyle = "rgb(13, 13, 13)";
roundedRectPath(
context,
TILE.x * unit,
TILE.y * unit,
TILE.size * unit,
TILE.size * unit,
TILE.radius * unit,
);
context.fill();
context.fillStyle = "rgb(179, 179, 179)";
fillPolygon(context, [CUBE.right, CUBE.center, CUBE.bottomApex, CUBE.rightBottom], unit);
context.fillStyle = "rgb(38, 38, 38)";
fillPolygon(context, [CUBE.left, CUBE.center, CUBE.bottomApex, CUBE.leftBottom], unit);
context.fillStyle = "rgb(110, 110, 110)";
fillPolygon(context, [CUBE.apex, CUBE.left, CUBE.center, CUBE.right], unit);
context.globalCompositeOperation = "destination-out";
const edges = [
[CUBE.apex, CUBE.left],
[CUBE.apex, CUBE.right],
[CUBE.left, CUBE.center],
[CUBE.center, CUBE.right],
[CUBE.left, CUBE.leftBottom],
[CUBE.right, CUBE.rightBottom],
[CUBE.center, CUBE.bottomApex],
[CUBE.leftBottom, CUBE.bottomApex],
[CUBE.rightBottom, CUBE.bottomApex],
];
context.beginPath();
edges.forEach(([[fromX, fromY], [toX, toY]]) => {
context.moveTo(fromX * unit, fromY * unit);
context.lineTo(toX * unit, toY * unit);
});
context.lineWidth = CARVE_WIDTH * unit;
context.lineJoin = "round";
context.lineCap = "round";
context.stroke();
context.globalCompositeOperation = "source-over";
}
/**
* Render inside a container with an explicit height.
* settings accepts any subset of LIQUID_DITHER_DEFAULTS as finite numbers.
* artworkSrc is optional: omit it for the built-in vector artwork, or supply
* an image you can use. Remote images require permission for canvas CORS access.
* Set ariaLabel to describe custom artwork for assistive technology.
* reducedMotion can disable motion; the OS preference always takes precedence.
* compact delays idle motion and pauses when less than 35% of the stage is visible.
*/
export function LiquidDitherStage({
compact = false,
reducedMotion = false,
settings = LIQUID_DITHER_DEFAULTS,
artworkSrc = null,
ariaLabel = "A shaded app tile with a cube logo built from thousands of small canvas dots that part around the pointer and spring back into place",
}) {
const normalizedSettings = useMemo(
() => normalizeLiquidDitherSettings(settings),
[settings],
);
const imageSource = typeof artworkSrc === "string" && artworkSrc.trim()
? artworkSrc.trim()
: null;
const stageRef = useRef(null);
const canvasRef = useRef(null);
const engineRef = useRef(null);
const settingsRef = useRef(normalizedSettings);
const previousSettingsRef = useRef(normalizedSettings);
const reduced = useCombinedReducedMotion(reducedMotion);
useEffect(() => {
const stage = stageRef.current;
const canvas = canvasRef.current;
if (!stage || !canvas) {
return undefined;
}
const context = canvas.getContext("2d");
if (!context) {
return undefined;
}
let disposed = false;
let animationFrame = 0;
let sleepTimer = 0;
let running = false;
let lastTime = 0;
let width = 0;
let height = 0;
let devicePixelRatio = 1;
let dots = [];
let sprites = [];
let spriteCoverage = [];
let visible = false;
let gain = 0;
let pointer = null;
let lastInteraction = compact
? performance.now()
: Number.NEGATIVE_INFINITY;
const influence = { x: 0, y: 0, placed: false };
const source = document.createElement("canvas");
source.width = MARK_SOURCE_SIZE;
source.height = MARK_SOURCE_SIZE;
const sourceContext = source.getContext("2d", { willReadFrequently: true });
let sourcePixels = null;
const markMetrics = () => {
const side = Math.min(width, height) * MARK_FILL;
return { centerX: width / 2, centerY: height / 2, side };
};
const calibration = document.createElement("canvas");
const measureCoverage = (sprite, pitch) => {
const cells = 6;
const cssSize = pitch * cells;
const pixels = Math.max(8, Math.ceil(cssSize * devicePixelRatio));
calibration.width = pixels;
calibration.height = pixels;
const bench = calibration.getContext("2d", { willReadFrequently: true });
if (!bench) {
return 0;
}
bench.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
bench.fillStyle = "#fff";
bench.fillRect(0, 0, cssSize, cssSize);
for (let gridY = 0; gridY < cells; gridY += 1) {
for (let gridX = 0; gridX < cells; gridX += 1) {
bench.drawImage(
sprite.canvas,
(gridX + 0.5) * pitch - sprite.half,
(gridY + 0.5) * pitch - sprite.half,
sprite.size,
sprite.size,
);
}
}
const inset = Math.round(1.5 * pitch * devicePixelRatio);
const span = Math.max(1, Math.round(3 * pitch * devicePixelRatio));
const data = bench.getImageData(inset, inset, span, span).data;
let sum = 0;
for (let index = 0; index < data.length; index += 4) {
sum += data[index];
}
return 1 - sum / (data.length / 4) / 255;
};
const rebuildSprites = () => {
const currentSettings = settingsRef.current;
const pitch = Math.max(3, currentSettings.spacing);
sprites = [];
spriteCoverage = [];
for (let level = 0; level < TONE_LEVELS; level += 1) {
const radius = (
(level / (TONE_LEVELS - 1))
* pitch
* MAX_RADIUS_RATIO
* currentSettings.dotRadius
);
if (radius < MIN_DOT_RADIUS * 0.5) {
sprites.push(null);
spriteCoverage.push(0);
continue;
}
const padding = 1;
const cssSize = (radius + padding) * 2;
const spriteCanvas = document.createElement("canvas");
spriteCanvas.width = Math.max(2, Math.ceil(cssSize * devicePixelRatio));
spriteCanvas.height = spriteCanvas.width;
const spriteContext = spriteCanvas.getContext("2d");
if (!spriteContext) {
sprites.push(null);
spriteCoverage.push(0);
continue;
}
spriteContext.scale(devicePixelRatio, devicePixelRatio);
spriteContext.fillStyle = "#000";
spriteContext.beginPath();
spriteContext.arc(cssSize / 2, cssSize / 2, radius, 0, Math.PI * 2);
spriteContext.fill();
const sprite = {
canvas: spriteCanvas,
half: cssSize / 2,
size: cssSize,
};
sprites.push(sprite);
spriteCoverage.push(measureCoverage(sprite, pitch));
}
};
const resample = () => {
dots = [];
if (!sourcePixels || width < 2 || height < 2) {
return;
}
const pitch = Math.max(3, settingsRef.current.spacing);
const mark = markMetrics();
const startX = mark.centerX - mark.side / 2;
const startY = mark.centerY - mark.side / 2;
for (let y = startY + pitch / 2; y < startY + mark.side; y += pitch) {
const sampleY = Math.min(
MARK_SOURCE_SIZE - 1,
Math.max(0, Math.floor(((y - startY) / mark.side) * MARK_SOURCE_SIZE)),
);
for (let x = startX + pitch / 2; x < startX + mark.side; x += pitch) {
const sampleX = Math.min(
MARK_SOURCE_SIZE - 1,
Math.max(0, Math.floor(((x - startX) / mark.side) * MARK_SOURCE_SIZE)),
);
let sum = 0;
let count = 0;
for (
let offsetY = -MASK_SAMPLE_RADIUS;
offsetY <= MASK_SAMPLE_RADIUS;
offsetY += 1
) {
const maskY = sampleY + offsetY;
if (maskY < 0 || maskY >= MARK_SOURCE_SIZE) {
continue;
}
for (
let offsetX = -MASK_SAMPLE_RADIUS;
offsetX <= MASK_SAMPLE_RADIUS;
offsetX += 1
) {
const maskX = sampleX + offsetX;
if (maskX < 0 || maskX >= MARK_SOURCE_SIZE) {
continue;
}
const pixel = (maskY * MARK_SOURCE_SIZE + maskX) * 4;
const alpha = sourcePixels[pixel + 3] / 255;
sum += 1 - alpha * (1 - sourcePixels[pixel] / 255);
count += 1;
}
}
const white = count > 0 ? sum / count : 1;
const targetInk = 1 - white;
if (targetInk < 0.02) {
continue;
}
let selectedLevel = -1;
let bestDistance = Number.POSITIVE_INFINITY;
for (let candidate = 0; candidate < sprites.length; candidate += 1) {
if (!sprites[candidate]) {
continue;
}
const distance = Math.abs(spriteCoverage[candidate] - targetInk);
if (distance < bestDistance) {
bestDistance = distance;
selectedLevel = candidate;
}
}
if (selectedLevel < 0) {
continue;
}
dots.push({
homeX: x,
homeY: y,
x,
y,
velocityX: 0,
velocityY: 0,
tone: selectedLevel,
});
}
}
};
const draw = () => {
if (width < 2 || height < 2) {
return;
}
context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
context.clearRect(0, 0, width, height);
dots.forEach((dot) => {
const sprite = sprites[dot.tone];
if (!sprite) {
return;
}
context.drawImage(
sprite.canvas,
dot.x - sprite.half,
dot.y - sprite.half,
sprite.size,
sprite.size,
);
});
};
const stop = () => {
running = false;
if (animationFrame) {
window.cancelAnimationFrame(animationFrame);
animationFrame = 0;
}
};
const snapHome = () => {
dots.forEach((dot) => {
dot.x = dot.homeX;
dot.y = dot.homeY;
dot.velocityX = 0;
dot.velocityY = 0;
});
};
const step = (now, deltaTime) => {
const currentSettings = settingsRef.current;
let targetX = null;
let targetY = 0;
let targetGain = 0;
if (pointer) {
targetX = pointer.x;
targetY = pointer.y;
targetGain = 1;
lastInteraction = now;
} else if (now - lastInteraction > IDLE_DELAY_MS && width > 2) {
const seconds = now / 1000;
const mark = markMetrics();
targetX = mark.centerX + Math.sin(seconds * 0.6) * mark.side * 0.34;
targetY = mark.centerY + Math.cos(seconds * 0.83 + 1.2) * mark.side * 0.26;
targetGain = IDLE_GAIN;
}
gain += (targetGain - gain) * Math.min(1, 0.14 * deltaTime);
if (targetX !== null) {
if (!influence.placed) {
influence.x = targetX;
influence.y = targetY;
influence.placed = true;
}
const follow = Math.min(1, 0.32 * deltaTime);
influence.x += (targetX - influence.x) * follow;
influence.y += (targetY - influence.y) * follow;
}
const radius = Math.max(1, currentSettings.interactionRadius);
const radiusSquared = radius * radius;
const dampingFactor = Math.pow(currentSettings.damping, deltaTime);
const stiffness = currentSettings.stiffness * deltaTime;
const influenceActive = gain > 0.004;
let maxOffset = 0;
let maxVelocity = 0;
dots.forEach((dot) => {
let targetHomeX = dot.homeX;
let targetHomeY = dot.homeY;
if (influenceActive) {
const deltaX = dot.homeX - influence.x;
const deltaY = dot.homeY - influence.y;
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
if (distanceSquared < radiusSquared && distanceSquared > 1e-6) {
const distance = Math.sqrt(distanceSquared);
const push = (
currentSettings.strength
* gain
* Math.pow(1 - distance / radius, currentSettings.falloff)
);
targetHomeX += (deltaX / distance) * push;
targetHomeY += (deltaY / distance) * push;
}
}
dot.velocityX = (
dot.velocityX + (targetHomeX - dot.x) * stiffness
) * dampingFactor;
dot.velocityY = (
dot.velocityY + (targetHomeY - dot.y) * stiffness
) * dampingFactor;
dot.x += dot.velocityX * deltaTime;
dot.y += dot.velocityY * deltaTime;
const offset = Math.abs(dot.x - dot.homeX) + Math.abs(dot.y - dot.homeY);
const velocity = Math.abs(dot.velocityX) + Math.abs(dot.velocityY);
maxOffset = Math.max(maxOffset, offset);
maxVelocity = Math.max(maxVelocity, velocity);
});
draw();
if (
!pointer
&& targetGain === 0
&& maxOffset < 0.06
&& maxVelocity < 0.06
) {
snapHome();
draw();
stop();
const remainingDelay = IDLE_DELAY_MS - (now - lastInteraction) + 60;
if (remainingDelay > 0 && visible) {
window.clearTimeout(sleepTimer);
sleepTimer = window.setTimeout(wake, remainingDelay);
}
}
};
const frame = (now) => {
if (disposed) {
return;
}
animationFrame = window.requestAnimationFrame(frame);
const deltaTime = lastTime === 0
? 1
: Math.min(2.5, Math.max(0.25, (now - lastTime) / 16.667));
lastTime = now;
step(now, deltaTime);
};
function wake() {
window.clearTimeout(sleepTimer);
if (disposed || reduced || running || !visible) {
return;
}
running = true;
lastTime = 0;
animationFrame = window.requestAnimationFrame(frame);
}
const resize = () => {
const rect = stage.getBoundingClientRect();
if (rect.width < 2 || rect.height < 2) {
return;
}
width = rect.width;
height = rect.height;
devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
const pixelWidth = Math.max(1, Math.round(width * devicePixelRatio));
const pixelHeight = Math.max(1, Math.round(height * devicePixelRatio));
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
rebuildSprites();
resample();
if (!running) {
draw();
}
};
const applyMask = (paint) => {
if (!sourceContext || disposed) {
return false;
}
try {
paint(sourceContext);
sourcePixels = sourceContext.getImageData(
0,
0,
MARK_SOURCE_SIZE,
MARK_SOURCE_SIZE,
).data;
} catch {
// Invalid images or blocked canvas readback must not crash the host page.
return false;
}
resample();
if (!running) {
draw();
}
return true;
};
const applyFallback = () => {
if (disposed) {
return;
}
// Reset the bitmap before drawing again, including its origin-clean flag.
source.width = MARK_SOURCE_SIZE;
applyMask((mask) => drawVectorFallback(mask, MARK_SOURCE_SIZE));
};
let artwork = null;
if (imageSource) {
artwork = new Image();
artwork.crossOrigin = "anonymous";
artwork.onload = () => {
if (!applyMask((mask) => drawArtworkMask(mask, artwork, MARK_SOURCE_SIZE))) {
applyFallback();
}
};
artwork.onerror = applyFallback;
artwork.src = imageSource;
} else {
applyFallback();
}
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(stage);
const intersectionObserver = new IntersectionObserver(
([entry]) => {
visible = Boolean(
entry?.isIntersecting
&& (!compact || entry.intersectionRatio >= 0.35),
);
if (visible) {
wake();
} else {
stop();
window.clearTimeout(sleepTimer);
}
},
{ threshold: [0, 0.35, 1] },
);
intersectionObserver.observe(stage);
resize();
engineRef.current = {
pointerMove: (x, y) => {
if (reduced) {
return;
}
pointer = { x, y };
wake();
},
pointerLeave: () => {
if (reduced) {
return;
}
pointer = null;
lastInteraction = performance.now();
},
syncSettings: ({ resample: shouldResample, sprite: shouldRebuildSprites }) => {
if (shouldRebuildSprites) {
rebuildSprites();
}
if (shouldResample) {
resample();
}
if (!running) {
draw();
}
wake();
},
};
return () => {
disposed = true;
stop();
window.clearTimeout(sleepTimer);
resizeObserver.disconnect();
intersectionObserver.disconnect();
if (artwork) {
artwork.onload = null;
artwork.onerror = null;
artwork.removeAttribute("src");
}
engineRef.current = null;
};
}, [compact, reduced, imageSource]);
useEffect(() => {
const previousSettings = previousSettingsRef.current;
previousSettingsRef.current = normalizedSettings;
settingsRef.current = normalizedSettings;
engineRef.current?.syncSettings({
resample: previousSettings.spacing !== normalizedSettings.spacing,
sprite: previousSettings.spacing !== normalizedSettings.spacing
|| previousSettings.dotRadius !== normalizedSettings.dotRadius,
});
}, [normalizedSettings]);
const updatePointer = (event) => {
const rect = event.currentTarget.getBoundingClientRect();
engineRef.current?.pointerMove(
event.clientX - rect.left,
event.clientY - rect.top,
);
};
const startPointer = (event) => {
if (!compact && !reduced && event.pointerType !== "mouse"
&& window.matchMedia("(max-width: 720px)").matches) {
event.currentTarget.setPointerCapture(event.pointerId);
}
updatePointer(event);
};
const releasePointer = (event) => {
// Mouse release keeps hover; cancellation must still clear any pointer.
if (event.pointerType === "mouse" && event.type === "pointerup") return;
engineRef.current?.pointerLeave();
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
return (
<div
ref={stageRef}
className="liquid-dither-card"
data-liquid-dither-mode={compact ? "compact" : "expanded"}
data-reduced-motion={reduced ? "true" : "false"}
onPointerMove={updatePointer}
onPointerDown={startPointer}
onPointerUp={releasePointer}
onLostPointerCapture={releasePointer}
onPointerLeave={() => engineRef.current?.pointerLeave()}
onPointerCancel={releasePointer}
>
<canvas
ref={canvasRef}
className="liquid-dither-card__canvas"
role="img"
aria-label={ariaLabel}
/>
</div>
);
}
export function LiquidDitherCard({ reducedMotion = false, artworkSrc = null, ariaLabel }) {
return (
<LiquidDitherStage
compact
reducedMotion={reducedMotion}
artworkSrc={artworkSrc}
ariaLabel={ariaLabel}
/>
);
}