Skip to content

Crumple Notes

Paper notes that crumple into balls and unfold again, creases and all.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/crumple-notes.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/glyph/canvas-loop.ts
    /**
     * The render loop every glyph and dither canvas shares: sizes the canvas to its host at a capped
     * pixel ratio, runs only while the host is on screen, the tab is visible, nothing is paused and
     * the user hasn't asked for reduced motion, and otherwise keeps one up-to-date still frame.
     * Framework-free: start it in an effect and dispose it in the cleanup. Browser only.
     */
    
    export interface CanvasLoopFrame {
      /** Seconds since the previous frame (capped at 0.05), or 0 for a redraw without advancing. */
      dt: number;
      /** Seconds the loop has been running. */
      time: number;
      /** True under reduced motion: draw a calm, finished-looking frame. */
      still: boolean;
      /** Host size in CSS pixels. */
      width: number;
      height: number;
      /** Device pixel ratio of the canvas (capped). */
      dpr: number;
    }
    
    export interface CanvasLoopOptions {
      /** Element observed for size and visibility. */
      host: HTMLElement;
      /** Canvas sized to the host times the pixel ratio. */
      canvas: HTMLCanvasElement;
      /** Advance by `frame.dt` and draw. Called every animation frame while running, and with dt 0 after resizes and redraws. */
      render: (frame: CanvasLoopFrame) => void;
      /** Called after the canvas is resized, before it is redrawn. */
      resize?: (frame: CanvasLoopFrame) => void;
      /** Read before every frame; while it returns true the loop stops and the last frame stays. */
      isPaused?: () => boolean;
      /** Highest device pixel ratio used. @default 2 */
      maxDpr?: number;
      /** Margin around the viewport that still counts as on screen. @default "120px" */
      rootMargin?: string;
    }
    
    export interface CanvasLoop {
      /** Re-checks paused, visibility and reduced motion, and starts or stops the loop. Call it when props change. */
      wake(): void;
      /** Draws one frame without advancing (no-op while the loop is running, since the next frame will). */
      redraw(): void;
      /** Stops the loop and removes every observer and listener. */
      dispose(): void;
    }
    
    export function runCanvasLoop(options: CanvasLoopOptions): CanvasLoop {
      const { host, canvas, rootMargin = "120px" } = options;
      const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
      let width = 0;
      let height = 0;
      let dpr = 1;
      let raf = 0;
      let last = 0;
      let time = 0;
      let inView = false;
      let disposed = false;
    
      const frame = (dt: number): CanvasLoopFrame => ({ dt, time, still: reducedMotion.matches, width, height, dpr });
      const shouldRun = () => !disposed && inView && !document.hidden && !reducedMotion.matches && !options.isPaused?.();
    
      const tick = (now: number) => {
        raf = 0;
        if (!shouldRun()) return;
        const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
        last = now;
        time += dt;
        options.render(frame(dt));
        raf = requestAnimationFrame(tick);
      };
    
      const wake = () => {
        if (disposed) return;
        if (shouldRun()) {
          if (!raf) {
            last = 0;
            raf = requestAnimationFrame(tick);
          }
          return;
        }
        if (raf) cancelAnimationFrame(raf);
        raf = 0;
      };
    
      const redraw = () => {
        if (!disposed && !raf && width > 0) options.render(frame(0));
      };
    
      const resize = () => {
        width = Math.max(1, host.clientWidth);
        height = Math.max(1, host.clientHeight);
        dpr = Math.min(window.devicePixelRatio || 1, options.maxDpr ?? 2);
        const w = Math.max(1, Math.round(width * dpr));
        const h = Math.max(1, Math.round(height * dpr));
        if (canvas.width !== w || canvas.height !== h) {
          canvas.width = w;
          canvas.height = h;
        }
        options.resize?.(frame(0));
        // Resizing clears the canvas, so repaint right away even while running.
        options.render(frame(0));
      };
    
      const resizeObserver = new ResizeObserver(resize);
      const intersection = new IntersectionObserver(
        ([entry]) => {
          inView = entry?.isIntersecting ?? true;
          wake();
        },
        { rootMargin },
      );
      const onVisibility = () => wake();
      const onMotionChange = () => {
        wake();
        redraw();
      };
    
      resize();
      resizeObserver.observe(host);
      intersection.observe(host);
      document.addEventListener("visibilitychange", onVisibility);
      reducedMotion.addEventListener("change", onMotionChange);
    
      return {
        wake,
        redraw,
        dispose() {
          disposed = true;
          if (raf) cancelAnimationFrame(raf);
          raf = 0;
          resizeObserver.disconnect();
          intersection.disconnect();
          document.removeEventListener("visibilitychange", onVisibility);
          reducedMotion.removeEventListener("change", onMotionChange);
        },
      };
    }
    lib/html-canvas/gl.ts
    /**
     * Small WebGL2 helpers shared by the HTML-in-Canvas components. Shaders work in the canvas's CSS
     * pixels, so a DOMMatrix used to draw an element is the same one passed to `syncElementGeometry`.
     */
    
    /**
     * GLSL: projects a CSS-px position (already multiplied by the draw matrix, so it may carry a
     * perspective w) to clip space. Keeps w for perspective-correct texturing. Needs `uniform vec2 u_canvas`.
     */
    export const PROJECT_GLSL = /* glsl */ `
    uniform vec2 u_canvas;
    vec4 projectCss(vec4 q) {
      return vec4(2.0 * q.x / u_canvas.x - q.w, -(2.0 * q.y / u_canvas.y - q.w), 0.0, q.w);
    }`;
    
    /** GLSL: signed distance to a rounded rectangle centered at the origin (half size `b`, radius `r`). */
    export const ROUND_RECT_GLSL = /* glsl */ `
    float sdRoundRect(vec2 p, vec2 b, float r) {
      vec2 q = abs(p) - b + r;
      return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
    }`;
    
    function compile(gl: WebGL2RenderingContext, type: number, source: string) {
      const shader = gl.createShader(type);
      if (!shader) throw new Error("Could not create a shader");
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS) && !gl.isContextLost()) {
        const log = gl.getShaderInfoLog(shader);
        gl.deleteShader(shader);
        throw new Error(`Shader compile failed: ${log}`);
      }
      return shader;
    }
    
    export interface Program {
      program: WebGLProgram;
      /** Cached uniform location lookup. */
      u: (name: string) => WebGLUniformLocation | null;
    }
    
    /** Compiles and links a program. Attribute 0 is always `a_pos`. */
    export function createProgram(gl: WebGL2RenderingContext, vertex: string, fragment: string): Program {
      const program = gl.createProgram();
      if (!program) throw new Error("Could not create a program");
      gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, vertex));
      gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, fragment));
      gl.bindAttribLocation(program, 0, "a_pos");
      gl.linkProgram(program);
      if (!gl.getProgramParameter(program, gl.LINK_STATUS) && !gl.isContextLost()) throw new Error(`Program link failed: ${gl.getProgramInfoLog(program)}`);
      const cache = new Map<string, WebGLUniformLocation | null>();
      return {
        program,
        u: (name) => {
          if (!cache.has(name)) cache.set(name, gl.getUniformLocation(program, name));
          return cache.get(name) ?? null;
        },
      };
    }
    
    export interface Mesh {
      vao: WebGLVertexArrayObject;
      count: number;
    }
    
    /** A unit grid over [0, 1]² as indexed triangles, in attribute 0. One cell is a plain quad. */
    export function createGrid(gl: WebGL2RenderingContext, cols = 1, rows = 1): Mesh {
      const vao = gl.createVertexArray();
      if (!vao) throw new Error("Could not create a vertex array");
      gl.bindVertexArray(vao);
      const positions: number[] = [];
      for (let y = 0; y <= rows; y++) for (let x = 0; x <= cols; x++) positions.push(x / cols, y / rows);
      const indices: number[] = [];
      for (let y = 0; y < rows; y++) {
        for (let x = 0; x < cols; x++) {
          const i = y * (cols + 1) + x;
          indices.push(i, i + 1, i + cols + 1, i + 1, i + cols + 2, i + cols + 1);
        }
      }
      const vbo = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
      gl.enableVertexAttribArray(0);
      gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
      const ibo = gl.createBuffer();
      gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
      gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(indices), gl.STATIC_DRAW);
      gl.bindVertexArray(null);
      return { vao, count: indices.length };
    }
    
    /** Draws a mesh from `createGrid`. */
    export function drawMesh(gl: WebGL2RenderingContext, mesh: Mesh, instances = 0) {
      gl.bindVertexArray(mesh.vao);
      if (instances > 0) gl.drawElementsInstanced(gl.TRIANGLES, mesh.count, gl.UNSIGNED_INT, 0, instances);
      else gl.drawElements(gl.TRIANGLES, mesh.count, gl.UNSIGNED_INT, 0);
      gl.bindVertexArray(null);
    }
    
    /** Uploads a DOMMatrix to a mat4 uniform. */
    export function setMatrix(gl: WebGL2RenderingContext, location: WebGLUniformLocation | null, matrix: DOMMatrix) {
      gl.uniformMatrix4fv(location, false, matrix.toFloat32Array());
    }
    
    /** Parses "#rrggbb" into 0–1 RGB. */
    export function rgb(hex: string): [number, number, number] {
      const h = hex.replace("#", "");
      const n = Number.parseInt(h.length === 3 ? h.replace(/./g, (c) => c + c) : h, 16) || 0;
      return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
    }
    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
    lib/webgl/noise.glsl.ts
    /**
     * Shared GLSL ES 3.00 snippets. Concatenate the ones a shader needs before its main():
     *
     *   const fragment = `${GLSL_HASH}${GLSL_SIMPLEX}${GLSL_FBM} void main() { … }`;
     *
     * GLSL_SIMPLEX needs GLSL_HASH, and GLSL_FBM needs GLSL_SIMPLEX.
     */
    
    /** Sine-free hashes: stable across GPUs, no visible banding. */
    export const GLSL_HASH = /* glsl */ `
    float hash12(vec2 p) {
      vec3 p3 = fract(vec3(p.xyx) * 0.1031);
      p3 += dot(p3, p3.yzx + 33.33);
      return fract((p3.x + p3.y) * p3.z);
    }
    
    vec2 hash22(vec2 p) {
      vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
      p3 += dot(p3, p3.yzx + 33.33);
      return fract((p3.xx + p3.yz) * p3.zy);
    }
    `;
    
    /** 2D simplex noise in roughly [-1, 1]. */
    export const GLSL_SIMPLEX = /* glsl */ `
    vec2 simplexGradient(vec2 cell) {
      float angle = hash12(cell) * 6.28318530718;
      return vec2(cos(angle), sin(angle));
    }
    
    float snoise(vec2 p) {
      const float F2 = 0.36602540378; // (sqrt(3) - 1) / 2
      const float G2 = 0.21132486540; // (3 - sqrt(3)) / 6
      vec2 cell = floor(p + (p.x + p.y) * F2);
      vec2 a = p - cell + (cell.x + cell.y) * G2;
      vec2 step1 = a.x > a.y ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
      vec2 b = a - step1 + G2;
      vec2 c = a - 1.0 + 2.0 * G2;
      vec3 falloff = max(0.5 - vec3(dot(a, a), dot(b, b), dot(c, c)), 0.0);
      falloff *= falloff;
      falloff *= falloff;
      vec3 contribution = vec3(
        dot(a, simplexGradient(cell)),
        dot(b, simplexGradient(cell + step1)),
        dot(c, simplexGradient(cell + 1.0))
      );
      return 70.0 * dot(falloff, contribution);
    }
    `;
    
    /** Fractal Brownian motion over simplex noise; octaves is clamped to 1–8. Roughly [-1, 1]. */
    export const GLSL_FBM = /* glsl */ `
    float fbm(vec2 p, int octaves) {
      const mat2 rotate = mat2(0.8, 0.6, -0.6, 0.8);
      float sum = 0.0;
      float amplitude = 0.5;
      for (int i = 0; i < 8; i++) {
        if (i >= octaves) break;
        sum += amplitude * snoise(p);
        p = rotate * p * 2.03 + 17.1;
        amplitude *= 0.5;
      }
      return sum;
    }
    `;
    
    /** Ordered (Bayer) dither thresholds in [0, 1) for 2×2, 4×4 and 8×8 matrices. Pass pixel coordinates. */
    export const GLSL_BAYER = /* glsl */ `
    float bayer2(vec2 a) {
      a = floor(a);
      return fract(a.x * 0.5 + a.y * a.y * 0.75);
    }
    
    float bayer4(vec2 a) {
      return bayer2(0.5 * a) * 0.25 + bayer2(a);
    }
    
    float bayer8(vec2 a) {
      return bayer4(0.5 * a) * 0.25 + bayer2(a);
    }
    `;
    
    /** Hash, simplex and fBm together, in dependency order. */
    export const GLSL_NOISE = GLSL_HASH + GLSL_SIMPLEX + GLSL_FBM;
  3. Copy the source

    components/ui/crumple-notes/crumple-notes.tsx
    "use client";
    
    import { useEffect, useId, useRef, useState, type ComponentProps, type CSSProperties, type PointerEvent } from "react";
    import { AnimatePresence, motion } from "motion/react";
    import { cn } from "@/lib/utils";
    import { duration, ease } from "@/lib/motion";
    import { createGrid, createProgram, drawMesh, type Mesh, type Program } from "@/lib/html-canvas/gl";
    import { runCanvasLoop, type CanvasLoop } from "@/lib/glyph/canvas-loop";
    import { GLSL_HASH, GLSL_SIMPLEX } from "@/lib/webgl/noise.glsl";
    
    export interface CrumpleNote {
      id: string;
      title?: string;
      body: string;
      /** Photo for a polaroid-style note. */
      image?: string;
      date?: string;
    }
    
    export interface CrumpleNotesProps extends Omit<ComponentProps<"section">, "children"> {
      /** Notes; the active one lies flat in the middle, the rest are crumpled balls. @default four sample notes */
      notes?: CrumpleNote[];
      /** Controlled active note id. */
      activeId?: string;
      /** Initial active note when uncontrolled. @default the first note */
      defaultActiveId?: string;
      /** Called with the id of the note that unfolds. */
      onActiveChange?: (id: string) => void;
      /** Paper color. @default "#fbf7ee" */
      paperColor?: string;
      /** Ink color for handwriting. @default "#2b3a55" */
      inkColor?: string;
    }
    
    const SAMPLE: CrumpleNote[] = [
      { id: "launch", title: "Launch day", body: "Check the onboarding copy one more time. Bring snacks for the team. Don't touch prod after 4pm.", date: "Mon, 9 Nov" },
      { id: "idea", title: "Idea", body: "What if the hero was a desktop? Icons you can drag, windows you can open. Retro but in our colors.", date: "Thu, 5 Nov" },
      { id: "groceries", title: "Groceries", body: "Oat milk, lemons, coffee beans (the good ones), basil, rice, eggs, something sweet.", date: "Tue, 3 Nov" },
      { id: "call", title: "Call Maya", body: "About the spring presets. Ask if the bouncy one is too much for the progress bar.", date: "Sun, 1 Nov" },
    ];
    
    const ASPECT = 1.22;
    const TEX_W = 512;
    const TEX_H = Math.round(TEX_W * ASPECT);
    const GRID = 48;
    const HAND = "var(--font-hand), 'Bradley Hand', 'Segoe Print', 'Comic Sans MS', cursive";
    
    /* ------------------------------------------------------------------ shaders */
    
    const FOLDS = /* glsl */ `
    vec2 foldDir(float seed, int i) {
      float fi = float(i);
      return normalize(vec2(sin(seed * 1.7 + fi * 2.1), cos(seed * 1.3 + fi * 1.7)));
    }`;
    
    const VERT = /* glsl */ `#version 300 es
    in vec2 a_pos;
    uniform vec2 u_canvas;
    uniform vec2 u_center;
    uniform vec2 u_size;
    uniform float u_scale;
    uniform float u_rot;
    uniform float u_tilt;
    uniform float u_crumple;
    uniform float u_seed;
    out vec2 v_uv;
    out vec3 v_pos;
    ${GLSL_HASH}${GLSL_SIMPLEX}${FOLDS}
    vec3 rotZ(vec3 p, float a) { float c = cos(a), s = sin(a); return vec3(c * p.x - s * p.y, s * p.x + c * p.y, p.z); }
    vec3 rotX(vec3 p, float a) { float c = cos(a), s = sin(a); return vec3(p.x, c * p.y - s * p.z, s * p.y + c * p.z); }
    vec3 rotY(vec3 p, float a) { float c = cos(a), s = sin(a); return vec3(c * p.x + s * p.z, p.y, -s * p.x + c * p.z); }
    void main() {
      vec2 uv = a_pos;
      v_uv = uv;
      vec3 flatPos = vec3((uv - 0.5) * u_size, 0.0);
      // Edges give way first, with a little noise so the crumple isn't perfectly even.
      float edge = length(uv - 0.5) * 1.414;
      float c = clamp(u_crumple * 1.4 - (1.0 - edge) * 0.4 + snoise(uv * 3.0 + u_seed) * 0.08, 0.0, 1.0);
      c = c * c * (3.0 - 2.0 * c);
      // Folding: ridges along a few random lines, strongest midway.
      float folds = 0.0;
      for (int i = 0; i < 4; i++) folds += abs(fract(dot(uv, foldDir(u_seed, i)) * (2.5 + float(i)) + u_seed) - 0.5);
      float side = min(u_size.x, u_size.y);
      vec3 mid = flatPos + vec3(0.0, 0.0, (folds - 1.0) * side * 0.32 * sin(c * 3.14159));
      // The ball: the sheet wrapped onto a lumpy sphere.
      float lon = (uv.x - 0.5) * 6.0;
      float lat = (uv.y - 0.5) * 2.9;
      vec3 ball = vec3(cos(lat) * sin(lon), sin(lat), cos(lat) * cos(lon)) * side * 0.24;
      ball *= 1.0 + snoise(uv * 5.0 + u_seed * 3.1) * 0.2 + snoise(uv * 11.0 - u_seed) * 0.05;
      vec3 p = mix(mix(flatPos, mid, smoothstep(0.0, 0.6, c)), ball, smoothstep(0.35, 1.0, c)) * u_scale;
      p = rotY(rotX(rotZ(p, u_rot), u_tilt * 0.7), u_tilt);
      v_pos = p;
      vec3 world = vec3(u_center, 0.0) + p;
      float persp = 1600.0 / (1600.0 - world.z);
      vec2 sp = (world.xy - u_canvas * 0.5) * persp + u_canvas * 0.5;
      gl_Position = vec4(sp.x / u_canvas.x * 2.0 - 1.0, 1.0 - sp.y / u_canvas.y * 2.0, -world.z / 4000.0, 1.0);
    }`;
    
    const FRAG = /* glsl */ `#version 300 es
    precision highp float;
    in vec2 v_uv;
    in vec3 v_pos;
    uniform sampler2D u_tex;
    uniform float u_crease;
    uniform float u_seed;
    out vec4 outColor;
    ${FOLDS}
    void main() {
      // Facet normals from screen-space derivatives make every fold catch the light.
      vec3 n = normalize(cross(dFdx(v_pos), dFdy(v_pos)));
      if (n.z < 0.0) n = -n;
      float light = 0.64 + 0.42 * max(dot(n, normalize(vec3(-0.45, -0.55, 0.75))), 0.0);
      // Faint crease lines left behind once a note has been crumpled and opened again.
      float creases = 0.0;
      for (int i = 0; i < 4; i++) {
        float x = fract(dot(v_uv, foldDir(u_seed, i)) * (2.5 + float(i)) + u_seed);
        creases += smoothstep(0.006, 0.0, min(abs(x - 0.5), min(x, 1.0 - x)));
      }
      vec4 paper = texture(u_tex, v_uv);
      outColor = vec4(paper.rgb * min(light, 1.04) * (1.0 - min(creases, 1.0) * u_crease * 0.09), 1.0);
    }`;
    
    /* ------------------------------------------------------------------ paper texture */
    
    function wrap(ctx: CanvasRenderingContext2D, text: string, width: number) {
      const words = text.split(/\s+/);
      const lines: string[] = [];
      let line = "";
      for (const word of words) {
        const next = line ? `${line} ${word}` : word;
        if (ctx.measureText(next).width > width && line) {
          lines.push(line);
          line = word;
        } else line = next;
      }
      if (line) lines.push(line);
      return lines;
    }
    
    /** Draws a note the same way the DOM note lays it out, so the swap between them doesn't jump. */
    function paintNote(canvas: HTMLCanvasElement, note: CrumpleNote, paper: string, ink: string, family: string, photo: HTMLImageElement | null) {
      const ctx = canvas.getContext("2d");
      if (!ctx) return;
      const w = canvas.width;
      const h = canvas.height;
      const pad = w * 0.09;
      ctx.fillStyle = note.image ? "#ffffff" : paper;
      ctx.fillRect(0, 0, w, h);
      // Paper grain.
      for (let i = 0; i < 900; i++) {
        ctx.fillStyle = `rgba(80, 60, 30, ${0.015 + ((i * 37) % 7) * 0.004})`;
        ctx.fillRect((i * 97.13) % w, (i * 53.71) % h, 2, 2);
      }
      let y = pad;
      if (note.image) {
        const ih = (w - pad * 2) * 0.72;
        ctx.fillStyle = "#e7e3da";
        ctx.fillRect(pad, pad, w - pad * 2, ih);
        if (photo?.complete && photo.naturalWidth) {
          const s = Math.max((w - pad * 2) / photo.naturalWidth, ih / photo.naturalHeight);
          const dw = photo.naturalWidth * s;
          const dh = photo.naturalHeight * s;
          ctx.save();
          ctx.beginPath();
          ctx.rect(pad, pad, w - pad * 2, ih);
          ctx.clip();
          ctx.drawImage(photo, pad + (w - pad * 2 - dw) / 2, pad + (ih - dh) / 2, dw, dh);
          ctx.restore();
        }
        y = pad + ih + pad * 0.5;
      } else {
        // Ruled lines.
        ctx.strokeStyle = "rgba(37, 99, 235, 0.16)";
        ctx.lineWidth = 2;
        for (let ly = h * 0.24; ly < h - pad; ly += w * 0.088) {
          ctx.beginPath();
          ctx.moveTo(0, ly);
          ctx.lineTo(w, ly);
          ctx.stroke();
        }
        ctx.strokeStyle = "rgba(220, 38, 38, 0.22)";
        ctx.beginPath();
        ctx.moveTo(pad * 0.7, 0);
        ctx.lineTo(pad * 0.7, h);
        ctx.stroke();
      }
      ctx.fillStyle = ink;
      ctx.textBaseline = "top";
      if (note.title) {
        ctx.font = `700 ${w * 0.1}px ${family}`;
        ctx.fillText(note.title, pad, y);
        y += w * 0.13;
      }
      ctx.font = `400 ${w * (note.image ? 0.068 : 0.072)}px ${family}`;
      for (const line of wrap(ctx, note.body, w - pad * 2).slice(0, note.image ? 2 : 8)) {
        ctx.fillText(line, pad, y);
        y += w * 0.088;
      }
      if (note.date) {
        ctx.globalAlpha = 0.6;
        ctx.font = `400 ${w * 0.06}px ${family}`;
        ctx.fillText(note.date, pad, h - pad - w * 0.06);
        ctx.globalAlpha = 1;
      }
    }
    
    /* ------------------------------------------------------------------ creases */
    
    const seedOf = (index: number) => 1.3 + index * 2.17;
    
    /** The fold lines the shader uses (where fract(dot(uv, d) * k + seed) is 0 or ½), as segments in uv space. */
    function creaseSegments(seed: number) {
      const out: [number, number, number, number][] = [];
      for (let i = 0; i < 4; i++) {
        const dx = Math.sin(seed * 1.7 + i * 2.1);
        const dy = Math.cos(seed * 1.3 + i * 1.7);
        const len = Math.hypot(dx, dy) || 1;
        const d = [dx / len, dy / len];
        const k = 2.5 + i;
        for (let n = -20; n <= 20; n++) {
          const c = (n / 2 - seed) / k;
          // Clip the line d·p = c to the unit square.
          const pts: [number, number][] = [];
          if (Math.abs(d[1]) > 1e-6) for (const x of [0, 1]) pts.push([x, (c - d[0] * x) / d[1]]);
          if (Math.abs(d[0]) > 1e-6) for (const y of [0, 1]) pts.push([(c - d[1] * y) / d[0], y]);
          const inside = pts.filter(([x, y]) => x >= -1e-6 && x <= 1 + 1e-6 && y >= -1e-6 && y <= 1 + 1e-6);
          if (inside.length >= 2) out.push([inside[0][0], inside[0][1], inside[1][0], inside[1][1]]);
        }
      }
      return out;
    }
    
    /* ------------------------------------------------------------------ scene */
    
    interface Sheet {
      x: number;
      y: number;
      scale: number;
      rot: number;
      crumple: number;
      crease: number;
      from: { x: number; y: number; scale: number; rot: number; crumple: number };
      to: { x: number; y: number; scale: number; rot: number; crumple: number };
      start: number;
      length: number;
      seed: number;
      texture: WebGLTexture | null;
      dragging: boolean;
    }
    
    const smooth = (t: number) => t * t * (3 - 2 * t);
    
    /**
     * Notes that crumple into paper balls and unfold again. The active note lies flat in the middle,
     * flanked by crumpled balls with handwriting on them; clicking a ball unfolds it while the current
     * note crumples and rolls aside, and throwing the flat note crumples it too. Each sheet is a 48×48
     * WebGL mesh blended from flat, through folds, to a lumpy sphere, lit per facet so the creases read,
     * and faint crease lines stay on a note once it has been crumpled. Without WebGL2 (or under reduced
     * motion) it crossfades between a flat note and CSS paper balls.
     */
    export function CrumpleNotes({ notes = SAMPLE, activeId, defaultActiveId, onActiveChange, paperColor = "#fbf7ee", inkColor = "#2b3a55", className, ...props }: CrumpleNotesProps) {
      const [internal, setInternal] = useState(defaultActiveId ?? notes[0]?.id);
      // Every note but the first one shown starts as a ball, so it keeps creases once unfolded.
      const [firstActive] = useState(activeId ?? defaultActiveId ?? notes[0]?.id);
      const [crumpled, setCrumpled] = useState<ReadonlySet<string>>(() => new Set());
      const active = activeId ?? internal;
      const activeIndex = Math.max(0, notes.findIndex((n) => n.id === active));
      const [mode, setMode] = useState<"idle" | "webgl" | "fallback">("idle");
      const [size, setSize] = useState({ w: 720, h: 480 });
      const stageRef = useRef<HTMLDivElement>(null);
      const canvasRef = useRef<HTMLCanvasElement>(null);
      const noteRef = useRef<HTMLElement>(null);
      const scene = useRef({ sheets: [] as Sheet[], order: [] as number[], loop: null as CanvasLoop | null, time: 0, active: activeIndex, drag: null as null | { x: number; y: number; t: number; vx: number; vy: number; ox: number; oy: number } });
      const liveId = useId();
      const notesKey = notes.map((n) => `${n.id}:${n.title ?? ""}:${n.body}:${n.image ?? ""}:${n.date ?? ""}`).join("|");
    
      const select = (id: string) => {
        if (id === active) return;
        if (active) setCrumpled((prev) => new Set(prev).add(active));
        if (activeId === undefined) setInternal(id);
        onActiveChange?.(id);
      };
    
      /* ---------------------------------------------------------- layout */
    
      const noteW = Math.round(Math.min(280, size.w * (size.w < 520 ? 0.62 : 0.38)));
      const noteH = Math.round(noteW * ASPECT);
      const narrow = size.w < 520;
      const center = { x: size.w / 2, y: narrow ? noteH / 2 + 24 : size.h / 2 - 8 };
      const slots = (narrow
        ? [
            { x: size.w * 0.2, y: noteH + 90 },
            { x: size.w * 0.4, y: noteH + 100 },
            { x: size.w * 0.6, y: noteH + 92 },
            { x: size.w * 0.8, y: noteH + 102 },
          ]
        : (() => {
            // Two balls on each side, stacked, in the space beside the note.
            const side = Math.max(noteW * 0.42, Math.min(noteW * 0.95, (size.w - noteW) / 4 + noteW * 0.42));
            return [
              { x: center.x - noteW / 2 - side * 0.55, y: center.y + noteH * 0.24 },
              { x: center.x + noteW / 2 + side * 0.55, y: center.y + noteH * 0.2 },
              { x: center.x - noteW / 2 - side * 0.7, y: center.y - noteH * 0.2 },
              { x: center.x + noteW / 2 + side * 0.72, y: center.y - noteH * 0.22 },
            ];
          })()
      ).map((s) => ({ x: Math.min(size.w - 40, Math.max(40, s.x)), y: Math.min(size.h - 40, s.y) }));
      // Balls in slot order: the notes after the active one, wrapping around.
      const balls = notes.map((_, i) => i).filter((i) => i !== activeIndex);
      const geometry = useRef({ center, slots, noteW, noteH });
      useEffect(() => {
        geometry.current = { center, slots, noteW, noteH };
      });
    
      useEffect(() => {
        const stage = stageRef.current;
        if (!stage) return;
        const ro = new ResizeObserver(([entry]) => {
          const r = entry?.contentRect;
          if (r) setSize({ w: Math.round(r.width), h: Math.round(r.height) });
        });
        ro.observe(stage);
        return () => ro.disconnect();
      }, []);
    
      /* ---------------------------------------------------------- WebGL */
    
      useEffect(() => {
        const stage = stageRef.current;
        const canvas = canvasRef.current;
        if (!stage || !canvas) return;
        const media = window.matchMedia("(prefers-reduced-motion: reduce)");
        let gl: WebGL2RenderingContext | null = null;
        let program: Program | null = null;
        let mesh: Mesh | null = null;
        let disposed = false;
        let loop: CanvasLoop | null = null;
        const s = scene.current;
        const photos = new Map<string, HTMLImageElement>();
    
        // Decide on the next frame, outside the effect body.
        const boot = requestAnimationFrame(() => {
          if (disposed) return;
          gl = media.matches ? null : canvas.getContext("webgl2", { antialias: true, alpha: true, premultipliedAlpha: true });
          const info = gl?.getExtension("WEBGL_debug_renderer_info");
          const renderer = gl ? String(gl.getParameter(info ? info.UNMASKED_RENDERER_WEBGL : gl.RENDERER)) : "";
          if (!gl || /swiftshader|llvmpipe|softpipe|software/i.test(renderer)) return setMode("fallback");
          try {
            program = createProgram(gl, VERT, FRAG);
            mesh = createGrid(gl, GRID, GRID);
          } catch {
            return setMode("fallback");
          }
          const family = getComputedStyle(noteRef.current ?? stage).fontFamily || "cursive";
          const upload = (sheet: Sheet, note: CrumpleNote) => {
            if (!gl) return;
            const paper = document.createElement("canvas");
            paper.width = TEX_W;
            paper.height = TEX_H;
            paintNote(paper, note, paperColor, inkColor, family, photos.get(note.id) ?? null);
            sheet.texture ??= gl.createTexture();
            gl.bindTexture(gl.TEXTURE_2D, sheet.texture);
            gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, paper);
            gl.generateMipmap(gl.TEXTURE_2D);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
          };
          const g = geometry.current;
          s.active = scene.current.active;
          s.sheets = notes.map((note, i) => {
            const isActive = i === s.active;
            const slot = g.slots[notes.map((_, k) => k).filter((k) => k !== s.active).indexOf(i)] ?? g.center;
            const at = isActive ? { x: g.center.x, y: g.center.y, scale: 1, rot: 0, crumple: 0 } : { x: slot.x, y: slot.y, scale: 0.62, rot: i * 1.3, crumple: 1 };
            return { ...at, crease: isActive ? 0 : 0.8, from: at, to: at, start: 0, length: 0, seed: seedOf(i), texture: null, dragging: false };
          });
          s.sheets.forEach((sheet, i) => upload(sheet, notes[i]));
          // Photos repaint their sheet once loaded.
          notes.forEach((note, i) => {
            if (!note.image) return;
            const img = new Image();
            img.decoding = "async";
            img.onload = () => !disposed && upload(s.sheets[i], note);
            img.src = note.image;
            photos.set(note.id, img);
          });
          // Repaint once the handwriting font arrives.
          document.fonts?.ready.then(() => !disposed && s.sheets.forEach((sheet, i) => upload(sheet, notes[i])));
          setMode("webgl");
    
          loop = runCanvasLoop({
            host: stage,
            canvas,
            resize: (frame) => gl?.viewport(0, 0, Math.round(frame.width * frame.dpr), Math.round(frame.height * frame.dpr)),
            render: (frame) => {
              if (!gl || !program || !mesh) return;
              s.time = frame.time;
              const now = frame.time;
              const geo = geometry.current;
              // Advance tweens.
              let settled = true;
              s.sheets.forEach((sheet) => {
                if (sheet.dragging) return;
                const t = sheet.length ? Math.min(1, (now - sheet.start) / sheet.length) : 1;
                const k = smooth(t);
                sheet.x = sheet.from.x + (sheet.to.x - sheet.from.x) * k;
                sheet.y = sheet.from.y + (sheet.to.y - sheet.from.y) * k;
                sheet.scale = sheet.from.scale + (sheet.to.scale - sheet.from.scale) * k;
                sheet.rot = sheet.from.rot + (sheet.to.rot - sheet.from.rot) * k;
                sheet.crumple = sheet.from.crumple + (sheet.to.crumple - sheet.from.crumple) * Math.min(1, t * 1.15);
                if (t < 1) settled = false;
              });
              // The DOM note covers the flat sheet while nothing moves.
              const flat = s.sheets[s.active];
              const showNote = settled && !s.drag && flat && flat.crumple < 0.01;
              if (noteRef.current) noteRef.current.style.opacity = showNote ? "1" : "0";
              gl.clearColor(0, 0, 0, 0);
              gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
              gl.enable(gl.DEPTH_TEST);
              gl.useProgram(program.program);
              gl.uniform2f(program.u("u_canvas"), frame.width, frame.height);
              gl.uniform2f(program.u("u_size"), geo.noteW, geo.noteH);
              gl.activeTexture(gl.TEXTURE0);
              gl.uniform1i(program.u("u_tex"), 0);
              s.sheets.forEach((sheet, i) => {
                if (showNote && i === s.active) return;
                const wobble = frame.still ? 0 : Math.sin(now * 0.9 + sheet.seed) * (sheet.crumple > 0.5 ? 0.06 : 0.025);
                gl!.bindTexture(gl!.TEXTURE_2D, sheet.texture);
                gl!.uniform2f(program!.u("u_center"), sheet.x, sheet.y);
                gl!.uniform1f(program!.u("u_scale"), sheet.scale);
                gl!.uniform1f(program!.u("u_rot"), sheet.rot);
                gl!.uniform1f(program!.u("u_tilt"), wobble);
                gl!.uniform1f(program!.u("u_crumple"), sheet.crumple);
                gl!.uniform1f(program!.u("u_seed"), sheet.seed);
                gl!.uniform1f(program!.u("u_crease"), sheet.crease);
                drawMesh(gl!, mesh!);
              });
              // Contact shadows follow the sheets.
              stage.querySelectorAll<HTMLElement>("[data-shadow]").forEach((el, i) => {
                const sheet = s.sheets[i];
                if (!sheet) return;
                const w = (sheet.crumple > 0.5 ? geo.noteW * 0.5 : geo.noteW * 0.92) * sheet.scale;
                // A fixed width (a no-op write once set) scaled per frame, so following the sheet stays off layout.
                const base = geo.noteW * 0.92;
                el.style.width = `${base.toFixed(1)}px`;
                el.style.transform = `translate3d(${(sheet.x - w / 2).toFixed(1)}px, ${(sheet.y + (sheet.crumple > 0.5 ? geo.noteW * 0.2 : geo.noteH * 0.47) * sheet.scale).toFixed(1)}px, 0) scaleX(${(w / base).toFixed(3)})`;
              });
            },
          });
          s.loop = loop;
        });
    
        return () => {
          disposed = true;
          cancelAnimationFrame(boot);
          loop?.dispose();
          s.loop = null;
          const ctx = gl as WebGL2RenderingContext | null;
          if (ctx) {
            s.sheets.forEach((sheet) => sheet.texture && ctx.deleteTexture(sheet.texture));
            if (program) ctx.deleteProgram(program.program);
          }
          s.sheets = [];
        };
        // The scene is rebuilt only when the notes or colors change; `active` moves it via the effect below.
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [notesKey, paperColor, inkColor]);
    
      // A new active note: it unfolds into the middle while the old one crumples into its slot.
      useEffect(() => {
        const s = scene.current;
        const prev = s.active;
        s.active = activeIndex;
        if (!s.sheets.length || prev === activeIndex) return;
        const g = geometry.current;
        const now = s.time;
        const order = notes.map((_, k) => k).filter((k) => k !== activeIndex);
        s.sheets.forEach((sheet, i) => {
          const slot = g.slots[order.indexOf(i)] ?? g.center;
          const isActive = i === activeIndex;
          sheet.from = { x: sheet.x, y: sheet.y, scale: sheet.scale, rot: sheet.rot, crumple: sheet.crumple };
          const roll = i === prev ? Math.sign(slot.x - sheet.x || 1) * 4 : 0;
          sheet.to = isActive ? { x: g.center.x, y: g.center.y, scale: 1, rot: 0, crumple: 0 } : { x: slot.x, y: slot.y, scale: 0.62, rot: sheet.rot + roll, crumple: 1 };
          sheet.start = now;
          sheet.length = i === prev || isActive ? 0.95 : 0.6;
          if (i === prev) sheet.crease = Math.max(sheet.crease, 0.8);
        });
        s.loop?.redraw();
      }, [activeIndex, notes]);
    
      // Positions follow resizes.
      useEffect(() => {
        const s = scene.current;
        const order = notes.map((_, k) => k).filter((k) => k !== s.active);
        s.sheets.forEach((sheet, i) => {
          if (sheet.length && s.time - sheet.start < sheet.length) return;
          const slot = i === s.active ? center : (slots[order.indexOf(i)] ?? center);
          const at = { x: slot.x, y: slot.y, scale: i === s.active ? 1 : 0.62, rot: sheet.rot, crumple: i === s.active ? 0 : 1 };
          Object.assign(sheet, at, { from: at, to: at });
        });
        s.loop?.redraw();
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [size.w, size.h]);
    
      /* ---------------------------------------------------------- throwing the flat note */
    
      const onNoteDown = (event: PointerEvent<HTMLElement>) => {
        if (mode !== "webgl" || event.button !== 0 || (event.target as HTMLElement).closest("a, button")) return;
        scene.current.drag = { x: event.clientX, y: event.clientY, t: event.timeStamp, vx: 0, vy: 0, ox: event.clientX, oy: event.clientY };
      };
      const onNoteMove = (event: PointerEvent<HTMLElement>) => {
        const s = scene.current;
        const d = s.drag;
        const sheet = s.sheets[s.active];
        if (!d || !sheet) return;
        if (!sheet.dragging && Math.hypot(event.clientX - d.ox, event.clientY - d.oy) > 6) {
          sheet.dragging = true;
          event.currentTarget.setPointerCapture(event.pointerId);
        }
        if (!sheet.dragging) return;
        const dt = Math.max(1, event.timeStamp - d.t) / 1000;
        d.vx = (event.clientX - d.x) / dt;
        d.vy = (event.clientY - d.y) / dt;
        d.x = event.clientX;
        d.y = event.clientY;
        d.t = event.timeStamp;
        const g = geometry.current;
        sheet.x = g.center.x + (event.clientX - d.ox);
        sheet.y = g.center.y + (event.clientY - d.oy);
        sheet.rot = (event.clientX - d.ox) * 0.0015;
        sheet.crumple = Math.min(0.25, Math.hypot(d.vx, d.vy) / 8000);
        s.loop?.redraw();
      };
      const onNoteUp = () => {
        const s = scene.current;
        const d = s.drag;
        s.drag = null;
        const sheet = s.sheets[s.active];
        if (!d || !sheet?.dragging) return;
        sheet.dragging = false;
        const g = geometry.current;
        const thrown = Math.hypot(d.vx, d.vy) > 900 || Math.hypot(sheet.x - g.center.x, sheet.y - g.center.y) > g.noteW * 0.55;
        if (thrown && notes.length > 1) {
          // Crumple it and bring up the next note.
          select(notes[(activeIndex + 1) % notes.length].id);
          return;
        }
        sheet.from = { x: sheet.x, y: sheet.y, scale: sheet.scale, rot: sheet.rot, crumple: sheet.crumple };
        sheet.to = { x: g.center.x, y: g.center.y, scale: 1, rot: 0, crumple: 0 };
        sheet.start = s.time;
        sheet.length = 0.45;
      };
    
      /* ---------------------------------------------------------- render */
    
      const current = notes[activeIndex];
      const paperStyle = { "--paper": paperColor, "--ink": inkColor, fontFamily: HAND } as CSSProperties;
    
      return (
        <section aria-label="Notes" className={cn("@container relative h-[480px] w-full overflow-hidden rounded-2xl bg-[#efece6]", className)} style={paperStyle} {...props}>
          <div ref={stageRef} className="absolute inset-0">
            {/* Contact shadows (moved by the WebGL loop). */}
            {mode === "webgl" ? notes.map((n) => <span key={n.id} data-shadow aria-hidden="true" className="pointer-events-none absolute top-0 left-0 h-5 origin-left rounded-[50%] bg-black/25 blur-md" />) : null}
            <canvas ref={canvasRef} aria-hidden="true" className={cn("pointer-events-none absolute inset-0 size-full transition-opacity duration-300", mode === "webgl" ? "opacity-100" : "opacity-0")} />
    
            {/* The flat note, as real text. */}
            <AnimatePresence mode="popLayout" initial={false}>
              {current ? (
                <motion.article
                  key={mode === "webgl" ? "note" : current.id}
                  ref={(el) => {
                    // Two notes can overlap while one exits; keep pointing at the one on screen.
                    if (el) noteRef.current = el;
                    else if (noteRef.current && !noteRef.current.isConnected) noteRef.current = null;
                  }}
                  aria-labelledby={`${liveId}-title`}
                  initial={mode === "webgl" ? false : { opacity: 0 }}
                  animate={{ opacity: 1 }}
                  exit={{ opacity: 0 }}
                  transition={{ duration: duration.base, ease: ease.out }}
                  onPointerDown={onNoteDown}
                  onPointerMove={onNoteMove}
                  onPointerUp={onNoteUp}
                  onPointerCancel={onNoteUp}
                  className={cn(
                    "absolute flex touch-none flex-col overflow-hidden text-[var(--ink)] shadow-[0_18px_30px_-16px_rgb(0_0_0/0.35)] select-none",
                    mode === "webgl" ? "cursor-grab transition-opacity duration-150 active:cursor-grabbing" : "",
                    current.image ? "bg-white" : "bg-[var(--paper)]",
                  )}
                  style={{
                    left: center.x - noteW / 2,
                    top: center.y - noteH / 2,
                    width: noteW,
                    height: noteH,
                    padding: noteW * 0.09,
                    // Same ruled lines and margin as the WebGL texture, so swapping between them doesn't jump.
                    ...(current.image
                      ? {}
                      : {
                          backgroundImage: `linear-gradient(to right, transparent ${noteW * 0.063 - 1}px, rgb(220 38 38 / 0.22) ${noteW * 0.063 - 1}px, rgb(220 38 38 / 0.22) ${noteW * 0.063}px, transparent ${noteW * 0.063}px), repeating-linear-gradient(to bottom, transparent 0, transparent ${noteW * 0.088 - 1}px, rgb(37 99 235 / 0.16) ${noteW * 0.088 - 1}px, rgb(37 99 235 / 0.16) ${noteW * 0.088}px)`,
                          backgroundPosition: `0 0, 0 ${noteH * 0.24 - noteW * 0.088 + 1}px`,
                          backgroundRepeat: "no-repeat, repeat-y",
                          backgroundSize: `100% 100%, 100% ${noteW * 0.088}px`,
                        }),
                  }}
                >
                  {current.id !== firstActive || crumpled.has(current.id) ? (
                    <svg aria-hidden="true" viewBox="0 0 1 1" preserveAspectRatio="none" className="pointer-events-none absolute inset-0 size-full">
                      {creaseSegments(seedOf(activeIndex)).map(([x1, y1, x2, y2], k) => (
                        <line key={k} x1={x1} y1={y1} x2={x2} y2={y2} stroke="rgb(60 45 25 / 0.1)" strokeWidth="1" vectorEffect="non-scaling-stroke" />
                      ))}
                    </svg>
                  ) : null}
                  {current.image ? (
                    // eslint-disable-next-line @next/next/no-img-element -- plain img keeps the component framework-agnostic
                    <img src={current.image} alt="" draggable={false} className="relative aspect-[25/18] w-full object-cover" />
                  ) : null}
                  <h3 id={`${liveId}-title`} className="font-bold" style={{ fontSize: noteW * 0.1, lineHeight: `${noteW * 0.13}px`, marginTop: current.image ? noteW * 0.045 : 0 }}>
                    {current.title ?? "Note"}
                  </h3>
                  <p className={current.image ? "line-clamp-2" : "line-clamp-[8]"} style={{ fontSize: noteW * (current.image ? 0.068 : 0.072), lineHeight: `${noteW * 0.088}px` }}>
                    {current.body}
                  </p>
                  {current.date ? (
                    <p className="mt-auto opacity-60" style={{ fontSize: noteW * 0.06 }}>
                      {current.date}
                    </p>
                  ) : null}
                </motion.article>
              ) : null}
            </AnimatePresence>
    
            {/* Crumpled balls: hit targets over the WebGL balls, or CSS paper balls without WebGL. */}
            {balls.map((index, k) => {
              const note = notes[index];
              const slot = slots[k] ?? center;
              const r = Math.round(noteW * 0.2);
              return (
                <button
                  key={note.id}
                  type="button"
                  aria-label={`Unfold note: ${note.title ?? note.body.slice(0, 32)}`}
                  onClick={() => select(note.id)}
                  className={cn(
                    "focus-ring absolute cursor-pointer rounded-full transition-transform hover:scale-105 motion-reduce:transition-none",
                    mode === "webgl" ? "bg-transparent" : "shadow-[0_10px_18px_-8px_rgb(0_0_0/0.45)]",
                  )}
                  style={{
                    left: slot.x - r,
                    top: slot.y - r,
                    width: r * 2,
                    height: r * 2,
                    ...(mode === "webgl"
                      ? {}
                      : {
                          background: `radial-gradient(circle at 34% 30%, #fff 0%, var(--paper) 38%, color-mix(in oklab, var(--paper) 72%, #7a6a55) 100%), var(--paper)`,
                          backgroundBlendMode: "multiply",
                        }),
                  }}
                >
                  {mode === "webgl" ? null : (
                    <span aria-hidden="true" className="absolute inset-0 rounded-full opacity-60 [background:conic-gradient(from_20deg,transparent_0_8%,rgb(0_0_0/0.12)_9%_10%,transparent_11%_30%,rgb(0_0_0/0.1)_31%_32%,transparent_33%_55%,rgb(0_0_0/0.12)_56%_57%,transparent_58%_80%,rgb(0_0_0/0.1)_81%_82%,transparent_83%)]" />
                  )}
                </button>
              );
            })}
          </div>
          <p className="sr-only" aria-live="polite">
            {current ? `Showing note ${activeIndex + 1} of ${notes.length}: ${current.title ?? ""}` : ""}
          </p>
          <p className="pointer-events-none absolute right-4 bottom-3 font-mono text-[11px] text-black/60">Click a paper ball to unfold it · throw the note to crumple it</p>
        </section>
      );
    }

Usage

import { CrumpleNotes } from "@/components/ui/crumple-notes/crumple-notes";

<CrumpleNotes
  notes={[
    { id: "a", title: "Launch day", body: "Check the onboarding copy one more time.", date: "Mon, 9 Nov" },
    { id: "b", title: "Salt flats", body: "First light, no wind.", image: "/photos/salt-flats.webp" },
  ]}
  onActiveChange={(id) => console.log(id)}
/>

API reference

PropTypeDefaultDescription
notesCrumpleNote[]four sample notesNotes; the active one lies flat in the middle, the rest are crumpled balls.
activeIdstring–Controlled active note id.
defaultActiveIdstringthe first noteInitial active note when uncontrolled.
onActiveChange(id: string) => void–Called with the id of the note that unfolds.
paperColorstring"#fbf7ee"Paper color.
inkColorstring"#2b3a55"Ink color for handwriting.

Also accepts every native <section> attribute, including ref and className.

  • Each sheet is a 48×48 grid (`createGrid` from the HTML-in-Canvas GL helpers) textured with the note drawn on a 2D canvas. The vertex shader blends it from flat, through ridges along a few random fold lines, to a lumpy sphere, with the edges giving way first. Normals come from screen-space derivatives, so every fold catches the light, and a note that has been crumpled keeps faint crease lines when it is opened again.
  • Clicking a ball unfolds it into the middle while the current note crumples and rolls into the freed spot. Dragging the flat note follows the pointer; a quick throw (or a long drag) crumples it and brings up the next note.
  • While nothing moves, the flat note on screen is DOM laid out exactly like its texture, so text stays crisp and selectable. Handwriting uses `--font-hand` (Caveat on this site) with a cursive fallback.

Accessibility

  • The flat note is a real article (title, text and date as DOM text), and each paper ball is a button named “Unfold note: …”. A polite live region announces the note that unfolds.
  • Throwing the note is a pointer extra; the ball buttons do the same with a click or the keyboard.
  • Under reduced motion, or without WebGL2 (including software renderers), there is no crumpling: the note crossfades and the balls are drawn with CSS.