Full-viewport vertical CSS gradients rendered pixel-by-pixel on a canvas with additive film grain: a Float32Array of per-pixel random offsets is generated once and reused across palette transitions so the grain texture stays locked to the screen rather than crawling. Clicking cross-fades between seven named palettes (Dawn, Dusk, Ocean, Ember, Arctic, Moss, Bruise) over 1200 ms with easeInOutCubic, interpolating every pixel's RGB channel from the old gradient to the new before re-applying the same noise map.
Source
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { motion } from "framer-motion";
import ScrambleLink from "../ScrambleLink";
import { palettes, type GradientPalette } from "./palettes";
const GRAIN_INTENSITY = 35;
// ---------------------------------------------------------------------------
// Generate a static noise map (one value per pixel, reused across palettes)
// ---------------------------------------------------------------------------
function generateNoise(pixelCount: number): Float32Array {
const noise = new Float32Array(pixelCount);
for (let i = 0; i < pixelCount; i++) {
noise[i] = (Math.random() - 0.5) * GRAIN_INTENSITY * 2;
}
return noise;
}
// ---------------------------------------------------------------------------
// Render a clean gradient (no grain) into pixel data
// ---------------------------------------------------------------------------
function renderGradient(
palette: GradientPalette,
w: number,
h: number,
): ImageData {
const offscreen = document.createElement("canvas");
offscreen.width = w;
offscreen.height = h;
const ctx = offscreen.getContext("2d")!;
const gradient = ctx.createLinearGradient(0, 0, 0, h);
for (const stop of palette.stops) {
gradient.addColorStop(stop.position / 100, stop.color);
}
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, w, h);
return ctx.getImageData(0, 0, w, h);
}
// ---------------------------------------------------------------------------
// Apply pre-computed noise to gradient ImageData and write to output
// ---------------------------------------------------------------------------
function applyGrain(
src: Uint8ClampedArray,
out: Uint8ClampedArray,
noise: Float32Array,
) {
for (let i = 0, p = 0; i < src.length; i += 4, p++) {
const n = noise[p];
out[i] = Math.min(255, Math.max(0, src[i] + n));
out[i + 1] = Math.min(255, Math.max(0, src[i + 1] + n));
out[i + 2] = Math.min(255, Math.max(0, src[i + 2] + n));
out[i + 3] = 255;
}
}
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function GradientCanvas() {
const [paletteIndex, setPaletteIndex] = useState(0);
const canvasRef = useRef<HTMLCanvasElement>(null);
const animatingRef = useRef(false);
const rafRef = useRef<number>(0);
const noiseRef = useRef<Float32Array | null>(null);
const palette = palettes[paletteIndex];
// Draw initial gradient + generate noise on first render / resize
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = Math.round(window.innerWidth * dpr);
const h = Math.round(window.innerHeight * dpr);
canvas.width = w;
canvas.height = h;
canvas.style.width = `${window.innerWidth}px`;
canvas.style.height = `${window.innerHeight}px`;
const pixelCount = w * h;
if (!noiseRef.current || noiseRef.current.length !== pixelCount) {
noiseRef.current = generateNoise(pixelCount);
}
const gradientData = renderGradient(palette, w, h);
const outData = ctx.createImageData(w, h);
applyGrain(gradientData.data, outData.data, noiseRef.current);
ctx.putImageData(outData, 0, 0);
}, [palette]);
// Handle resize — regenerate noise at new size
useEffect(() => {
const handleResize = () => {
noiseRef.current = null;
setPaletteIndex((prev) => prev);
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
// Cleanup RAF on unmount
useEffect(() => {
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, []);
const handleClick = useCallback(() => {
if (animatingRef.current) return;
animatingRef.current = true;
const canvas = canvasRef.current;
if (!canvas) { animatingRef.current = false; return; }
const maybeCtx = canvas.getContext("2d");
if (!maybeCtx) { animatingRef.current = false; return; }
const ctx = maybeCtx;
const w = canvas.width;
const h = canvas.height;
const noise = noiseRef.current;
if (!noise) { animatingRef.current = false; return; }
const fromIndex = paletteIndex;
const toIndex = (fromIndex + 1) % palettes.length;
const fromPx = renderGradient(palettes[fromIndex], w, h).data;
const toPx = renderGradient(palettes[toIndex], w, h).data;
const outData = ctx.createImageData(w, h);
const outPx = outData.data;
const duration = 1200;
const start = performance.now();
function step(now: number) {
const rawT = Math.min((now - start) / duration, 1);
const t = easeInOutCubic(rawT);
for (let i = 0, p = 0; i < fromPx.length; i += 4, p++) {
const r = fromPx[i] + (toPx[i] - fromPx[i]) * t;
const g = fromPx[i + 1] + (toPx[i + 1] - fromPx[i + 1]) * t;
const b = fromPx[i + 2] + (toPx[i + 2] - fromPx[i + 2]) * t;
const n = noise![p];
outPx[i] = Math.min(255, Math.max(0, r + n));
outPx[i + 1] = Math.min(255, Math.max(0, g + n));
outPx[i + 2] = Math.min(255, Math.max(0, b + n));
outPx[i + 3] = 255;
}
ctx.putImageData(outData, 0, 0);
if (rawT < 1) {
rafRef.current = requestAnimationFrame(step);
} else {
setPaletteIndex(toIndex);
animatingRef.current = false;
}
}
rafRef.current = requestAnimationFrame(step);
}, [paletteIndex]);
return (
<div
className="fixed inset-0 cursor-pointer select-none"
onClick={handleClick}
>
<canvas ref={canvasRef} className="absolute inset-0" />
{/* UI overlay */}
<div className="fixed bottom-8 left-8 pointer-events-none font-[family-name:var(--font-flux)]" style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}>
<motion.div
key={palette.name}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<p className="text-[12px] tracking-[0.08em] text-white/50 uppercase mix-blend-difference">
{palette.name}
</p>
<p className="text-[12px] tracking-[0.2em] text-white/30 mt-1 mix-blend-difference">
{paletteIndex + 1} / {palettes.length}
</p>
</motion.div>
</div>
<div className="fixed bottom-8 right-8 pointer-events-none font-[family-name:var(--font-flux)]" style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}>
<p className="text-[12px] tracking-[0.2em] text-white/30 uppercase mix-blend-difference">
Click anywhere
</p>
</div>
<div className="fixed top-8 left-8 z-10">
<ScrambleLink
from="NEMAWASHI LAB"
to="← BACK"
href="/lab"
className="text-[14px] tracking-[0.08em] text-white/50 uppercase mix-blend-difference pointer-events-auto font-[family-name:var(--font-flux)]"
style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
/>
</div>
</div>
);
}
export interface GradientPalette {
name: string;
stops: { color: string; position: number }[];
}
export const palettes: GradientPalette[] = [
{
name: "DAWN",
stops: [
{ color: "#7BA4D4", position: 0 },
{ color: "#A8A0D2", position: 20 },
{ color: "#6DC8C8", position: 40 },
{ color: "#B8D44E", position: 60 },
{ color: "#E8C840", position: 75 },
{ color: "#E07830", position: 90 },
{ color: "#C85020", position: 100 },
],
},
{
name: "DUSK",
stops: [
{ color: "#1a0533", position: 0 },
{ color: "#3d1a6e", position: 20 },
{ color: "#c2185b", position: 45 },
{ color: "#ff6f00", position: 70 },
{ color: "#ffab00", position: 85 },
{ color: "#fff9c4", position: 100 },
],
},
{
name: "OCEAN",
stops: [
{ color: "#0d1b2a", position: 0 },
{ color: "#1b3a4b", position: 25 },
{ color: "#2a6f7a", position: 45 },
{ color: "#40c4aa", position: 65 },
{ color: "#a8e6cf", position: 82 },
{ color: "#dcedc1", position: 100 },
],
},
{
name: "EMBER",
stops: [
{ color: "#1a1a2e", position: 0 },
{ color: "#4a1530", position: 25 },
{ color: "#c0392b", position: 50 },
{ color: "#e67e22", position: 70 },
{ color: "#f39c12", position: 85 },
{ color: "#f1c40f", position: 100 },
],
},
{
name: "ARCTIC",
stops: [
{ color: "#e8e8e8", position: 0 },
{ color: "#b0c4de", position: 20 },
{ color: "#87CEEB", position: 40 },
{ color: "#4682B4", position: 60 },
{ color: "#2c3e6b", position: 80 },
{ color: "#1a1a3e", position: 100 },
],
},
{
name: "MOSS",
stops: [
{ color: "#2d2d1e", position: 0 },
{ color: "#4a5d3a", position: 25 },
{ color: "#7a9a5a", position: 45 },
{ color: "#c4b78a", position: 65 },
{ color: "#e8d5a8", position: 80 },
{ color: "#f5ead6", position: 100 },
],
},
{
name: "BRUISE",
stops: [
{ color: "#0a0a1a", position: 0 },
{ color: "#2d1b4e", position: 20 },
{ color: "#5b3a8c", position: 40 },
{ color: "#8b5fbf", position: 55 },
{ color: "#c49ae0", position: 72 },
{ color: "#e8d4f0", position: 88 },
{ color: "#f5eef8", position: 100 },
],
},
];