Image Generation Loader
Live preview
Loading demo…
Installation
npx shadcn@latest add https://ui.aceternity.com/registry/image-generation-loader.json
Source
"use client";
import React from "react";
import { cn } from "@/lib/utils";
export type ImageGenerationLoaderEffect =
| "shimmer"
| "wave"
| "scale-wave"
| "none";
export type ImageGenerationLoaderColors = readonly [from: string, to: string];
type EasingPreset = "linear" | "ease-in" | "ease-out" | "ease-in-out";
export type ImageGenerationLoaderEasing =
| EasingPreset
| (string & {})
| readonly [x1: number, y1: number, x2: number, y2: number]
| ((progress: number) => number);
export type ImageGenerationLoaderProps = {
className?: string;
cellSize?: number;
gap?: number;
bandHeight?: number;
duration?: number;
effect?: ImageGenerationLoaderEffect;
waveAmplitude?: number;
scaleAmplitude?: number;
colors?: ImageGenerationLoaderColors;
text?: string;
textLetterSpacing?: number;
textWordSpacing?: number;
overlayOpacity?: number;
overlayExitDuration?: number;
easing?: ImageGenerationLoaderEasing;
};
type Cell = {
column: number;
row: number;
opacity: number;
phase: number;
scaleSeed: number;
isText: boolean;
};
const DEFAULT_COLORS = [
"var(--color-blue-300)",
"var(--color-blue-600)",
] as const;
const TAU = Math.PI * 2;
const GLYPH_WIDTH = 5;
const GLYPH_HEIGHT = 7;
const PIXEL_FONT: Record<string, string> = {
A: "01110/10001/10001/11111/10001/10001/10001",
B: "11110/10001/10001/11110/10001/10001/11110",
C: "01111/10000/10000/10000/10000/10000/01111",
D: "11110/10001/10001/10001/10001/10001/11110",
E: "11111/10000/10000/11110/10000/10000/11111",
F: "11111/10000/10000/11110/10000/10000/10000",
G: "01111/10000/10000/10111/10001/10001/01111",
H: "10001/10001/10001/11111/10001/10001/10001",
I: "11111/00100/00100/00100/00100/00100/11111",
J: "00111/00010/00010/00010/10010/10010/01100",
K: "10001/10010/10100/11000/10100/10010/10001",
L: "10000/10000/10000/10000/10000/10000/11111",
M: "10001/11011/10101/10101/10001/10001/10001",
N: "10001/11001/10101/10011/10001/10001/10001",
O: "01110/10001/10001/10001/10001/10001/01110",
P: "11110/10001/10001/11110/10000/10000/10000",
Q: "01110/10001/10001/10001/10101/10010/01101",
R: "11110/10001/10001/11110/10100/10010/10001",
S: "01111/10000/10000/01110/00001/00001/11110",
T: "11111/00100/00100/00100/00100/00100/00100",
U: "10001/10001/10001/10001/10001/10001/01110",
V: "10001/10001/10001/10001/10001/01010/00100",
W: "10001/10001/10001/10101/10101/10101/01010",
X: "10001/10001/01010/00100/01010/10001/10001",
Y: "10001/10001/01010/00100/00100/00100/00100",
Z: "11111/00001/00010/00100/01000/10000/11111",
"0": "01110/10001/10011/10101/11001/10001/01110",
"1": "00100/01100/00100/00100/00100/00100/01110",
"2": "01110/10001/00001/00010/00100/01000/11111",
"3": "11110/00001/00001/01110/00001/00001/11110",
"4": "00010/00110/01010/10010/11111/00010/00010",
"5": "11111/10000/10000/11110/00001/00001/11110",
"6": "01110/10000/10000/11110/10001/10001/01110",
"7": "11111/00001/00010/00100/01000/01000/01000",
"8": "01110/10001/10001/01110/10001/10001/01110",
"9": "01110/10001/10001/01111/00001/00001/01110",
"-": "00000/00000/00000/11111/00000/00000/00000",
".": "00000/00000/00000/00000/00000/01100/01100",
":": "00000/01100/01100/00000/01100/01100/00000",
"?": "01110/10001/00001/00010/00100/00000/00100",
};
const randomFrom = (x: number, y: number, salt: number) => {
const value = Math.sin(x * 12.9898 + y * 78.233 + salt * 37.719);
return value * 43758.5453 - Math.floor(value * 43758.5453);
};
const EASING_CURVES: Record<
EasingPreset,
readonly [number, number, number, number]
> = {
linear: [0, 0, 1, 1],
"ease-in": [0.42, 0, 1, 1],
"ease-out": [0, 0, 0.58, 1],
"ease-in-out": [0.42, 0, 0.58, 1],
};
const cubicCoordinate = (time: number, point1: number, point2: number) => {
const inverseTime = 1 - time;
return (
3 * inverseTime * inverseTime * time * point1 +
3 * inverseTime * time * time * point2 +
time * time * time
);
};
const cubicDerivative = (time: number, point1: number, point2: number) => {
const inverseTime = 1 - time;
return (
3 * inverseTime * inverseTime * point1 +
6 * inverseTime * time * (point2 - point1) +
3 * time * time * (1 - point2)
);
};
const createCubicBezier = (x1: number, y1: number, x2: number, y2: number) => {
const safeX1 = Math.min(1, Math.max(0, x1));
const safeX2 = Math.min(1, Math.max(0, x2));
return (progress: number) => {
let time = progress;
for (let iteration = 0; iteration < 6; iteration++) {
const difference = cubicCoordinate(time, safeX1, safeX2) - progress;
const derivative = cubicDerivative(time, safeX1, safeX2);
if (Math.abs(derivative) < 0.0001) break;
time = Math.min(1, Math.max(0, time - difference / derivative));
}
return cubicCoordinate(time, y1, y2);
};
};
const resolveEasing = (easing: ImageGenerationLoaderEasing) => {
if (typeof easing === "function") return easing;
if (typeof easing !== "string") {
return createCubicBezier(easing[0], easing[1], easing[2], easing[3]);
}
const preset = EASING_CURVES[easing as EasingPreset];
if (preset) return createCubicBezier(...preset);
const match = easing.match(
/^cubic-bezier\(\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*\)$/,
);
if (!match) return (progress: number) => progress;
return createCubicBezier(
Number(match[1]),
Number(match[2]),
Number(match[3]),
Number(match[4]),
);
};
export function ImageGenerationLoader({
className,
cellSize = 5,
gap = 2,
bandHeight = 48,
duration = 4000,
effect = "shimmer",
waveAmplitude = 12,
scaleAmplitude = 0.75,
colors = DEFAULT_COLORS,
text,
textLetterSpacing,
textWordSpacing,
overlayOpacity = 0.3,
overlayExitDuration = 700,
easing = "ease-in-out",
}: ImageGenerationLoaderProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const overlayRef = React.useRef<HTMLDivElement>(null);
const [fromColor, toColor] = colors;
React.useEffect(() => {
const canvas = canvasRef.current;
const overlay = overlayRef.current;
const parent = canvas?.parentElement;
if (!canvas || !overlay || !parent) return;
const context = canvas.getContext("2d", { alpha: true });
if (!context) return;
const colorProbe = document.createElement("span");
colorProbe.hidden = true;
parent.appendChild(colorProbe);
const colorCanvas = document.createElement("canvas");
colorCanvas.width = 1;
colorCanvas.height = 1;
const colorContext = colorCanvas.getContext("2d", {
willReadFrequently: true,
});
const decodeColor = (color: string) => {
colorProbe.style.color = color;
const resolvedColor = window.getComputedStyle(colorProbe).color;
colorContext?.clearRect(0, 0, 1, 1);
if (colorContext) {
colorContext.fillStyle = resolvedColor;
colorContext.fillRect(0, 0, 1, 1);
}
const pixel = colorContext?.getImageData(0, 0, 1, 1).data;
return [pixel?.[0] ?? 0, pixel?.[1] ?? 0, pixel?.[2] ?? 0] as const;
};
const fromRgb = decodeColor(fromColor);
const toRgb = decodeColor(toColor);
colorProbe.remove();
const rgba = (color: readonly [number, number, number], alpha: number) =>
`rgba(${color[0]}, ${color[1]}, ${color[2]}, ${alpha})`;
let width = 0;
let height = 0;
let cells: Cell[] = [];
let frameId = 0;
let startTime = performance.now();
let isVisible = true;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const pitch = Math.max(2, cellSize + gap);
const easingFunction = resolveEasing(easing);
const buildCells = () => {
const columnCount = Math.ceil(width / pitch) + 1;
const rowCount = Math.max(1, Math.floor((bandHeight + gap) / pitch));
const nextCells: Cell[] = [];
const textCells = new Set<string>();
if (text && rowCount >= GLYPH_HEIGHT) {
const characters = Array.from(text.trim().toUpperCase());
const letterGapColumns =
textLetterSpacing === undefined
? 1
: Math.max(0, Math.round(textLetterSpacing / pitch));
const wordGapColumns =
textWordSpacing === undefined
? 3
: Math.max(1, Math.round(textWordSpacing / pitch));
const characterWidths = characters.map((character) =>
character === " " ? wordGapColumns : GLYPH_WIDTH,
);
const textWidth =
characterWidths.reduce(
(total, characterWidth) => total + characterWidth,
0,
) +
Math.max(0, characters.length - 1) * letterGapColumns;
const visibleColumnCount = Math.max(
1,
Math.floor((width + gap) / pitch),
);
const startColumn = Math.max(
0,
Math.floor((visibleColumnCount - textWidth) / 2),
);
const startRow = Math.floor((rowCount - GLYPH_HEIGHT) / 2);
let glyphColumn = startColumn;
characters.forEach((character, characterIndex) => {
if (character === " ") {
glyphColumn += wordGapColumns;
} else {
const glyph = (PIXEL_FONT[character] ?? PIXEL_FONT["?"]).split("/");
for (let glyphRow = 0; glyphRow < GLYPH_HEIGHT; glyphRow++) {
for (let glyphPixel = 0; glyphPixel < GLYPH_WIDTH; glyphPixel++) {
if (glyph[glyphRow]?.[glyphPixel] === "1") {
textCells.add(
`${glyphColumn + glyphPixel}:${startRow + glyphRow}`,
);
}
}
}
glyphColumn += GLYPH_WIDTH;
}
if (characterIndex < characters.length - 1) {
glyphColumn += letterGapColumns;
}
});
}
for (let row = 0; row < rowCount; row++) {
for (let column = 0; column < columnCount; column++) {
nextCells.push({
column,
row,
opacity: 0.45 + randomFrom(column, row, 4) * 0.55,
phase: randomFrom(column, row, 5) * TAU,
scaleSeed: randomFrom(column, row, 6),
isText: textCells.has(`${column}:${row}`),
});
}
}
cells = nextCells;
};
const resize = () => {
const bounds = parent.getBoundingClientRect();
width = Math.max(1, bounds.width);
height = Math.max(1, bounds.height);
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
context.setTransform(dpr, 0, 0, dpr, 0, 0);
buildCells();
};
const draw = (now: number) => {
context.clearRect(0, 0, width, height);
const cycleDuration = Math.max(1, duration);
const cycleElapsed = (now - startTime) % cycleDuration;
const linearProgress = cycleElapsed / cycleDuration;
const progress = reducedMotion.matches
? 0.5
: easingFunction(linearProgress);
const centerY = -bandHeight + progress * (height + bandHeight * 2);
const bandTop = Math.round(centerY - bandHeight / 2);
const bandCenter = bandTop + bandHeight / 2;
const revealEdge = Math.max(0, Math.min(height, bandTop));
const exitWindow = Math.min(
Math.max(1, overlayExitDuration),
cycleDuration / 2,
);
const exitAmount = reducedMotion.matches
? 1
: Math.min(1, (cycleDuration - cycleElapsed) / exitWindow);
const easedExit = exitAmount * exitAmount * (3 - 2 * exitAmount);
overlay.style.clipPath = `inset(0 0 ${height - revealEdge}px 0)`;
overlay.style.opacity = `${overlayOpacity * easedExit}`;
const rowCount = Math.max(1, Math.floor((bandHeight + gap) / pitch));
const gridHeight = rowCount * cellSize + (rowCount - 1) * gap;
const gridTop = bandTop + Math.round((bandHeight - gridHeight) / 2);
const waveProgress = reducedMotion.matches
? 0.5
: ((now - startTime) % 1800) / 1800;
const waveX = -100 + waveProgress * (width + 200);
const getBulge = (x: number) => {
if (effect !== "wave" || reducedMotion.matches) return 0;
const distance = x - waveX;
return waveAmplitude * Math.exp(-(distance * distance) / (2 * 72 * 72));
};
const backgroundGradient = context.createLinearGradient(
0,
bandTop - waveAmplitude,
0,
bandTop + bandHeight + waveAmplitude,
);
backgroundGradient.addColorStop(0, rgba(toRgb, 0.03));
backgroundGradient.addColorStop(0.18, rgba(toRgb, 0.12));
backgroundGradient.addColorStop(0.5, rgba(fromRgb, 0.2));
backgroundGradient.addColorStop(0.82, rgba(toRgb, 0.12));
backgroundGradient.addColorStop(1, rgba(toRgb, 0.03));
context.fillStyle = backgroundGradient;
if (effect === "wave" && !reducedMotion.matches) {
context.beginPath();
context.moveTo(0, bandTop - getBulge(0));
for (let x = 4; x <= width; x += 4) {
context.lineTo(x, bandTop - getBulge(x));
}
for (let x = width; x >= 0; x -= 4) {
context.lineTo(x, bandTop + bandHeight + getBulge(x));
}
context.closePath();
context.fill();
} else {
context.fillRect(0, bandTop, width, bandHeight);
}
if (effect === "shimmer") {
const shineProgress = reducedMotion.matches
? 0.5
: ((now - startTime) % 1400) / 1400;
const shineX = -120 + shineProgress * (width + 240);
const shineGradient = context.createLinearGradient(
shineX - 120,
0,
shineX + 120,
0,
);
shineGradient.addColorStop(0, rgba(fromRgb, 0));
shineGradient.addColorStop(0.5, rgba(fromRgb, 0.18));
shineGradient.addColorStop(1, rgba(fromRgb, 0));
context.fillStyle = shineGradient;
context.fillRect(0, bandTop, width, bandHeight);
}
for (const cell of cells) {
const x = cell.column * pitch;
const baseY = gridTop + cell.row * pitch;
const distanceFromCenter = baseY + cellSize / 2 - bandCenter;
const stretch = 1 + getBulge(x + cellSize / 2) / (bandHeight / 2);
const y = bandCenter + distanceFromCenter * stretch - cellSize / 2;
if (y + cellSize < 0 || y > height) continue;
const animatedColorMix = (Math.sin(now * 0.0022 + cell.phase) + 1) / 2;
const colorMix = text
? cell.isText
? 0.82 + animatedColorMix * 0.18
: animatedColorMix * 0.35
: animatedColorMix;
const red = Math.round(fromRgb[0] + (toRgb[0] - fromRgb[0]) * colorMix);
const green = Math.round(
fromRgb[1] + (toRgb[1] - fromRgb[1]) * colorMix,
);
const blue = Math.round(
fromRgb[2] + (toRgb[2] - fromRgb[2]) * colorMix,
);
const pulse =
0.16 + ((Math.sin(now * 0.0045 + cell.phase) + 1) / 2) * 0.84;
const cellCenterX = x + cellSize / 2;
let renderedSize = cellSize;
if (
effect === "scale-wave" &&
!reducedMotion.matches &&
cell.scaleSeed > 0.42
) {
const rowStagger = cell.row * 9;
const distanceFromScaleWave = cellCenterX - (waveX - rowStagger);
const scaleWaveInfluence = Math.exp(
-(distanceFromScaleWave * distanceFromScaleWave) / (2 * 52 * 52),
);
const randomStrength = (cell.scaleSeed - 0.42) / 0.58;
renderedSize =
cellSize *
(1 +
scaleAmplitude *
scaleWaveInfluence *
(0.35 + randomStrength * 0.65));
}
const cellCenterY = y + cellSize / 2;
if (text && cell.isText) {
context.globalAlpha = 0.76 + pulse * 0.24;
context.fillStyle = `rgb(${fromRgb[0]} ${fromRgb[1]} ${fromRgb[2]})`;
context.fillRect(
cellCenterX - renderedSize / 2,
cellCenterY - renderedSize / 2,
renderedSize,
renderedSize,
);
const innerSize = Math.max(1, renderedSize * 0.55);
context.globalAlpha = 0.84 + pulse * 0.16;
context.fillStyle = `rgb(${toRgb[0]} ${toRgb[1]} ${toRgb[2]})`;
context.fillRect(
cellCenterX - innerSize / 2,
cellCenterY - innerSize / 2,
innerSize,
innerSize,
);
} else {
context.globalAlpha = text
? pulse * (0.32 + cell.opacity * 0.3)
: pulse * (0.7 + cell.opacity * 0.3);
context.fillStyle = `rgb(${red} ${green} ${blue})`;
context.fillRect(
cellCenterX - renderedSize / 2,
cellCenterY - renderedSize / 2,
renderedSize,
renderedSize,
);
}
}
context.globalAlpha = 1;
if (!reducedMotion.matches && isVisible) {
frameId = requestAnimationFrame(draw);
}
};
const start = () => {
cancelAnimationFrame(frameId);
startTime = performance.now();
frameId = requestAnimationFrame(draw);
};
const resizeObserver = new ResizeObserver(() => {
resize();
if (reducedMotion.matches) draw(performance.now());
});
const intersectionObserver = new IntersectionObserver(([entry]) => {
isVisible = entry.isIntersecting;
if (isVisible) start();
else cancelAnimationFrame(frameId);
});
const handleMotionPreference = () => start();
resize();
resizeObserver.observe(parent);
intersectionObserver.observe(canvas);
reducedMotion.addEventListener("change", handleMotionPreference);
start();
return () => {
cancelAnimationFrame(frameId);
resizeObserver.disconnect();
intersectionObserver.disconnect();
reducedMotion.removeEventListener("change", handleMotionPreference);
};
}, [
bandHeight,
cellSize,
duration,
easing,
effect,
fromColor,
gap,
overlayExitDuration,
overlayOpacity,
scaleAmplitude,
text,
textLetterSpacing,
textWordSpacing,
toColor,
waveAmplitude,
]);
return (
<>
<div
ref={overlayRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-(--loader-overlay-color) will-change-[clip-path,opacity]"
style={
{
"--loader-overlay-color": toColor,
clipPath: "inset(0 0 100% 0)",
opacity: overlayOpacity,
} as React.CSSProperties
}
/>
<canvas
ref={canvasRef}
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 size-full",
className,
)}
/>
</>
);
}