{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "grain-gradient",
  "type": "registry:block",
  "title": "Grain Gradient",
  "description": "An animated mesh gradient with real film grain: five inks drift through a domain-warped field and blend in OKLab, so mixes stay clean instead of going muddy. Swap the palette live and it eases across; the cursor smears the colour along its path.",
  "docs": "Grain Gradient was added to public/vividsites/grain-gradient.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/grain-gradient",
  "categories": [
    "3d",
    "shader-backgrounds"
  ],
  "files": [
    {
      "path": "components/vividsites/grain-gradient.html",
      "type": "registry:file",
      "target": "public/vividsites/grain-gradient.html",
      "content": "<!--\n  vs-grain-gradient — an animated mesh gradient with real film grain.\n  Five colour points drift on slow Lissajous paths through a domain-warped field and are\n  blended in OKLab (so the mixes stay clean instead of going muddy). A soft silk sheen rides\n  the folds, the cursor smears the colour along its velocity, and 24 fps grain sits on top.\n\n  Fills its nearest positioned parent (position:absolute; inset:0). Put content above it.\n  CSS variables (set on .vs-grain-gradient; change them live and it eases to the new palette):\n    --vs-grain-gradient-c1 … c5   the palette, darkest to lightest reads best (any CSS colour); default \"tidal\"\n                                  (try #120a09 #6b1a12 #d4471e #f0a35e #f6e6d3 for ember, #0a0e1c #1f3a6e #e0764f #f3c4a1 #f7efe6 for dusk)\n    --vs-grain-gradient-speed     drift speed, 1 = default, 0 = frozen\n    --vs-grain-gradient-grain     grain strength 0–1 (default .5)\n    --vs-grain-gradient-warp      how liquid the folds are 0–2 (default 1)\n    --vs-grain-gradient-sheen     silk highlight on the folds 0–1 (default .35)\n    --vs-grain-gradient-cursor    cursor smear 0–2 (default 1)\n    --vs-grain-gradient-soft      how far the colours bleed into each other .4–2 (default 1; low = crisp poster bands)\n-->\n<div class=\"vs-grain-gradient\" aria-hidden=\"true\"><canvas class=\"vs-grain-gradient-canvas\"></canvas></div>\n<style>\n  .vs-grain-gradient {\n    --vs-grain-gradient-c1: #061318;\n    --vs-grain-gradient-c2: #0f4c5c;\n    --vs-grain-gradient-c3: #3f8f8a;\n    --vs-grain-gradient-c4: #d66b3d;\n    --vs-grain-gradient-c5: #ece0c8;\n    --vs-grain-gradient-speed: 1;\n    --vs-grain-gradient-grain: .5;\n    --vs-grain-gradient-warp: 1;\n    --vs-grain-gradient-sheen: .35;\n    --vs-grain-gradient-cursor: 1;\n    --vs-grain-gradient-soft: 1;\n    position: absolute; inset: 0; overflow: hidden; pointer-events: none;\n    /* static fallback (no WebGL): the same palette as a layered radial mesh */\n    background:\n      radial-gradient(60% 70% at 18% 30%, var(--vs-grain-gradient-c3), transparent 70%),\n      radial-gradient(50% 60% at 80% 20%, var(--vs-grain-gradient-c4), transparent 70%),\n      radial-gradient(70% 60% at 70% 90%, var(--vs-grain-gradient-c2), transparent 70%),\n      var(--vs-grain-gradient-c1);\n  }\n  .vs-grain-gradient-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity .9s cubic-bezier(.16, 1, .3, 1); }\n  .vs-grain-gradient-ready .vs-grain-gradient-canvas { opacity: 1; }\n</style>\n<script>\n(() => {\n  /* ---- shared runtime: WebGL2 context, resize, visibility, pointer spring, CSS-variable props ---- */\n  const VS = (root, spec) => {\n    const P = spec.prefix, canvas = root.querySelector('canvas');\n    const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n    const S = { root, canvas, gl: null, w: 1, h: 1, cssW: 1, cssH: 1, dpr: 1, t: spec.t0 || 0, rt: 0, speed: 1, frame: 0, visible: false, lost: false, raf: 0, reduced: reduce.matches, css: {},\n      ptr: { x: .5, y: .5, tx: .5, ty: .5, vx: 0, vy: 0, px: .5, py: .5, down: false, inside: false, active: 0, seen: false } };\n    const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, depth: false, stencil: false, premultipliedAlpha: false, powerPreference: 'high-performance' });\n    if (!gl) { root.classList.add(P + '-fallback'); return null; }\n    S.gl = gl;\n    /* colours: any CSS colour string -> linear rgb */\n    const cc = document.createElement('canvas').getContext('2d', { willReadFrequently: true });\n    const toLin = (v) => (v <= .04045 ? v / 12.92 : Math.pow((v + .055) / 1.055, 2.4));\n    const color = (str) => { cc.clearRect(0, 0, 1, 1); cc.fillStyle = '#000'; cc.fillStyle = str; cc.fillRect(0, 0, 1, 1); const d = cc.getImageData(0, 0, 1, 1).data; return [toLin(d[0] / 255), toLin(d[1] / 255), toLin(d[2] / 255)]; };\n    const readVars = () => {\n      const cs = getComputedStyle(root);\n      for (const [k, def] of Object.entries(spec.vars || {})) {\n        const raw = cs.getPropertyValue(`--${P}-${k}`).trim() || String(def);\n        S.css[k] = typeof def === 'number' ? (parseFloat(raw) || 0) : color(raw);\n      }\n      S.speed = S.css.speed != null ? S.css.speed : 1;\n      spec.vars && spec.onVars && spec.onVars(S);\n    };\n    /* GL helpers */\n    const VERT = `#version 300 es\nvoid main(){ vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); gl_Position = vec4(p * 2. - 1., 0., 1.); }`;\n    S.program = (frag, vert) => {\n      const mk = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS) && !gl.isContextLost()) console.warn(P, gl.getShaderInfoLog(s)); return s; };\n      const p = gl.createProgram(); gl.attachShader(p, mk(gl.VERTEX_SHADER, vert || VERT)); gl.attachShader(p, mk(gl.FRAGMENT_SHADER, '#version 300 es\\nprecision highp float;\\nprecision highp int;\\n' + frag)); gl.linkProgram(p);\n      const u = {}; const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS) || 0;\n      for (let i = 0; i < n; i++) { const a = gl.getActiveUniform(p, i); u[a.name.replace(/\\[0\\]$/, '')] = gl.getUniformLocation(p, a.name); }\n      return { p, u };\n    };\n    S.target = (w, h, o = {}) => {\n      const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);\n      const half = o.half !== false && S.hf;\n      gl.texImage2D(gl.TEXTURE_2D, 0, half ? gl.RGBA16F : gl.RGBA8, w, h, 0, gl.RGBA, half ? gl.HALF_FLOAT : gl.UNSIGNED_BYTE, null);\n      const f = o.nearest ? gl.NEAREST : gl.LINEAR;\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, o.mip ? gl.LINEAR_MIPMAP_LINEAR : f); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, f);\n      const wrap = o.repeat ? gl.REPEAT : gl.CLAMP_TO_EDGE; gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap);\n      const fb = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fb); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0);\n      gl.viewport(0, 0, w, h); gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); gl.bindFramebuffer(gl.FRAMEBUFFER, null);\n      return { t, fb, w, h, mip: !!o.mip };\n    };\n    S.pair = (w, h, o) => { const a = S.target(w, h, o), b = S.target(w, h, o); return { a, b, w, h, swap() { const x = this.a; this.a = this.b; this.b = x; } }; };\n    S.free = (...ts) => ts.forEach((x) => { if (!x) return; if (x.a) { S.free(x.a, x.b); return; } gl.deleteTexture(x.t); gl.deleteFramebuffer(x.fb); });\n    /* draw a program into a target (null = screen); tex = {name: target|texture} bound to units in order */\n    S.draw = (pr, out, uni = {}, tex = {}) => {\n      gl.useProgram(pr.p);\n      if (out) { gl.bindFramebuffer(gl.FRAMEBUFFER, out.fb); gl.viewport(0, 0, out.w, out.h); } else { gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, S.w, S.h); }\n      let unit = 0;\n      for (const [k, v] of Object.entries(tex)) { gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, v && v.t !== undefined ? v.t : v); if (pr.u[k]) gl.uniform1i(pr.u[k], unit); unit++; }\n      for (const [k, v] of Object.entries(uni)) {\n        const l = pr.u[k]; if (!l) continue;\n        if (typeof v === 'number') gl.uniform1f(l, v); else if (v.length === 2) gl.uniform2fv(l, v); else if (v.length === 3) gl.uniform3fv(l, v); else if (v.length === 4) gl.uniform4fv(l, v); else gl.uniform3fv(l, v);\n      }\n      gl.drawArrays(gl.TRIANGLES, 0, 3);\n      if (out && out.mip) { gl.bindTexture(gl.TEXTURE_2D, out.t); gl.generateMipmap(gl.TEXTURE_2D); }\n    };\n    const setup = () => {\n      S.hf = !!gl.getExtension('EXT_color_buffer_float') || !!gl.getExtension('EXT_color_buffer_half_float');\n      gl.getExtension('OES_texture_float_linear');\n      gl.disable(gl.DEPTH_TEST); gl.disable(gl.BLEND);\n      spec.init(S); S.sized = false; readVars(); resize();\n    };\n    const resize = () => {\n      const r = root.getBoundingClientRect(); const scale = (spec.scale ? spec.scale(S) : 1);\n      S.cssW = Math.max(1, r.width); S.cssH = Math.max(1, r.height);\n      S.dpr = Math.min(window.devicePixelRatio || 1, 2) * scale;\n      const w = Math.max(1, Math.round(S.cssW * S.dpr)), h = Math.max(1, Math.round(S.cssH * S.dpr));\n      if (w === S.w && h === S.h && S.sized) return;\n      S.w = canvas.width = w; S.h = canvas.height = h; S.aspect = w / h; S.sized = true;\n      spec.resize && spec.resize(S);\n      if (S.reduced) still();\n    };\n    /* pointer: a spring toward the finger/mouse, in root-normalised coords (0..1, y up) */\n    const P_ = S.ptr;\n    const at = (cx, cy) => { const r = root.getBoundingClientRect(); P_.tx = (cx - r.left) / Math.max(1, r.width); P_.ty = 1 - (cy - r.top) / Math.max(1, r.height); P_.inside = P_.tx >= 0 && P_.tx <= 1 && P_.ty >= 0 && P_.ty <= 1; if (!P_.seen) { P_.x = P_.px = P_.tx; P_.y = P_.py = P_.ty; P_.seen = true; } P_.active = 1; P_.last = performance.now(); };\n    addEventListener('pointermove', (e) => { if (e.pointerType !== 'touch') at(e.clientX, e.clientY); }, { passive: true });\n    addEventListener('pointerdown', (e) => { at(e.clientX, e.clientY); if (P_.inside) { P_.down = true; spec.press && spec.press(S, true); } }, { passive: true });\n    addEventListener('pointerup', () => { if (P_.down) { P_.down = false; spec.press && spec.press(S, false); } }, { passive: true });\n    addEventListener('pointercancel', () => { if (P_.down) { P_.down = false; spec.press && spec.press(S, false); } }, { passive: true });\n    addEventListener('touchstart', (e) => { const t = e.touches[0]; if (t) { P_.seen = false; at(t.clientX, t.clientY); } }, { passive: true });\n    addEventListener('touchmove', (e) => { const t = e.touches[0]; if (t) at(t.clientX, t.clientY); }, { passive: true });\n    const stepPtr = (dt) => {\n      P_.px = P_.x; P_.py = P_.y;\n      const k = spec.ptrSpring || [170, 26]; const n = Math.ceil(dt / (1 / 120));\n      for (let i = 0; i < n; i++) { const h = dt / n; P_.vx += ((P_.tx - P_.x) * k[0] - P_.vx * k[1]) * h; P_.vy += ((P_.ty - P_.y) * k[0] - P_.vy * k[1]) * h; P_.x += P_.vx * h; P_.y += P_.vy * h; }\n      P_.active = Math.max(0, P_.active - dt * .6);\n    };\n    /* loop: only while on screen, tab visible and context alive */\n    let last = 0;\n    const loop = (now) => {\n      S.raf = 0; if (!canRun()) return;\n      const dt = last ? Math.min(.05, (now - last) / 1000) : 1 / 60; last = now;\n      stepPtr(dt); S.t += dt * S.speed; S.rt += dt; S.frame++;\n      spec.frame(S, dt);\n      schedule();\n    };\n    const canRun = () => S.visible && !document.hidden && !S.lost && !S.reduced;\n    const schedule = () => { if (!S.raf && canRun()) { S.raf = requestAnimationFrame(loop); } else if (!canRun()) last = 0; };\n    const still = () => { if (S.lost) return; (spec.still || ((s) => spec.frame(s, 1 / 60)))(S); };\n    new ResizeObserver(resize).observe(root);\n    new IntersectionObserver((es) => { S.visible = es[es.length - 1].isIntersecting; schedule(); }, { rootMargin: '64px' }).observe(root);\n    document.addEventListener('visibilitychange', schedule);\n    reduce.addEventListener('change', () => { S.reduced = reduce.matches; S.reduced ? still() : schedule(); });\n    new MutationObserver(() => { readVars(); if (S.reduced) still(); }).observe(root, { attributes: true, attributeFilter: ['style', 'class'] });\n    root.addEventListener(P + ':refresh', () => { readVars(); if (S.reduced) still(); });\n    canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); S.lost = true; if (S.raf) cancelAnimationFrame(S.raf); S.raf = 0; });\n    canvas.addEventListener('webglcontextrestored', () => { S.lost = false; setup(); schedule(); });\n    S.api = { press: (on) => spec.press && spec.press(S, on) };\n    setup();\n    if (S.reduced) still();\n    root.classList.add(P + '-ready');\n    return S;\n  };\n\n  const FRAG = `\nuniform vec2 uRes; uniform float uTime, uGrain, uWarp, uSheen, uFrame, uSoft; uniform vec3 uLab[5]; uniform vec2 uPtr, uVel;\nout vec4 o;\nvec3 fromLab(vec3 c){ float l = c.x + .3963377774 * c.y + .2158037573 * c.z, m = c.x - .1055613458 * c.y - .0638541728 * c.z, s = c.x - .0894841775 * c.y - 1.291485548 * c.z;\n  l = l * l * l; m = m * m * m; s = s * s * s;\n  return vec3(4.0767416621 * l - 3.3077115913 * m + .2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - .3413193965 * s, -.0041960863 * l - .7034186147 * m + 1.707614701 * s); }\nvec2 h22(vec2 p){ vec3 a = fract(p.xyx * vec3(.1031, .103, .0973)); a += dot(a, a.yzx + 33.33); return fract((a.xx + a.yz) * a.zy); }\nfloat h12(vec2 p){ vec3 a = fract(p.xyx * .1031); a += dot(a, a.yzx + 33.33); return fract((a.x + a.y) * a.z); }\nfloat gnoise(vec2 p){ vec2 i = floor(p), f = fract(p); vec2 u = f * f * f * (f * (f * 6. - 15.) + 10.);\n  float a = dot(h22(i) * 2. - 1., f), b = dot(h22(i + vec2(1, 0)) * 2. - 1., f - vec2(1, 0)), c = dot(h22(i + vec2(0, 1)) * 2. - 1., f - vec2(0, 1)), d = dot(h22(i + 1.) * 2. - 1., f - 1.);\n  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); }\nvec3 toSRGB(vec3 c){ c = max(c, 0.); return mix(c * 12.92, 1.055 * pow(c, vec3(1. / 2.4)) - .055, step(.0031308, c)); }\nfloat field(vec2 p, float t){ return gnoise(p * .9 + vec2(t * .7, -t * .4)) + .5 * gnoise(p * 1.9 - vec2(t * .3, t * .6) + 3.1); }\nvoid main(){\n  vec2 uv = gl_FragCoord.xy / uRes; float asp = uRes.x / uRes.y;\n  vec2 p = (uv - .5) * vec2(asp, 1.);\n  float t = uTime * .08;\n  // the cursor smears the field along its own velocity\n  vec2 d = p - (uPtr - .5) * vec2(asp, 1.);\n  p -= uVel * exp(-dot(d, d) * 5.) * .18;\n  // two-stage domain warp: broad folds, then a finer ripple on them\n  vec2 q = vec2(field(p, t), field(p + vec2(4.2, 1.7), t));\n  vec2 r = vec2(field(p + q * 1.3 + vec2(1.7, 9.2), t * 1.3), field(p + q * 1.3 + vec2(8.3, 2.8), t * 1.3));\n  vec2 w = p + r * .55 * uWarp;\n  // five points on slow Lissajous paths, blended in OKLab with inverse-distance weights\n  vec3 acc = vec3(0.); float ws = 0., cs = 0.;\n  for (int i = 0; i < 5; i++){\n    float fi = float(i);\n    // inks sit along a sweep in palette order (dark pools low, the lightest rides high), then wander\n    vec2 home = i == 0 ? vec2(-.1 * asp, -.62) : vec2((-.62 + (fi - 1.) * .4) * asp * .8, .3 - abs(fi - 2.4) * .12);\n    float am = i == 0 ? .5 : 1.;\n    vec2 c = home + am * vec2(sin(t * (1.1 + fi * .23) + fi * 1.7) * .24 * asp, cos(t * (.9 + fi * .31) + fi * 2.9) * .22);\n    vec2 e = w - c;\n    // inverse-distance weights (a smooth Shepard blend): the nearest ink always leads, so there are no muddy gaps\n    // between colour bodies; softness lowers the power so neighbours bleed further into each other\n    float k = pow(dot(e, e) + .012, -1.7 / uSoft) * (i == 0 ? .8 : 1.);\n    acc += uLab[i] * k; ws += k; cs += length(uLab[i].yz) * k;\n  }\n  vec3 lab = acc / ws;\n  // lift some chroma back into the seams (plain a,b averaging greys them; full restoration invents hues)\n  float ab = length(lab.yz); lab.yz *= mix(1., clamp((cs / ws) / max(ab, 1e-4), 1., 1.8), .75 * smoothstep(0., .02, ab));\n  // silk: a highlight where the warp folds over, tinted by the lightest colour\n  float fold = r.x * .8 + r.y * .6;\n  float sheen = smoothstep(.18, .62, fold) * (1. - smoothstep(.62, 1.1, fold));\n  lab.x += sheen * .09 * uSheen;\n  lab.x -= smoothstep(-.1, -.7, fold) * .05 * uSheen;\n  vec3 col = fromLab(lab);\n  col = toSRGB(col);\n  // film grain: triangular, refreshed at 24 fps, strongest in the mid-tones\n  float l = dot(col, vec3(.2126, .7152, .0722));\n  vec2 gp = gl_FragCoord.xy + floor(uFrame) * vec2(17.31, 43.17);\n  float g = h12(gp) + h12(gp + 91.7) - 1.;\n  col += g * uGrain * (.045 + .09 * l * (1. - l) * 4.) ;\n  // soft rounded vignette\n  vec2 v = abs(uv - .5) * 2.; float vr = length(max(v - .55, 0.)) / .7;\n  col *= 1. - .22 * smoothstep(0., 1., vr);\n  o = vec4(clamp(col, 0., 1.), 1.);\n}`;\n  const toLab = (c) => {\n    const l = Math.cbrt(.4122214708 * c[0] + .5363325363 * c[1] + .0514459929 * c[2]), m = Math.cbrt(.2119034982 * c[0] + .6806995451 * c[1] + .1073969566 * c[2]), s = Math.cbrt(.0883024619 * c[0] + .2817188376 * c[1] + .6299787005 * c[2]);\n    return [.2104542553 * l + .793617785 * m - .0040720468 * s, 1.9779984951 * l - 2.428592205 * m + .4505937099 * s, .0259040371 * l + .7827717662 * m - .808675766 * s];\n  };\n  document.querySelectorAll('.vs-grain-gradient:not(.vs-grain-gradient-ready)').forEach((root) => {\n    let pr, lab = null, tgt = null;\n    VS(root, {\n      prefix: 'vs-grain-gradient', t0: 40,\n      vars: { c1: '#061318', c2: '#0f4c5c', c3: '#3f8f8a', c4: '#d66b3d', c5: '#ece0c8', speed: 1, grain: .5, warp: 1, sheen: .35, cursor: 1, soft: 1 },\n      onVars(S) { tgt = ['c1', 'c2', 'c3', 'c4', 'c5'].map((k) => toLab(S.css[k])); if (!lab || S.reduced) lab = tgt.map((x) => x.slice()); },\n      init(S) { pr = S.program(FRAG); },\n      frame(S, dt) {\n        const k = 1 - Math.exp(-dt * 2.4);\n        for (let i = 0; i < 5; i++) for (let j = 0; j < 3; j++) lab[i][j] += (tgt[i][j] - lab[i][j]) * k;\n        const P = S.ptr, cur = S.css.cursor;\n        S.gl.useProgram(pr.p); S.gl.uniform3fv(pr.u.uLab, lab.flat());\n        S.draw(pr, null, { uRes: [S.w, S.h], uTime: S.t, uGrain: S.css.grain, uWarp: S.css.warp, uSoft: Math.max(.3, Math.min(3, S.css.soft)), uSheen: S.css.sheen, uFrame: Math.floor(S.rt * 24),\n          uPtr: [P.x, P.y], uVel: [Math.max(-3, Math.min(3, P.vx)) * .3 * cur * S.aspect, Math.max(-3, Math.min(3, P.vy)) * .3 * cur] });\n      },\n    });\n  });\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "Shader backgrounds",
    "tags": [
      "gradient",
      "mesh gradient",
      "grain",
      "noise",
      "oklab",
      "palette",
      "webgl",
      "shader",
      "background"
    ],
    "tech": [
      "WebGL2",
      "GLSL",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#d66b3d",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/grain-gradient/index.html",
    "thumbnail": "https://vividsites.app/api/component/grain-gradient/thumb.jpg",
    "video": "https://vividsites.app/api/component/grain-gradient/thumb.mp4",
    "page": "https://vividsites.app/c/grain-gradient"
  }
}