# Grainient

A grainy gradient: three colours flow through each other on a slowly warping field, under a heavy film grain that keeps it from banding. WebGL, written in the file.

**Category:** Backgrounds  
**Exports:** `Grainient`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/grainient.json
```
The file lands at `components/ui/grainient.tsx`; import from `@/components/ui/grainient`.

## API — Grainient
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `speed` | `number` | `1` | 0 holds still. |
| `color / color2 / color3` | `string` | — |  |
| `grain` | `number` | `0.4` |  |
| `warp` | `number` | `1` |  |
| `className` | `string` | — |  |

## Example — Grainient

```tsx
import { Grainient } from "@/components/ui/grainient";

export default function GrainientDemo({ speed = 1, grain = 0.4, warp = 1, color = "var(--ember)", color2 = "#8b5cf6", color3 = "#06b6d4" }: { speed?: number; grain?: number; warp?: number; color?: string; color2?: string; color3?: string }) {
  return (
    <div className="relative grid h-80 w-full place-items-center overflow-hidden rounded-xl border bg-background">
      <Grainient speed={speed} grain={grain} warp={warp} color={color} color2={color2} color3={color3} />
      <p className="relative text-center text-2xl font-semibold tracking-tight">Grain and colour</p>
    </div>
  );
}
```

## Source — components/ui/grainient.tsx

```tsx
"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type GrainientProps = {
  /** Animation speed; 0 holds still. */
  speed?: number;
  /** First colour. */
  color?: string;
  /** Second colour. */
  color2?: string;
  /** Third colour. */
  color3?: string;
  /** Grain amount, 0 to 1. */
  grain?: number;
  /** How much the field bends. */
  warp?: number;
  className?: string;
};

const VERT = `attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}`;
const FRAG = `precision highp float;
uniform vec2 uRes;uniform float uTime;uniform float uDpr;uniform vec2 uMouse;uniform vec3 uBg;uniform vec3 uFg;uniform float u_grain;uniform float u_warp;uniform vec3 u_color;uniform vec3 u_color2;uniform vec3 u_color3;
float hash(vec2 p){return fract(sin(dot(p,vec2(12.9898,78.233)))*43758.5453);}
float noise(vec2 p){vec2 i=floor(p),f=fract(p);vec2 u=f*f*(3.-2.*f);return mix(mix(hash(i),hash(i+vec2(1,0)),u.x),mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),u.x),u.y);}
float fbm(vec2 p){float v=0.,a=.5;for(int i=0;i<5;i++){v+=a*noise(p);p=p*2.03+17.1;a*=.5;}return v;}
void main(){
  vec2 p=(gl_FragCoord.xy-.5*uRes)/uRes.y;
  float t=uTime*.15;
  vec2 q=p+u_warp*.4*vec2(fbm(p*1.4+t),fbm(p*1.4-t+4.))-u_warp*.2;
  float a=.5+.5*sin(q.x*2.4+t*2.);
  float b=.5+.5*sin(q.y*2.8-t*1.6+1.);
  vec3 col=mix(u_color,u_color2,smoothstep(.1,.9,a));
  col=mix(col,u_color3,smoothstep(.2,1.,b)*.75);
  col=mix(uBg,col,.88);
  col+=(hash(gl_FragCoord.xy+fract(uTime*7.))-.5)*u_grain*.4;
  gl_FragColor=vec4(col,1.);
}`;

function rgb(el: HTMLElement, value: string, fallback: string) {
  const v = value.trim().match(/^var\((--[\w-]+)\)$/);
  const c = document.createElement("canvas").getContext("2d")!;
  c.fillStyle = fallback;
  c.fillStyle = v ? getComputedStyle(el).getPropertyValue(v[1]).trim() || fallback : value;
  c.fillRect(0, 0, 1, 1);
  const d = c.getImageData(0, 0, 1, 1).data;
  return [d[0] / 255, d[1] / 255, d[2] / 255];
}

function setup(canvas: HTMLCanvasElement) {
  const gl = canvas.getContext("webgl", { antialias: false, preserveDrawingBuffer: false, premultipliedAlpha: false, powerPreference: "low-power" });
  if (!gl) return null;
  const prog = gl.createProgram()!;
  for (const [type, src] of [[gl.VERTEX_SHADER, VERT], [gl.FRAGMENT_SHADER, FRAG]] as const) {
    const s = gl.createShader(type)!;
    gl.shaderSource(s, src);
    gl.compileShader(s);
    gl.attachShader(prog, s);
  }
  gl.linkProgram(prog);
  if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return null;
  gl.useProgram(prog);
  gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
  const loc = gl.getAttribLocation(prog, "p");
  gl.enableVertexAttribArray(loc);
  gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
  const locs = new Map<string, WebGLUniformLocation | null>();
  const u = (name: string) => {
    if (!locs.has(name)) locs.set(name, gl.getUniformLocation(prog, name));
    return locs.get(name)!;
  };
  return { gl, u };
}

/** A grainy gradient: three colours flow through each other on a warped field, under a heavy film grain. WebGL, in the file. */
export function Grainient({ speed = 1, color = "var(--ember)", color2 = "#8b5cf6", color3 = "#06b6d4", grain = 0.4, warp = 1, className }: GrainientProps) {
  const ref = React.useRef<HTMLCanvasElement>(null);
  const reduce = useReducedMotion();
  const [fallback, setFallback] = React.useState(false);
  const live = React.useRef({ speed, color, color2, color3, grain, warp });
  live.current = { speed, color, color2, color3, grain, warp };

  React.useEffect(() => {
    const canvas = ref.current;
    if (!canvas) return;
    const ctx = setup(canvas);
    if (!ctx) return setFallback(true);
    const { gl, u } = ctx;
    let raf = 0, visible = true, last = 0, time = 2;
    const mouse = [0.5, 0.5], target = [0.5, 0.5];
    let bg = [0, 0, 0], fg = [1, 1, 1], tintKey = "";
    const tints: Record<string, number[]> = {};
    const resize = () => {
      const dpr = Math.min(2, devicePixelRatio || 1);
      canvas.width = Math.max(1, Math.floor(canvas.clientWidth * dpr));
      canvas.height = Math.max(1, Math.floor(canvas.clientHeight * dpr));
      gl.viewport(0, 0, canvas.width, canvas.height);
    };
    const frame = (now: number) => {
      const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
      time += dt * live.current.speed;
      last = now;
      mouse[0] += (target[0] - mouse[0]) * Math.min(1, dt * 5);
      mouse[1] += (target[1] - mouse[1]) * Math.min(1, dt * 5);
      // Colours are parsed only when a colour prop or the theme changes.
      const key = live.current.color + "|" + live.current.color2 + "|" + live.current.color3;
      if (key !== tintKey) {
        tintKey = key;
        bg = rgb(canvas, "var(--background)", "#151515");
        fg = rgb(canvas, "var(--foreground)", "#f0f0f0");
        tints.color = rgb(canvas, live.current.color, "#0285f7");
        tints.color2 = rgb(canvas, live.current.color2, "#8b5cf6");
        tints.color3 = rgb(canvas, live.current.color3, "#06b6d4");
      }
      gl.uniform2f(u("uRes"), canvas.width, canvas.height);
      gl.uniform1f(u("uTime"), time);
      gl.uniform1f(u("uDpr"), Math.min(2, devicePixelRatio || 1));
      gl.uniform2f(u("uMouse"), mouse[0], mouse[1]);
      gl.uniform3fv(u("uBg"), bg);
      gl.uniform3fv(u("uFg"), fg);
      gl.uniform1f(u("u_grain"), live.current.grain);
      gl.uniform1f(u("u_warp"), live.current.warp);
      gl.uniform3fv(u("u_color"), tints.color);
      gl.uniform3fv(u("u_color2"), tints.color2);
      gl.uniform3fv(u("u_color3"), tints.color3);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    };
    const loop = (now: number) => {
      frame(now);
      if (visible && !reduce) raf = requestAnimationFrame(loop);
    };
    resize();
    raf = requestAnimationFrame(loop);
    const ro = new ResizeObserver(() => {
      resize();
      if (reduce) frame(performance.now());
    });
    ro.observe(canvas);
    const io = new IntersectionObserver(([e]) => {
      visible = e.isIntersecting;
      if (visible && !reduce) {
        cancelAnimationFrame(raf);
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    });
    io.observe(canvas);
    const mo = new MutationObserver(() => {
      tintKey = "";
      if (reduce) frame(performance.now());
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
    };
  }, [reduce]);

  if (fallback) return <div aria-hidden className={cn("pointer-events-none absolute inset-0", className)} style={{ background: `radial-gradient(ellipse at 50% 40%, color-mix(in oklab, ${color} 35%, var(--color-background)), var(--color-background) 70%)` }} />;
  return <canvas ref={ref} aria-hidden className={cn("pointer-events-none absolute inset-0 size-full", className)} />;
}
```