"use client";
import { memo, useEffect, useRef } from "react";
import { useAnimationLoop, type Metrics } from "@/hooks/use-animation-loop";
export type RimeMode = "loop" | "hover" | "hold";
interface RimeProps {
text?: string;
mode?: RimeMode;
growth?: number;
branching?: number;
density?: number;
seeds?: number;
thaw?: number;
hold?: number;
sparkle?: number;
frostColor?: string;
baseColor?: string;
paused?: boolean;
reducedMotion?: boolean;
className?: string;
}
const CELL = 2;
const STEP = 1.15;
const RATE = 4000;
const OVERHANG = 3;
const CROSSINGS = 2;
const JITTER = 0.18;
const STEER = 0.42;
const RELIEF = 3;
const CATCHUP_MAX = 90_000;
interface Tip {
x: number;
y: number;
dir: number;
out: number;
}
const Rime = memo(
({
text = "RIME",
mode = "loop",
growth = 1,
branching = 0.55,
density = 0.7,
seeds = 14,
thaw = 1,
hold = 1.5,
sparkle = 0.35,
frostColor = "#d6ecff",
baseColor = "#e9e6f2",
paused = false,
reducedMotion = false,
className,
}: RimeProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawRef = useRef<((dt: number) => void | false) | null>(null);
const measureRef = useRef<((m: Metrics) => void) | null>(null);
const engaged = useRef(false);
const loop = useAnimationLoop({
target: containerRef,
halted: paused || reducedMotion,
dpr: "auto",
onResize: (metrics) => measureRef.current?.(metrics),
onFrame: ({ dt }) => (drawRef.current ? drawRef.current(dt) : false),
});
const live = useRef({
mode, growth, branching, density, seeds, thaw, hold, sparkle,
frostColor, baseColor, reducedMotion,
});
live.current = {
mode, growth, branching, density, seeds, thaw, hold, sparkle,
frostColor, baseColor, reducedMotion,
};
useEffect(() => {
const container = containerRef.current;
const span = textRef.current;
const canvas = canvasRef.current;
if (!container || !span || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const frost = document.createElement("canvas");
const fctx = frost.getContext("2d");
if (!fctx) return;
const mask = document.createElement("canvas");
let gw = 0;
let gh = 0;
let cover: Uint8Array = new Uint8Array(0);
let taken: Uint8Array = new Uint8Array(0);
let edges: Int32Array = new Int32Array(0);
let edgeCount = 0;
let tips: Tip[] = [];
let glints: number[] = [];
let steps = 0;
let width = 0;
let height = 0;
let dprNow = 1;
let elapsed = 0;
let melt = 0;
let held = 0;
let seeded = false;
let painted = "";
let rngState = 0x2f6e2b1 >>> 0;
const rnd = () => {
rngState ^= rngState << 13;
rngState ^= rngState >>> 17;
rngState ^= rngState << 5;
rngState >>>= 0;
return rngState / 4294967296;
};
const rasterMask = () => {
const outer = container.getBoundingClientRect();
const box = span.getBoundingClientRect();
if (box.width < 1 || outer.width < 1) return false;
gw = Math.max(4, Math.ceil(outer.width / CELL));
gh = Math.max(4, Math.ceil(outer.height / CELL));
mask.width = gw;
mask.height = gh;
const mctx = mask.getContext("2d", { willReadFrequently: true });
if (!mctx) return false;
const style = getComputedStyle(span);
const size = parseFloat(style.fontSize) || 16;
const scale = 1 / CELL;
mctx.clearRect(0, 0, gw, gh);
mctx.font = `${style.fontStyle} ${style.fontWeight} ${size * scale}px ${style.fontFamily}`;
if ("letterSpacing" in mctx) {
const sp = style.letterSpacing === "normal" ? 0 : parseFloat(style.letterSpacing) || 0;
mctx.letterSpacing = `${sp * scale}px`;
}
mctx.textAlign = "left";
mctx.textBaseline = "alphabetic";
mctx.fillStyle = "#fff";
const m = mctx.measureText(text);
const asc = m.fontBoundingBoxAscent || size * scale * 0.8;
const desc = m.fontBoundingBoxDescent || size * scale * 0.2;
const lineH = box.height * scale;
const baseline = (box.top - outer.top) * scale + (lineH - (asc + desc)) / 2 + asc;
mctx.fillText(text, (box.left - outer.left) * scale, baseline);
const data = mctx.getImageData(0, 0, gw, gh).data;
const n = gw * gh;
if (cover.length !== n) {
cover = new Uint8Array(n);
taken = new Uint8Array(n);
edges = new Int32Array(n);
}
for (let i = 0; i < n; i++) cover[i] = data[i * 4 + 3];
edgeCount = 0;
for (let y = 1; y < gh - 1; y++) {
for (let x = 1; x < gw - 1; x++) {
const i = y * gw + x;
if (cover[i] < 110) continue;
if (
cover[i - 1] < 110 ||
cover[i + 1] < 110 ||
cover[i - gw] < 110 ||
cover[i + gw] < 110
) {
edges[edgeCount++] = i;
}
}
}
return edgeCount > 0;
};
const reset = () => {
taken.fill(0);
tips = [];
glints = [];
steps = 0;
rngState = 0x2f6e2b1 >>> 0;
fctx.setTransform(1, 0, 0, 1, 0, 0);
fctx.clearRect(0, 0, frost.width, frost.height);
fctx.setTransform(dprNow, 0, 0, dprNow, 0, 0);
const want = Math.max(1, Math.round(live.current.seeds));
for (let s = 0; s < want && edgeCount > 0; s++) {
const i = edges[Math.floor(rnd() * edgeCount)];
const x = i % gw;
const y = Math.floor(i / gw);
const gx =
(cover[i + 1] ?? 0) - (cover[i - 1] ?? 0);
const gy =
(cover[i + gw] ?? 0) - (cover[i - gw] ?? 0);
const dir =
gx === 0 && gy === 0
? rnd() * Math.PI * 2
: Math.atan2(gy, gx) + (rnd() - 0.5) * 1.1;
tips.push({ x, y, dir, out: 0 });
}
seeded = tips.length > 0;
};
const advance = (budget: number) => {
const l = live.current;
if (!tips.length) return;
const cap = Math.max(24, Math.round(l.density * 400));
const FORK = Math.PI / 3;
fctx.strokeStyle = l.frostColor;
fctx.shadowColor = "rgba(4, 12, 24, 0.95)";
fctx.shadowBlur = RELIEF;
fctx.lineWidth = 1;
fctx.lineCap = "round";
fctx.beginPath();
let drawn = 0;
for (let n = 0; n < budget && tips.length; n++) {
const ti = steps % tips.length;
const tip = tips[ti];
tip.dir += (rnd() - 0.5) * JITTER;
const nx = tip.x + Math.cos(tip.dir) * STEP;
const ny = tip.y + Math.sin(tip.dir) * STEP;
steps++;
const gx = Math.round(nx);
const gy = Math.round(ny);
if (gx < 0 || gy < 0 || gx >= gw || gy >= gh) {
tips.splice(ti, 1);
continue;
}
const idx = gy * gw + gx;
if (cover[idx] >= 90) {
tip.out = 0;
} else {
if (++tip.out > OVERHANG) {
tips.splice(ti, 1);
continue;
}
const gx = (cover[idx + 1] ?? 0) - (cover[idx - 1] ?? 0);
const gy = (cover[idx + gw] ?? 0) - (cover[idx - gw] ?? 0);
if (gx !== 0 || gy !== 0) {
let turn = Math.atan2(gy, gx) - tip.dir;
while (turn > Math.PI) turn -= Math.PI * 2;
while (turn < -Math.PI) turn += Math.PI * 2;
tip.dir += turn * STEER;
}
}
if (taken[idx] > CROSSINGS) {
tips.splice(ti, 1);
continue;
}
taken[idx]++;
fctx.moveTo(tip.x * CELL, tip.y * CELL);
fctx.lineTo(nx * CELL, ny * CELL);
drawn++;
tip.x = nx;
tip.y = ny;
if (glints.length < 220 && rnd() < 0.02) {
glints.push(nx * CELL, ny * CELL);
}
if (tips.length < cap && rnd() < l.branching * 0.07) {
tips.push({
x: nx,
y: ny,
dir: tip.dir + (rnd() < 0.5 ? FORK : -FORK),
out: tip.out,
});
}
}
if (drawn) fctx.stroke();
};
measureRef.current = ({ width: w, height: h, dpr, bufferWidth, bufferHeight }) => {
if (w < 1 || h < 1) return;
width = w;
height = h;
dprNow = dpr;
canvas.width = bufferWidth;
canvas.height = bufferHeight;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
frost.width = bufferWidth;
frost.height = bufferHeight;
if (!rasterMask()) return;
const had = steps;
reset();
if (had > 0) advance(Math.min(had, CATCHUP_MAX));
};
drawRef.current = (dt) => {
const l = live.current;
if (width < 1 || !cover.length) return;
if (!seeded && edgeCount > 0) reset();
if (painted !== l.frostColor) {
painted = l.frostColor;
const had = steps;
reset();
if (had > 0) advance(Math.min(had, CATCHUP_MAX));
}
elapsed = (elapsed + dt) % 3600;
const growing = () => advance(Math.round(l.growth * RATE * dt));
const rate = Math.max(l.thaw, 0.05);
const complete = tips.length === 0 && steps > 0;
if (l.reducedMotion) {
if (!complete) advance(CATCHUP_MAX);
melt = 0;
} else if (l.mode === "loop") {
if (!complete) {
growing();
} else if (held < l.hold) {
held += dt;
} else {
melt += rate * dt;
if (melt >= 1) {
melt = 0;
held = 0;
reset();
}
}
} else if (l.mode === "hover") {
if (engaged.current) {
melt = Math.min(1, melt + rate * dt);
} else {
if (melt >= 1) {
melt = 0;
reset();
} else {
melt = Math.max(0, melt - rate * dt);
}
if (!complete) growing();
}
} else {
if (engaged.current) {
melt = Math.max(0, melt - rate * 2 * dt);
if (!complete) growing();
} else {
melt = Math.min(1, melt + rate * dt);
if (melt >= 1 && steps > 0) reset();
}
}
ctx.clearRect(0, 0, width, height);
const opacity = 1 - melt;
if (opacity <= 0.001) return;
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalAlpha = opacity;
ctx.drawImage(frost, 0, 0);
ctx.restore();
if (l.sparkle > 0.01 && glints.length) {
ctx.save();
ctx.globalCompositeOperation = "lighter";
ctx.fillStyle = "#ffffff";
for (let i = 0; i < glints.length; i += 2) {
const phase = i * 1.37;
const tw = Math.sin(elapsed * 2.6 + phase) * 0.5 + 0.5;
const a = Math.pow(tw, 6) * l.sparkle * opacity;
if (a < 0.02) continue;
ctx.globalAlpha = a;
ctx.fillRect(glints[i] - 0.75, glints[i + 1] - 0.75, 1.5, 1.5);
}
ctx.restore();
}
};
let alive = true;
const style = getComputedStyle(span);
const size = parseFloat(style.fontSize) || 16;
Promise.resolve()
.then(() => document.fonts.load(`${style.fontWeight} ${size}px ${style.fontFamily}`))
.then(() => document.fonts.ready)
.then(() => {
if (!alive) return;
loop.resize();
})
.catch(() => {});
loop.resize();
loop.start();
return () => {
alive = false;
drawRef.current = null;
measureRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text]);
const engage = () => {
if (reducedMotion) return;
engaged.current = true;
loop.start();
};
const release = () => {
engaged.current = false;
loop.start();
};
const hover = mode === "hover";
const held = mode === "hold";
return (
<div
ref={containerRef}
className={`relative inline-block ${className ?? "font-display text-[clamp(2.5rem,11vw,6.5rem)] leading-none font-extrabold tracking-tight"}`}
onPointerEnter={hover ? engage : undefined}
onPointerLeave={hover || held ? release : undefined}
onPointerDown={held ? engage : undefined}
onPointerUp={held ? release : undefined}
onPointerCancel={release}
>
<span
ref={textRef}
className="relative block whitespace-pre"
style={{ color: baseColor }}
>
{text}
</span>
<canvas
ref={canvasRef}
className="pointer-events-none absolute inset-0 block"
/>
</div>
);
},
);
Rime.displayName = "Rime";
export default Rime;