# Grain Gradient (`grain-gradient`)

> Three pools of colour drift and breathe across the page under a layer of film grain, mixed into the theme's own background — works on paper and on night.

- **Docs:** https://www.mellowui.com/components/grain-gradient
- **Markdown:** https://www.mellowui.com/components/grain-gradient.md
- **Registry:** https://www.mellowui.com/r/grain-gradient.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/grain-gradient
- **Categories:** background, webgl, animation
- **Dependencies:** none

## AI prompt

Add a GrainGradient background from the mellow library — three drifting pools of colour under film grain, mixed into the theme background. Wrap page content as `children`, and tune `colors` (three "R, G, B" triplets), `speed`, `blend` and `grain`.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/grain-gradient.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `colors` | `[string, string, string]` | `["236, 122, 92", "142, 108, 220", "62, 168, 162"]` | Three pool colours as "R, G, B" triplets. |
| `speed` | `number` | `1` | Drift speed multiplier. |
| `blend` | `number` | `0.55` | How strongly the colours saturate the page, 0–1. |
| `grain` | `number` | `0.05` | Film grain amount, 0–0.2. |
| `children` | `React.ReactNode` | — | Content rendered above the gradient. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useEffect, useRef, useState } from "react";

/** Page background as an "R, G, B" triplet — re-checks on theme change. */
function useBackgroundRgb(): string {
  const [rgb, setRgb] = useState<string>("5, 5, 5");
  useEffect(() => {
    const read = () => {
      const v = getComputedStyle(document.documentElement)
        .getPropertyValue("--background-rgb")
        .trim();
      if (v) setRgb(v);
    };
    read();
    const obs = new MutationObserver(read);
    obs.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "style", "data-theme"],
    });
    return () => obs.disconnect();
  }, []);
  return rgb;
}

export interface GrainGradientProps {
  /** Three pool colours as "R, G, B" triplets. */
  colors?: [string, string, string];
  /** Drift speed multiplier. */
  speed?: number;
  /** How strongly the colours saturate the page, 0–1. */
  blend?: number;
  /** Film grain amount, 0–0.2. */
  grain?: number;
  className?: string;
  style?: React.CSSProperties;
  children?: React.ReactNode;
}

const VERT = `
attribute vec2 a_pos;
void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
`;

const FRAG = `
precision highp float;
uniform vec2 u_res;
uniform float u_t;
uniform vec3 u_bg;
uniform vec3 u_c1;
uniform vec3 u_c2;
uniform vec3 u_c3;
uniform float u_blend;
uniform float u_grain;

float hash(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}

void main() {
  vec2 uv = gl_FragCoord.xy / u_res;
  float aspect = u_res.x / u_res.y;
  vec2 p = vec2(uv.x * aspect, uv.y);

  // three pools of colour drifting on slow orbits
  vec2 b1 = vec2(0.32 * aspect + 0.22 * sin(u_t * 0.21), 0.62 + 0.18 * cos(u_t * 0.17));
  vec2 b2 = vec2(0.72 * aspect + 0.26 * cos(u_t * 0.15), 0.38 + 0.22 * sin(u_t * 0.19));
  vec2 b3 = vec2(0.5 * aspect + 0.3 * sin(u_t * 0.12 + 2.1), 0.2 + 0.2 * cos(u_t * 0.23 + 1.2));

  float w1 = exp(-dot(p - b1, p - b1) * 5.5);
  float w2 = exp(-dot(p - b2, p - b2) * 5.0);
  float w3 = exp(-dot(p - b3, p - b3) * 6.0);

  vec3 col = u_bg;
  col = mix(col, u_c1, clamp(w1, 0.0, 1.0) * u_blend);
  col = mix(col, u_c2, clamp(w2, 0.0, 1.0) * u_blend);
  col = mix(col, u_c3, clamp(w3, 0.0, 1.0) * u_blend);

  // film grain — breaks banding, gives the print feel
  float g = hash(gl_FragCoord.xy + fract(u_t) * 61.7) - 0.5;
  col += g * u_grain;

  gl_FragColor = vec4(col, 1.0);
}
`;

// dusk palette — coral, violet, deep teal
const DEFAULT_COLORS: [string, string, string] = [
  "236, 122, 92",
  "142, 108, 220",
  "62, 168, 162",
];

/**
 * A grain gradient background — three pools of colour drift and breathe
 * across the page under a layer of film grain. The pools mix into the
 * theme's own background, so it works on paper and on night.
 */
export function GrainGradient({
  colors = DEFAULT_COLORS,
  speed = 1,
  blend = 0.55,
  grain = 0.05,
  className,
  style,
  children,
}: GrainGradientProps) {
  const bgRgb = useBackgroundRgb();
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    if (!canvasRef.current) return;
    const canvas: HTMLCanvasElement = canvasRef.current;
    const glOrNull = canvas.getContext("webgl", { alpha: false, antialias: false });
    if (!glOrNull) return; // no WebGL — the theme background shows through
    const gl: WebGLRenderingContext = glOrNull;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const compile = (type: number, src: string) => {
      const sh = gl.createShader(type);
      if (!sh) return null;
      gl.shaderSource(sh, src);
      gl.compileShader(sh);
      if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
        gl.deleteShader(sh);
        return null;
      }
      return sh;
    };
    const vs = compile(gl.VERTEX_SHADER, VERT);
    const fs = compile(gl.FRAGMENT_SHADER, FRAG);
    if (!vs || !fs) return;
    const prog = gl.createProgram();
    if (!prog) return;
    gl.attachShader(prog, vs);
    gl.attachShader(prog, fs);
    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return;
    gl.useProgram(prog);

    const buf = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buf);
    gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array([-1, -1, 3, -1, -1, 3]),
      gl.STATIC_DRAW
    );
    const loc = gl.getAttribLocation(prog, "a_pos");
    gl.enableVertexAttribArray(loc);
    gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);

    const u = (name: string) => gl.getUniformLocation(prog, name);
    const uRes = u("u_res");
    const uT = u("u_t");

    const triplet = (s: string): [number, number, number] => {
      const p = s.split(",").map((n) => parseFloat(n) / 255);
      return [p[0] ?? 0, p[1] ?? 0, p[2] ?? 0];
    };
    gl.uniform3f(u("u_bg"), ...triplet(bgRgb));
    gl.uniform3f(u("u_c1"), ...triplet(colors[0]));
    gl.uniform3f(u("u_c2"), ...triplet(colors[1]));
    gl.uniform3f(u("u_c3"), ...triplet(colors[2]));
    gl.uniform1f(u("u_blend"), blend);
    gl.uniform1f(u("u_grain"), grain);

    let rafId = 0;
    let t = 40.0;
    let last = 0;
    let lost = false;

    function resize() {
      const dpr = Math.min(window.devicePixelRatio, 1.5);
      const rect = canvas.getBoundingClientRect();
      canvas.width = Math.max(1, Math.round(rect.width * dpr));
      canvas.height = Math.max(1, Math.round(rect.height * dpr));
      gl.viewport(0, 0, canvas.width, canvas.height);
    }

    function frame() {
      gl.uniform2f(uRes, canvas.width, canvas.height);
      gl.uniform1f(uT, t);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    }

    function loop(now: number) {
      if (lost) return;
      const dt = Math.min((now - last) / 1000, 1 / 20);
      last = now;
      t += dt * speed;
      frame();
      rafId = requestAnimationFrame(loop);
    }

    const onLost = (e: Event) => {
      e.preventDefault();
      lost = true;
      cancelAnimationFrame(rafId);
    };
    canvas.addEventListener("webglcontextlost", onLost);

    const ro = new ResizeObserver(() => {
      resize();
      if (reduced) frame();
    });
    ro.observe(canvas);
    resize();

    if (reduced) {
      frame();
    } else {
      last = performance.now();
      rafId = requestAnimationFrame(loop);
    }

    return () => {
      cancelAnimationFrame(rafId);
      ro.disconnect();
      canvas.removeEventListener("webglcontextlost", onLost);
      gl.deleteProgram(prog);
      gl.deleteShader(vs);
      gl.deleteShader(fs);
      gl.deleteBuffer(buf);
    };
  }, [bgRgb, colors, speed, blend, grain]);

  return (
    <div
      className={["relative overflow-hidden", className].filter(Boolean).join(" ")}
      style={style}
    >
      <canvas
        ref={canvasRef}
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 h-full w-full"
      />
      {children && <div className="absolute inset-0 z-[1]">{children}</div>}
    </div>
  );
}

export default GrainGradient;

```

## Demo

```tsx
"use client";

import React from "react";
import { GrainGradient } from "../mellow/grain-gradient";

export default function GrainGradientDemo() {
  return (
    <GrainGradient className="h-full min-h-[280px] w-full sm:min-h-[420px]">
      <div className="flex h-full flex-col items-center justify-center gap-4 text-center">
        <p className="[font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.24em] text-[rgba(var(--ink-rgb),0.55)] uppercase">
          Dusk, on 35mm
        </p>
        <h2 className="max-w-md [font-family:var(--font-serif)] text-4xl leading-tight text-[var(--ink)] italic">
          Colour that breathes
        </h2>
        <p className="max-w-sm text-[0.875rem] leading-relaxed text-[rgba(var(--ink-rgb),0.6)]">
          Three pools of colour drift under film grain, mixed into the
          theme's own paper — no two seconds alike.
        </p>
      </div>
    </GrainGradient>
  );
}

```
