"use client";
import { useEffect, useRef } from "react";
import GlyphPortal from "@/components/ui/glyph-portal";
const settings = { word: "TIDE", scrollLength: 2.4, annotations: false };
/** A live canvas, owned by the demo. The portal never copies or rasterizes it. */
function TideField() {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current!;
const context = canvas.getContext("2d");
if (!context) return;
const motion = matchMedia("(prefers-reduced-motion: reduce)");
let frame = 0, visible = false, width = 1, height = 1;
const draw = (time: number) => {
frame = 0;
const t = motion.matches ? 0 : time / 8000;
context.fillStyle = "#092c2c";
context.fillRect(0, 0, width, height);
const glow = context.createRadialGradient(width * .72, height * .2, 0, width * .5, height * .4, width * .8);
glow.addColorStop(0, "#246454"); glow.addColorStop(1, "#082b2c");
context.fillStyle = glow;
context.fillRect(0, 0, width, height);
for (let line = -6; line < 46; line++) {
context.beginPath();
for (let x = -8; x <= width + 8; x += 8) {
const u = x / width;
const bend = Math.sin(u * 5 + t + line * .035) * height * .13 + Math.cos(u * 9 - t * .7) * height * .045;
const y = line * height / 35 + bend;
if (x === -8) context.moveTo(x, y); else context.lineTo(x, y);
}
context.strokeStyle = line % 5 === 0 ? "#94b995" : "#4b8772";
context.globalAlpha = line % 5 === 0 ? .3 : .2;
context.lineWidth = line % 5 === 0 ? 1.2 : .7;
context.stroke();
}
context.globalAlpha = 1;
context.fillStyle = "rgba(0,18,16,.1)";
context.fillRect(0, 0, width, height);
if (visible && !motion.matches && !document.hidden) frame = requestAnimationFrame(draw);
};
const refresh = () => {
cancelAnimationFrame(frame);
const box = canvas.getBoundingClientRect();
width = box.width; height = box.height;
const ratio = Math.min(devicePixelRatio, 2);
canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio);
context.setTransform(ratio, 0, 0, ratio, 0, 0);
draw(performance.now());
};
const resize = new ResizeObserver(refresh);
resize.observe(canvas);
const observer = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; refresh(); });
observer.observe(canvas);
motion.addEventListener("change", refresh);
document.addEventListener("visibilitychange", refresh);
refresh();
return () => {
cancelAnimationFrame(frame); resize.disconnect(); observer.disconnect();
motion.removeEventListener("change", refresh);
document.removeEventListener("visibilitychange", refresh);
};
}, []);
return <canvas ref={ref} style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }} />;
}
export default function CanvasDemo(props: Partial<typeof settings>) {
const s = { ...settings, ...props };
return (
<div data-demo-scroll tabIndex={0} role="region" aria-label="Tide canvas portal demo. Scroll to move through the word."
style={{ width: "100%", height: "min(720px, 100svh)", overflowY: "auto", background: "#fff", containerType: "inline-size" }}>
<GlyphPortal word={s.word} scrollLength={s.scrollLength} annotations={s.annotations}
style={{ "--gp-paper": "#fff", "--gp-ink": "#203e36", "--gp-field": "#092c2c", "--gp-foreground": "#f4f0d7" }} background={<TideField />}>
<div style={{ maxWidth: 850 }}>
<p style={{ color: "inherit", font: "12px/1.5 ui-monospace, monospace", letterSpacing: ".1em", textTransform: "uppercase", margin: "0 0 28px" }}>Live canvas · Tide</p>
<h2 style={{ color: "inherit", fontFamily: "Georgia, serif", fontWeight: 400, fontSize: "clamp(36px, 7cqw, 84px)", lineHeight: 1.02, letterSpacing: "-.045em", margin: "0 0 28px", maxWidth: "14ch", textWrap: "balance" }}>Go with the current.</h2>
<p style={{ color: "inherit", font: "16px/1.65 Arial, sans-serif", maxWidth: "38ch", margin: 0 }}>A canvas keeps drawing behind the letters, throughout the passage, and on the other side.</p>
</div>
</GlyphPortal>
</div>
);
}