"use client";
import { memo, useEffect, useId, useRef, useState, type ReactNode } from "react";
import { useAnimationLoop, type Metrics } from "@/hooks/use-animation-loop";
export type MirageMode = "haze" | "liquify" | "shatter" | "tear";
export type MirageTrigger = "always" | "hover";
export interface MirageProps {
children?: ReactNode;
mode?: MirageMode;
intensity?: number;
speed?: number;
scale?: number;
chroma?: number;
trigger?: MirageTrigger;
edgeHold?: number;
tintColor?: string;
paused?: boolean;
reducedMotion?: boolean;
className?: string;
}
const TILE_BASE = 180;
const TILE_MIN = 60;
const TILE_MAX = 720;
const tileFor = (scale: number) =>
Math.max(
TILE_MIN,
Math.min(TILE_MAX, Math.round(TILE_BASE * Math.max(scale, 0.05))),
);
const CELLS: Record<MirageMode, [number, number]> = {
haze: [3, 6],
liquify: [2, 2],
shatter: [9, 9],
tear: [1, 11],
};
const OCTAVES: Record<MirageMode, number> = {
haze: 2,
liquify: 2,
shatter: 1,
tear: 1,
};
const smoothstep = (a: number, b: number, x: number) => {
const t = Math.min(1, Math.max(0, (x - a) / (b - a || 1e-6)));
return t * t * (3 - 2 * t);
};
function makeNoise(cellsX: number, cellsY: number, seed: number) {
const hash = (ix: number, iy: number) => {
const x = ((ix % cellsX) + cellsX) % cellsX;
const y = ((iy % cellsY) + cellsY) % cellsY;
const n = Math.sin(x * 127.1 + y * 311.7 + seed * 74.7) * 43758.5453;
return n - Math.floor(n);
};
return (u: number, v: number) => {
const fx = u * cellsX;
const fy = v * cellsY;
const ix = Math.floor(fx);
const iy = Math.floor(fy);
const tx = fx - ix;
const ty = fy - iy;
const sx = tx * tx * (3 - 2 * tx);
const sy = ty * ty * (3 - 2 * ty);
const a = hash(ix, iy);
const b = hash(ix + 1, iy);
const c = hash(ix, iy + 1);
const d = hash(ix + 1, iy + 1);
return (a + (b - a) * sx) * (1 - sy) + (c + (d - c) * sx) * sy;
};
}
function buildNoiseTile(mode: MirageMode, tile: number): HTMLCanvasElement | null {
const canvas = document.createElement("canvas");
canvas.width = tile;
canvas.height = tile;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
const [cx, cy] = CELLS[mode];
const oct = OCTAVES[mode];
const n1x = makeNoise(cx, cy, 1);
const n1y = makeNoise(cx, cy, 2);
const n2x = makeNoise(cx * 2, cy * 2, 3);
const n2y = makeNoise(cx * 2, cy * 2, 4);
const img = ctx.createImageData(tile, tile);
const data = img.data;
for (let y = 0; y < tile; y++) {
const v = y / tile;
for (let x = 0; x < tile; x++) {
const u = x / tile;
let dx = n1x(u, v) - 0.5;
let dy = n1y(u, v) - 0.5;
if (oct > 1) {
dx += (n2x(u, v) - 0.5) * 0.5;
dy += (n2y(u, v) - 0.5) * 0.5;
}
const i = (y * tile + x) * 4;
data[i] = Math.max(0, Math.min(255, 128 + dx * 220));
data[i + 1] = Math.max(0, Math.min(255, 128 + dy * 220));
data[i + 2] = 128;
data[i + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
return canvas;
}
function buildNoiseMap(
w: number,
h: number,
mode: MirageMode,
tile: number,
): string {
const src = buildNoiseTile(mode, tile);
if (!src) return "";
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(w));
canvas.height = Math.max(1, Math.round(h));
const ctx = canvas.getContext("2d");
if (!ctx) return "";
const pattern = ctx.createPattern(src, "repeat");
if (!pattern) return "";
ctx.fillStyle = pattern;
ctx.fillRect(0, 0, canvas.width, canvas.height);
return canvas.toDataURL();
}
function buildTaper(w: number, h: number, hold: number): string {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(w));
canvas.height = Math.max(1, Math.round(h));
const ctx = canvas.getContext("2d");
if (!ctx) return "";
const band = Math.max(
1,
Math.min(canvas.width, canvas.height) * 0.5 * Math.min(Math.max(hold, 0), 1),
);
const img = ctx.createImageData(canvas.width, canvas.height);
const data = img.data;
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
const d = Math.min(x, y, canvas.width - 1 - x, canvas.height - 1 - y);
const t = band <= 1 ? 1 : smoothstep(0, band, d);
const c = Math.round(t * 255);
const i = (y * canvas.width + x) * 4;
data[i] = c;
data[i + 1] = c;
data[i + 2] = c;
data[i + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
return canvas.toDataURL();
}
const Mirage = memo(
({
children,
mode = "haze",
intensity = 0.4,
speed = 0.35,
scale = 2.2,
chroma = 0.25,
trigger = "always",
edgeHold = 0.35,
tintColor = "#a855f7",
paused = false,
reducedMotion = false,
className,
}: MirageProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const noiseRef = useRef<SVGFEImageElement>(null);
const dispRef = useRef<SVGFEDisplacementMapElement>(null);
const tintRef = useRef<HTMLDivElement>(null);
const rawId = useId();
const filterId = `sg-mirage-${rawId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const gathered = useRef(0);
const pointer = useRef({ x: 0.5, y: 0.5, over: false });
const box = useRef({ w: 0, h: 0 });
const [maps, setMaps] = useState({
noise: "",
taper: "",
w: 0,
h: 0,
tile: tileFor(scale),
});
const tileRef = useRef(maps.tile);
const live = useRef({ mode, intensity, speed, scale, chroma, trigger, edgeHold, reducedMotion });
live.current = { mode, intensity, speed, scale, chroma, trigger, edgeHold, reducedMotion };
const rebuild = useRef<(() => void) | null>(null);
rebuild.current = () => {
const { w, h } = box.current;
if (w < 2 || h < 2) return;
const l = live.current;
const tile = tileFor(l.scale);
tileRef.current = tile;
setMaps({
noise: buildNoiseMap(w + tile, h + tile, l.mode, tile),
taper: buildTaper(w, h, l.edgeHold),
w,
h,
tile,
});
};
const loop = useAnimationLoop({
target: containerRef,
halted: paused || reducedMotion,
onResize: (m: Metrics) => {
const changed =
Math.abs(m.width - box.current.w) > 1 ||
Math.abs(m.height - box.current.h) > 1;
box.current = { w: m.width, h: m.height };
if (changed) rebuild.current?.();
},
onFrame: ({ elapsed }) => {
const l = live.current;
const noise = noiseRef.current;
const disp = dispRef.current;
const active = l.trigger === "always" ? 1 : pointer.current.over ? 1 : 0;
gathered.current += (active - gathered.current) * 0.09;
if (noise) {
const tile = tileRef.current;
const t = elapsed * l.speed;
const ox = -(((t * 26) % tile) + tile) % tile;
const oy = -(((t * 17) % tile) + tile) % tile;
noise.setAttribute("x", ox.toFixed(2));
noise.setAttribute("y", oy.toFixed(2));
}
if (disp) {
disp.setAttribute(
"scale",
(l.intensity * 26 * gathered.current).toFixed(2),
);
}
const tint = tintRef.current;
if (tint) {
tint.style.opacity = String(
Math.min(0.45, l.chroma * 0.45 * gathered.current),
);
const p = pointer.current;
tint.style.background = `radial-gradient(circle at ${(p.x * 100).toFixed(1)}% ${(p.y * 100).toFixed(1)}%, currentColor 0%, transparent 62%)`;
}
},
deps: [],
});
useEffect(() => {
rebuild.current?.();
}, [mode, scale, edgeHold]);
useEffect(() => {
loop.paint();
}, [intensity, speed, chroma, trigger, tintColor, maps, loop]);
const track = (e: React.PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return;
pointer.current.x = (e.clientX - r.left) / r.width;
pointer.current.y = (e.clientY - r.top) / r.height;
pointer.current.over = true;
loop.start();
};
const release = () => {
pointer.current.over = false;
loop.start();
};
const ready = maps.noise !== "" && maps.taper !== "";
return (
<div
ref={containerRef}
className={className ?? "relative"}
onPointerMove={track}
onPointerLeave={release}
onPointerCancel={release}
>
<svg aria-hidden className="absolute h-0 w-0" focusable="false">
<defs>
<filter
id={filterId}
colorInterpolationFilters="sRGB"
x="0"
y="0"
width="100%"
height="100%"
>
{ready ? (
<>
<feImage
ref={noiseRef}
href={maps.noise}
x="0"
y="0"
width={maps.w + maps.tile}
height={maps.h + maps.tile}
preserveAspectRatio="none"
result="noise"
/>
<feImage
href={maps.taper}
x="0"
y="0"
width={maps.w}
height={maps.h}
preserveAspectRatio="none"
result="taper"
/>
<feComposite
in="noise"
in2="taper"
operator="arithmetic"
k1="1"
k2="0"
k3="-0.5"
k4="0.5"
result="map"
/>
<feDisplacementMap
ref={dispRef}
in="SourceGraphic"
in2="map"
scale={0}
xChannelSelector="R"
yChannelSelector="G"
/>
</>
) : null}
</filter>
</defs>
</svg>
<div
style={{
filter: ready && !reducedMotion ? `url(#${filterId})` : undefined,
}}
>
{children}
</div>
<div
ref={tintRef}
aria-hidden
className="pointer-events-none absolute inset-0"
style={{ color: tintColor, mixBlendMode: "plus-lighter", opacity: 0 }}
/>
</div>
);
},
);
Mirage.displayName = "Mirage";
export default Mirage;