NEMAWASHI LAB

Gradients

Canvas

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.

DAWN

1 / 7

Click anywhere

Source

GradientCanvas.tsx220 lines
"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>
  );
}
palettes.ts87 lines
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 },
    ],
  },
];

NEMAWASHI — Kotaro Abe