mewo

Grainient

A flowing, film-grained gradient in up to eight colours that stirs under the pointer and swirls on click. Raw WebGL 2, no dependencies.

No dependencies

Grainient

Move the pointer to stir it. Click to swirl.

Customize

Colors
Angle55°
Bands1
Flow0.8
Speed1
Silk0.35
Grain0.08
Saturation1

Install

Terminal
npx shadcn@latest add @mewo/grainient
Not using the registry directory?

Point the CLI straight at the item URL:

npx shadcn@latest add https://www.mewo-ui.com/r/grainient.json

Props

PropTypeDefaultDescription
colorsstring[]["#120f17", "#2a0f0a", "#9a3412", "#f97316", "#fed7aa"]Two to eight colours, darkest first. Blended in Oklab so the ramp never muddies.
type"linear" | "radial""linear"Gradient shape.
anglenumber55Direction of a linear gradient, in degrees.
bandsnumber1How many times the ramp repeats across the surface.
softnessnumber1Edge softness between colours, 0 to 1.
flownumber0.8How far the surface warps.
scalenumber1Size of the warp.
speednumber1Animation speed.
silknumber0.35A lit, cloth-like sheen along the bands.
grainAmountnumber0.08Film grain strength.
grainSizenumber1.2Film grain size, in device pixels.
grainAnimatedbooleantrueRe-seed the grain a few times a second, like film.
contrastnumber1Lightness contrast.
saturationnumber1Colour saturation. 0 is greyscale.
variationnumber0Picks a different arrangement of the same colours.
interactivebooleantrueLet the pointer stir the gradient.
stirnumber1How strongly the pointer stirs.
clickSwirlbooleantrueSwirl around the pointer on click.
introbooleantrueSettle in on mount.
lightModebooleanfalseLift the ramp for a light page.
dprnumber—Override the device pixel ratio, which is capped at 2 by default.
classNamestring—Merged onto the root element, which fills its parent.

Source

"use client"

import { useEffect, useRef, type CSSProperties, type HTMLAttributes } from "react"
import { cn } from "@/lib/utils"

export type GrainientType = "linear" | "radial"

export interface GrainientProps extends Omit<HTMLAttributes<HTMLDivElement>, "style"> {
  /** Two to eight colours, darkest first. Blended in Oklab so the ramp never muddies. */
  colors?: string[]
  type?: GrainientType
  /** Direction of a linear gradient, in degrees. */
  angle?: number
  /** How many times the ramp repeats across the surface. */
  bands?: number
  speed?: number
  /** How far the surface warps. */
  flow?: number
  scale?: number
  /** Edge softness between colours. */
  softness?: number
  /** A lit, cloth-like sheen along the bands. */
  silk?: number
  grainAmount?: number
  grainSize?: number
  /** Re-seed the grain a few times a second, like film. */
  grainAnimated?: boolean
  contrast?: number
  saturation?: number
  /** Picks a different arrangement of the same colours. */
  variation?: number
  /** Let the pointer stir the gradient. */
  interactive?: boolean
  stir?: number
  /** Swirl around the pointer on click. */
  clickSwirl?: boolean
  /** Settle in on mount. */
  intro?: boolean
  /** Lift the ramp for a light page. */
  lightMode?: boolean
  /** Override the device pixel ratio (capped at 2 by default). */
  dpr?: number
  style?: CSSProperties
}

type Settings = {
  colors: string[]
  type: number
  angle: number
  bands: number
  speed: number
  flow: number
  scale: number
  softness: number
  silk: number
  grainAmount: number
  grainSize: number
  grainAnimated: boolean
  contrast: number
  saturation: number
  variation: number
  interactive: boolean
  stir: number
  clickSwirl: boolean
  intro: boolean
  lightMode: boolean
  dpr?: number
}

type Sample = { x: number; y: number; vx: number; vy: number; age: number; grow: number; done: boolean }

type Spin = { x: number; y: number; age: number; direction: number }

type Program = { program: WebGLProgram; uniforms: Record<string, WebGLUniformLocation | null> }

const MAX_COLORS = 8
const MAX_STIRS = 24
const MAX_TWIRLS = 4
const STIR_LIFE = 1.3
const STIR_STEP = 0.06
const TWIRL_LIFE = 2.6
const INTRO_SECONDS = 1.6
const DEFAULT_COLORS = ["#120f17", "#2a0f0a", "#9a3412", "#f97316", "#fed7aa"]
const TYPES: Record<string, number> = { linear: 0, radial: 1 }
const LIFT = 0.2

const VERTEX = `#version 300 es
out vec2 vUv;
void main() {
  vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
  vUv = p;
  gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}`

const FIELD = `#version 300 es
precision highp float;
precision highp int;
uniform float uAspect;
uniform float uTime;
uniform vec3 uRamp[8];
uniform int uCount;
uniform int uType;
uniform vec2 uDir;
uniform float uExtent;
uniform float uBands;
uniform float uSoftness;
uniform float uFlow;
uniform float uScale;
uniform float uSilk;
uniform vec4 uStir[24];
uniform float uStirWeight[24];
uniform vec4 uTwirl[4];
uniform vec2 uSeed;
uniform float uContrast;
uniform float uSaturation;
uniform float uSettle;
uniform float uReveal;
in vec2 vUv;
out vec4 outColor;

vec2 hash2(vec2 p) {
  vec3 q = fract(vec3(p.xyx) * vec3(0.1031, 0.103, 0.0973));
  q += dot(q, q.yzx + 33.33);
  return fract((q.xx + q.yz) * q.zy) * 2.0 - 1.0;
}

float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
  float a = dot(hash2(i), f);
  float b = dot(hash2(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0));
  float c = dot(hash2(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0));
  float d = dot(hash2(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0));
  return 0.5 + 0.5 * mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

vec2 drift(vec2 p, float t) {
  vec2 w1 = vec2(
    noise(p * 0.85 + vec2(t * 0.11, t * 0.07)),
    noise(p * 0.85 + vec2(4.1, 1.7) - vec2(t * 0.08, t * 0.12))
  );
  vec2 w2 = vec2(
    noise(p * 1.55 + w1 * 1.8 + vec2(1.3, 7.1) + t * 0.05),
    noise(p * 1.55 + w1 * 1.8 + vec2(6.4, 2.2) - t * 0.06)
  );
  return (w1 - 0.5) * 1.1 + (w2 - 0.5) * 0.55;
}

vec2 warp(vec2 p) {
  vec2 q = p / uScale;
  if (uType == 1) {
    float turn = uTime * 0.35;
    q = mat2(cos(turn), sin(turn), -sin(turn), cos(turn)) * q;
  } else {
    q += vec2(-uDir.y, uDir.x) * uTime * 0.45;
  }
  return p + drift(q + uSeed, uTime) * uFlow * uScale * uSettle;
}

float coord(vec2 p) {
  vec2 w = warp(p);
  return uType == 1 ? 1.0 - length(w) * 1.1 : dot(w, uDir) / uExtent + 0.5;
}

vec3 labToRgb(vec3 lab) {
  float l = lab.x + 0.3963377774 * lab.y + 0.2158037573 * lab.z;
  float m = lab.x - 0.1055613458 * lab.y - 0.0638541728 * lab.z;
  float s = lab.x - 0.0894841775 * lab.y - 1.2914855480 * lab.z;
  l = l * l * l;
  m = m * m * m;
  s = s * s * s;
  return vec3(
    4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
    -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
    -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
  );
}

vec3 encode(vec3 c) {
  c = clamp(c, 0.0, 1.0);
  return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));
}

void main() {
  vec2 p = (vUv - 0.5) * vec2(uAspect, 1.0);
  for (int i = 0; i < 24; i++) {
    float w = uStirWeight[i];
    if (w <= 0.0) continue;
    vec2 d = p - uStir[i].xy;
    p -= uStir[i].zw * w * exp(-dot(d, d) / 0.045);
  }
  for (int i = 0; i < 4; i++) {
    float angle = uTwirl[i].z;
    if (angle == 0.0) continue;
    vec2 d = p - uTwirl[i].xy;
    float a = angle * exp(-dot(d, d) / (uTwirl[i].w * uTwirl[i].w));
    float c = cos(a);
    float s = sin(a);
    p = uTwirl[i].xy + mat2(c, -s, s, c) * d;
  }
  float raw = coord(p);
  float s = raw;
  if (uBands > 1.0) {
    s *= uBands;
    float cycle = floor(s);
    s = fract(s);
    if (mod(cycle, 2.0) > 0.5) s = 1.0 - s;
  }
  s = clamp(s, 0.0, 1.0);
  float scaled = s * float(uCount - 1);
  int index = int(min(floor(scaled), float(uCount - 2)));
  float f = scaled - float(index);
  float edge = max(uSoftness * 0.5, fwidth(scaled) * 0.75);
  float shaped = smoothstep(0.5 - edge, 0.5 + edge, f);
  vec3 from = uRamp[0];
  vec3 to = uRamp[1];
  for (int i = 0; i < 7; i++) {
    if (i == index) {
      from = uRamp[i];
      to = uRamp[i + 1];
    }
  }
  vec3 lab = mix(from, to, shaped);
  if (uSilk > 0.0) {
    vec2 grad = vec2(coord(p + vec2(0.004, 0.0)) - raw, coord(p + vec2(0.0, 0.004)) - raw) / 0.004;
    vec2 slope = grad * sin(3.14159265 * clamp(raw, 0.0, 1.0) * uBands) * uSilk * 4.0;
    vec3 normal = normalize(vec3(-slope, 1.0));
    vec3 light = normalize(vec3(-uDir.y, uDir.x, 1.2));
    float diffuse = dot(normal, light) - light.z;
    lab = uRamp[0] + (lab - uRamp[0]) * (1.0 + (diffuse * 0.35 + max(diffuse, 0.0) * diffuse * 0.6) * uSilk);
  }
  lab = mix(uRamp[0], lab, smoothstep(0.0, 1.0, uReveal * 1.5 - length(vUv - 0.5) * 0.6));
  lab.x = (lab.x - 0.55) * uContrast + 0.55;
  lab.yz *= uSaturation;
  outColor = vec4(encode(labToRgb(lab)), 1.0);
}`

const COMPOSITE = `#version 300 es
precision highp float;
uniform sampler2D uField;
uniform float uGrain;
uniform float uGrainSize;
uniform float uFrame;
in vec2 vUv;
out vec4 outColor;

float hash(vec2 p) {
  vec3 q = fract(vec3(p.xyx) * 0.1031);
  q += dot(q, q.yzx + 33.33);
  return fract((q.x + q.y) * q.z);
}

float clump(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);
}

void main() {
  vec3 color = texture(uField, vUv).rgb;
  vec2 pixel = gl_FragCoord.xy + uFrame * vec2(17.0, 59.0);
  float fine = hash(pixel) + hash(pixel + 7.13) - 1.0;
  float coarse = (clump(pixel / max(uGrainSize, 1.0)) - 0.5) * 2.0;
  float grain = mix(fine, coarse, clamp(uGrainSize - 1.0, 0.0, 1.0) * 0.8);
  float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
  color += grain * uGrain * (0.45 + 2.2 * luma * (1.0 - luma));
  outColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}`

const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))

const toLinear = (value: number) => (value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4)

const toLab = ([r, g, b]: number[]): number[] => {
  const lr = toLinear(r)
  const lg = toLinear(g)
  const lb = toLinear(b)
  const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb)
  const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb)
  const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb)
  return [
    0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
    1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
    0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
  ]
}

const compile = (gl: WebGL2RenderingContext, type: number, source: string) => {
  const shader = gl.createShader(type)
  if (!shader) return null
  gl.shaderSource(shader, source)
  gl.compileShader(shader)
  if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader
  gl.deleteShader(shader)
  return null
}

const link = (gl: WebGL2RenderingContext, fragmentSource: string): Program | null => {
  const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX)
  const fragment = compile(gl, gl.FRAGMENT_SHADER, fragmentSource)
  const program = gl.createProgram()
  if (!vertex || !fragment || !program) return null
  gl.attachShader(program, vertex)
  gl.attachShader(program, fragment)
  gl.linkProgram(program)
  gl.deleteShader(vertex)
  gl.deleteShader(fragment)
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    gl.deleteProgram(program)
    return null
  }
  const uniforms: Record<string, WebGLUniformLocation | null> = {}
  const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS)
  for (let i = 0; i < count; i++) {
    const info = gl.getActiveUniform(program, i)
    if (!info) continue
    const name = info.name.replace(/\[0\]$/, "")
    uniforms[name] = gl.getUniformLocation(program, name)
  }
  return { program, uniforms }
}

export function Grainient({
  colors = DEFAULT_COLORS,
  type = "linear",
  angle = 55,
  bands = 1,
  speed = 1,
  flow = 0.8,
  scale = 1,
  softness = 1,
  silk = 0.35,
  grainAmount = 0.08,
  grainSize = 1.2,
  grainAnimated = true,
  contrast = 1,
  saturation = 1,
  variation = 0,
  interactive = true,
  stir = 1,
  clickSwirl = true,
  intro = true,
  lightMode = false,
  dpr,
  className = "",
  style,
  ...rest
}: GrainientProps) {
  const containerRef = useRef<HTMLDivElement>(null)
  const wakeRef = useRef<(() => void) | null>(null)
  const palette = (Array.isArray(colors) && colors.length ? colors : DEFAULT_COLORS).slice(0, MAX_COLORS)
  const settings: Settings = {
    colors: palette.length > 1 ? palette : [palette[0], palette[0]],
    type: TYPES[type] ?? 0,
    angle,
    bands: clamp(bands, 1, 8),
    speed: Math.max(0, speed),
    flow: clamp(flow, 0, 3),
    scale: clamp(scale, 0.25, 3),
    softness: clamp(softness, 0, 1),
    silk: clamp(silk, 0, 1),
    grainAmount: clamp(grainAmount, 0, 0.5),
    grainSize: clamp(grainSize, 0.5, 4),
    grainAnimated,
    contrast: clamp(contrast, 0.2, 2),
    saturation: clamp(saturation, 0, 2),
    variation: Math.max(0, variation),
    interactive,
    stir: clamp(stir, 0, 2),
    clickSwirl,
    intro,
    lightMode,
    dpr,
  }
  const settingsRef = useRef(settings)

  useEffect(() => {
    const container = containerRef.current
    if (!container) return undefined
    // The canvas is made here, not in JSX: React Strict Mode runs this effect twice in development, and the
    // context the first cleanup loses would come straight back from getContext() on the same element.
    const canvas = document.createElement("canvas")
    canvas.className = "absolute inset-0 block h-full w-full"
    canvas.setAttribute("aria-hidden", "true")
    const gl = canvas.getContext("webgl2", {
      alpha: false,
      antialias: false,
      depth: false,
      stencil: false,
    })
    if (!gl) return undefined

    const field = link(gl, FIELD)
    const composite = link(gl, COMPOSITE)
    if (!field || !composite) return undefined
    container.appendChild(canvas)
    const fu = field.uniforms
    const cu = composite.uniforms
    const vao = gl.createVertexArray()
    const target = gl.createTexture()
    gl.bindTexture(gl.TEXTURE_2D, target)
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.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 buffer = gl.createFramebuffer()
    const fieldSize = { width: 0, height: 0 }

    const probe = document.createElement("canvas").getContext("2d", { willReadFrequently: true })
    const colorCache = new Map<string, number[]>()
    const parse = (value: string): number[] => {
      const key = String(value)
      const cached = colorCache.get(key)
      if (cached) return cached
      let rgb = [0, 0, 0]
      if (probe) {
        probe.clearRect(0, 0, 1, 1)
        probe.fillStyle = "#000000"
        probe.fillStyle = key
        probe.fillRect(0, 0, 1, 1)
        const [r, g, b] = probe.getImageData(0, 0, 1, 1).data
        rgb = [r / 255, g / 255, b / 255]
      }
      colorCache.set(key, rgb)
      return rgb
    }

    const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false
    const view = { width: 1, height: 1, ratio: 1 }
    const ramp = new Float32Array(MAX_COLORS * 3)
    const stirs = new Float32Array(MAX_STIRS * 4)
    const stirWeights = new Float32Array(MAX_STIRS)
    const twirls = new Float32Array(MAX_TWIRLS * 4)
    const trail: Sample[] = []
    const spins: Spin[] = []
    const pointer = { x: 0.5, y: 0.5, sx: 0.5, sy: 0.5, vx: 0, inside: false, fresh: true }
    let paletteKey = ""
    let time = 0
    let introClock = settingsRef.current.intro && !reduce ? 0 : 1
    let frameSeed = 0
    let grainClock = 0
    let raf = 0
    let last = 0
    let visible = true

    const syncPalette = () => {
      const s = settingsRef.current
      const key = `${s.colors.join(",")}|${s.lightMode}`
      if (key === paletteKey) return
      paletteKey = key
      const labs = s.colors.map((color) => toLab(parse(color)))
      if (!s.lightMode) {
        labs.forEach((lab, i) => ramp.set(lab, i * 3))
      } else {
        const blank = labs.map(([L]) => Math.abs(L - labs[0][0]) < 0.1)
        const tints = labs.filter((_, i) => !blank[i]).reverse()
        labs.forEach((_, i) => {
          const [L, a, b] = blank[i] ? [1, 0, 0] : (tints.shift() as number[])
          ramp.set([L + (1 - L) * LIFT, a * (1 - LIFT), b * (1 - LIFT)], i * 3)
        })
      }
    }

    const draw = () => {
      const s = settingsRef.current
      const aspect = view.width / view.height
      syncPalette()
      gl.bindVertexArray(vao)
      gl.disable(gl.BLEND)
      gl.bindFramebuffer(gl.FRAMEBUFFER, buffer)
      gl.viewport(0, 0, fieldSize.width, fieldSize.height)
      gl.useProgram(field.program)
      gl.uniform1f(fu.uAspect, aspect)
      gl.uniform1f(fu.uTime, time)
      gl.uniform3fv(fu.uRamp, ramp)
      gl.uniform1i(fu.uCount, s.colors.length)
      const radians = (s.angle * Math.PI) / 180
      gl.uniform1i(fu.uType, s.type)
      gl.uniform2f(fu.uDir, Math.sin(radians), Math.cos(radians))
      gl.uniform1f(fu.uExtent, aspect * Math.abs(Math.sin(radians)) + Math.abs(Math.cos(radians)))
      gl.uniform1f(fu.uBands, s.bands)
      gl.uniform1f(fu.uSoftness, s.softness)
      gl.uniform1f(fu.uFlow, s.flow)
      gl.uniform1f(fu.uScale, s.scale)
      gl.uniform1f(fu.uSilk, s.silk)
      gl.uniform4fv(fu.uStir, stirs)
      gl.uniform1fv(fu.uStirWeight, stirWeights)
      gl.uniform4fv(fu.uTwirl, twirls)
      gl.uniform2f(fu.uSeed, s.variation * 3.17, s.variation * 1.91)
      gl.uniform1f(fu.uContrast, s.contrast)
      gl.uniform1f(fu.uSaturation, s.saturation)
      gl.uniform1f(fu.uSettle, introClock * introClock * (3 - 2 * introClock))
      gl.uniform1f(fu.uReveal, introClock)
      gl.drawArrays(gl.TRIANGLES, 0, 3)
      gl.bindFramebuffer(gl.FRAMEBUFFER, null)
      gl.viewport(0, 0, canvas.width, canvas.height)
      gl.useProgram(composite.program)
      gl.activeTexture(gl.TEXTURE0)
      gl.bindTexture(gl.TEXTURE_2D, target)
      gl.uniform1i(cu.uField, 0)
      gl.uniform1f(cu.uGrain, s.grainAmount)
      gl.uniform1f(cu.uGrainSize, s.grainSize * view.ratio)
      gl.uniform1f(cu.uFrame, frameSeed)
      gl.drawArrays(gl.TRIANGLES, 0, 3)
    }

    const resize = () => {
      const s = settingsRef.current
      view.width = Math.max(1, container.clientWidth)
      view.height = Math.max(1, container.clientHeight)
      view.ratio = Math.min(s.dpr || window.devicePixelRatio || 1, 2)
      canvas.width = Math.max(1, Math.round(view.width * view.ratio))
      canvas.height = Math.max(1, Math.round(view.height * view.ratio))
      const width = Math.max(1, Math.round(canvas.width / 2))
      const height = Math.max(1, Math.round(canvas.height / 2))
      if (width !== fieldSize.width || height !== fieldSize.height) {
        fieldSize.width = width
        fieldSize.height = height
        gl.bindTexture(gl.TEXTURE_2D, target)
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
        gl.bindFramebuffer(gl.FRAMEBUFFER, buffer)
        gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, target, 0)
        gl.bindFramebuffer(gl.FRAMEBUFFER, null)
      }
      draw()
      wake()
    }

    const updateTouches = (dt: number, aspect: number) => {
      const s = settingsRef.current
      const follow = 1 - Math.exp(-dt / 0.06)
      const px = pointer.sx
      const py = pointer.sy
      pointer.sx += (pointer.x - pointer.sx) * follow
      pointer.sy += (pointer.y - pointer.sy) * follow
      const vx = ((pointer.sx - px) / dt) * aspect
      const vy = (pointer.sy - py) / dt
      pointer.vx = vx
      const head = trail[0]
      trail.forEach((sample) => {
        if (sample.done) sample.age += dt
      })
      if (s.interactive && pointer.inside && Math.hypot(vx, vy) > 0.02 && !reduce) {
        if (!head || head.done) {
          trail.unshift({ x: 0, y: 0, vx: 0, vy: 0, age: 0, grow: 0, done: false })
          if (trail.length > MAX_STIRS) trail.pop()
        }
        const live = trail[0]
        live.x = (pointer.sx - 0.5) * aspect
        live.y = pointer.sy - 0.5
        live.vx = vx
        live.vy = vy
        live.grow = Math.min(1, live.grow + dt / STIR_STEP)
        live.done = live.grow >= 1
      } else if (head) {
        head.done = true
      }
      while (trail.length && trail[trail.length - 1].age > STIR_LIFE) trail.pop()
      for (let i = 0; i < MAX_STIRS; i++) {
        const sample = trail[i]
        if (!sample) {
          stirWeights[i] = 0
          continue
        }
        const fade = 1 - sample.age / STIR_LIFE
        const cap = Math.min(1, 2.2 / Math.max(Math.hypot(sample.vx, sample.vy), 0.001))
        stirs.set([sample.x, sample.y, sample.vx * cap, sample.vy * cap], i * 4)
        stirWeights[i] = fade * fade * sample.grow * s.stir * 0.05
      }
      spins.forEach((spin) => {
        spin.age += dt
      })
      while (spins.length && spins[spins.length - 1].age > TWIRL_LIFE) spins.pop()
      for (let i = 0; i < MAX_TWIRLS; i++) {
        const spin = spins[i]
        if (!spin) {
          twirls[i * 4 + 2] = 0
          continue
        }
        const k = spin.age / TWIRL_LIFE
        const envelope = Math.min(1, spin.age / 0.18) * (1 - k) * (1 - k)
        twirls.set([spin.x, spin.y, spin.direction * 2.4 * envelope, 0.16 + 0.1 * Math.min(1, spin.age / 0.5)], i * 4)
      }
    }

    const frame = (now: number) => {
      raf = 0
      if (!visible || document.hidden) return
      const s = settingsRef.current
      const dt = last ? clamp((now - last) / 1000, 0.001, 0.05) : 1 / 60
      last = now
      if (!reduce) time += dt * s.speed * 0.16
      if (introClock < 1) introClock = Math.min(1, introClock + dt / INTRO_SECONDS)
      grainClock += dt
      if (s.grainAnimated && !reduce && grainClock > 1 / 24) {
        grainClock = 0
        frameSeed = (frameSeed + 1) % 97
      }
      updateTouches(dt, view.width / view.height)
      draw()
      const busy =
        (!reduce && (s.speed > 0 || s.grainAnimated)) || introClock < 1 || trail.length > 0 || spins.length > 0
      if (busy) raf = requestAnimationFrame(frame)
      else last = 0
    }

    function wake() {
      if (!raf && visible && !document.hidden) raf = requestAnimationFrame(frame)
    }

    const locate = (event: PointerEvent) => {
      const rect = container.getBoundingClientRect()
      const x = (event.clientX - rect.left) / Math.max(1, rect.width)
      const y = 1 - (event.clientY - rect.top) / Math.max(1, rect.height)
      pointer.inside = x >= 0 && x <= 1 && y >= 0 && y <= 1
      pointer.x = x
      pointer.y = y
      if (pointer.fresh || !pointer.inside) {
        pointer.sx = x
        pointer.sy = y
        pointer.fresh = false
      }
    }

    const onPointerMove = (event: PointerEvent) => {
      locate(event)
      if (pointer.inside || trail.length) wake()
    }

    const onPointerDown = (event: PointerEvent) => {
      locate(event)
      const s = settingsRef.current
      if (!pointer.inside || !s.clickSwirl || reduce) return
      spins.unshift({
        x: (pointer.x - 0.5) * (view.width / view.height),
        y: pointer.y - 0.5,
        age: 0,
        direction: pointer.vx < 0 ? 1 : -1,
      })
      if (spins.length > MAX_TWIRLS) spins.pop()
      wake()
    }

    const onPointerLeave = () => {
      pointer.inside = false
    }

    const onVisibility = () => {
      last = 0
      wake()
    }

    const resizeObserver = new ResizeObserver(resize)
    resizeObserver.observe(container)
    const intersection = new IntersectionObserver((entries) => {
      visible = entries.some((entry) => entry.isIntersecting)
      if (visible) {
        last = 0
        wake()
      }
    })
    intersection.observe(container)
    window.addEventListener("pointermove", onPointerMove, { passive: true })
    window.addEventListener("pointerdown", onPointerDown, { passive: true })
    document.documentElement.addEventListener("pointerleave", onPointerLeave)
    document.addEventListener("visibilitychange", onVisibility)
    resize()
    wakeRef.current = wake

    return () => {
      cancelAnimationFrame(raf)
      wakeRef.current = null
      resizeObserver.disconnect()
      intersection.disconnect()
      window.removeEventListener("pointermove", onPointerMove)
      window.removeEventListener("pointerdown", onPointerDown)
      document.documentElement.removeEventListener("pointerleave", onPointerLeave)
      document.removeEventListener("visibilitychange", onVisibility)
      gl.deleteTexture(target)
      gl.deleteFramebuffer(buffer)
      gl.deleteVertexArray(vao)
      gl.deleteProgram(field.program)
      gl.deleteProgram(composite.program)
      gl.getExtension("WEBGL_lose_context")?.loseContext()
      canvas.remove()
    }
  }, [])

  // Props flow through a ref, so the WebGL setup effect above runs exactly once: a prop change only
  // updates the ref and wakes the loop. It never tears the context down.
  useEffect(() => {
    settingsRef.current = settings
    wakeRef.current?.()
  })

  return (
    <div
      ref={containerRef}
      data-slot="grainient"
      className={cn("relative h-full w-full overflow-hidden", className)}
      style={style}
      {...rest}
    />
  )
}