shadimage
uselayouts
componentliquid-indexsource: ok · refreshed

Liquid Index

A feature list whose hover badge is a WebGL liquid gradient that tracks the row.

npx shadcn add "https://uselayouts.com/r/liquid-index.json"
free

Where it goes

feature-grid

Visual style

gradient

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Files2 · source cached 10/8/2026, 7:40:34 AM

registry/default/example/liquid-index.tsx
"use client"

import { useEffect, useLayoutEffect, useRef, useState } from "react"
import {
  AnimatePresence,
  motion,
  useReducedMotion,
} from "motion/react"
import {
  BookOpen,
  Camera,
  Droplets,
  Flame,
  SwatchBook,
  type LucideIcon,
} from "lucide-react"
import { LiquidGradientCanvas } from "./liquid-index-canvas"

const SPRING = { type: "spring" as const, bounce: 0, duration: 0.36 }
const COLOR_EASE = [0.25, 0.1, 0.25, 1] as const
const IMG = "auto=format&fit=crop&w=800&q=80"

type Item = {
  title: string
  description: string
  image: string
  icon: LucideIcon
  seed: number
  colors: [number, number, number][]
}

const ITEMS: Item[] = [
  {
    title: "Marbling",
    description: "Wet color that settles into the grain instead of sitting on top.",
    image: `https://images.unsplash.com/photo-1541701494587-cb58502866ab?${IMG}`,
    icon: Droplets,
    seed: 648,
    colors: [
      [0, 0, 26],
      [41, 98, 255],
      [64, 188, 255],
    ],
  },
  {
    title: "Darkroom",
    description: "Trays, safelight, and a print that only appears in the last minute.",
    image: `https://images.unsplash.com/photo-1557672172-298e090bd0f1?${IMG}`,
    icon: Camera,
    seed: 732,
    colors: [
      [22, 11, 0],
      [255, 138, 0],
      [255, 193, 79],
    ],
  },
  {
    title: "Kiln",
    description: "Clay that is unfinished until it comes back from the fire.",
    image: `https://images.unsplash.com/photo-1574169208507-84376144848b?${IMG}`,
    icon: Flame,
    seed: 516,
    colors: [
      [18, 10, 36],
      [124, 58, 237],
      [255, 139, 209],
    ],
  },
  {
    title: "Binding",
    description: "Signatures sewn so the book can open flat on a desk.",
    image: `https://images.unsplash.com/photo-1550684848-fac1c5b4e853?${IMG}`,
    icon: BookOpen,
    seed: 884,
    colors: [
      [6, 19, 13],
      [0, 200, 83],
      [182, 255, 106],
    ],
  },
  {
    title: "Proofing",
    description: "One last pass on the sheet before the press run starts.",
    image: `https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?${IMG}`,
    icon: SwatchBook,
    seed: 291,
    colors: [
      [11, 16, 32],
      [51, 65, 85],
      [148, 163, 184],
    ],
  },
]

function useIsPhone() {
  const [isPhone, setIsPhone] = useState(false)

  useEffect(() => {
    const media = window.matchMedia("(max-width: 809px)")
    const update = () => setIsPhone(media.matches)
    update()
    media.addEventListener("change", update)
    return () => media.removeEventListener("change", update)
  }, [])

  return isPhone
}

export default function LiquidIndex() {
  const isPhone = useIsPhone()
  const reduceMotion = useReducedMotion()
  const [active, setActive] = useState<number | null>(null)
  const [offsetY, setOffsetY] = useState(0)
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([])

  const current = active == null ? ITEMS[0] : ITEMS[active]
  const Icon = current.icon
  const transition = reduceMotion ? { duration: 0 } : SPRING

  useLayoutEffect(() => {
    if (isPhone || active == null) {
      setOffsetY(0)
      return
    }

    const first = itemRefs.current[0]
    const item = itemRefs.current[active]
    if (!first || !item) return

    setOffsetY(item.offsetTop - first.offsetTop)
  }, [active, isPhone])

  return (
    <div
      className={
        isPhone
          ? "flex w-full max-w-[931px] flex-col items-start gap-[27px]"
          : "flex w-full max-w-[931px] flex-row items-stretch gap-12"
      }
    >
      <div
        className={
          isPhone
            ? "relative flex w-[120px] shrink-0 flex-col items-center"
            : "relative flex w-[220px] shrink-0 flex-col items-center"
        }
      >
        <motion.div
          className={
            isPhone
              ? "relative flex aspect-square w-full items-center justify-center overflow-hidden rounded-[24px] bg-muted opacity-0"
              : "relative flex aspect-square w-full items-center justify-center overflow-hidden rounded-[32px] bg-muted opacity-0"
          }
          initial={false}
          animate={{
            opacity: active == null ? 0 : 1,
            y: isPhone || active == null ? 0 : offsetY,
          }}
          transition={transition}
        >
          <AnimatePresence initial={false} mode="sync">
            <motion.img
              key={current.image}
              src={current.image}
              alt=""
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{
                duration: reduceMotion ? 0 : 0.18,
                ease: COLOR_EASE,
              }}
              className="absolute inset-0 size-full object-cover"
              draggable={false}
            />
          </AnimatePresence>
          <div className="absolute inset-0 mix-blend-soft-light">
            <LiquidGradientCanvas
              colors={current.colors}
              seed={current.seed}
              paused={!!reduceMotion}
            />
          </div>
          <div
            className={
              isPhone
                ? "relative z-[2] flex size-9 items-center justify-center text-white"
                : "relative z-[2] flex size-14 items-center justify-center text-white"
            }
          >
            <AnimatePresence initial={false} mode="sync">
              <motion.span
                key={current.title}
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                transition={{
                  duration: reduceMotion ? 0 : 0.18,
                  ease: COLOR_EASE,
                }}
                className="absolute inset-0 flex items-center justify-center"
              >
                <Icon
                  className={isPhone ? "size-7" : "size-10"}
                  fill="currentColor"
                  strokeWidth={0}
                  aria-hidden
                />
              </motion.span>
            </AnimatePresence>
          </div>
        </motion.div>
      </div>

      <div
        className={
          isPhone
            ? "flex min-w-0 flex-1 flex-col items-start gap-[19px] py-0"
            : "flex min-w-0 flex-1 flex-col items-start gap-[25px] py-[81px]"
        }
        onMouseLeave={() => {
          if (!isPhone) setActive(null)
        }}
      >
        <div className="flex w-full flex-col items-start">
          {ITEMS.map((item, index) => {
            const isActive = active === index
            const inactive = active != null && !isActive

            return (
              <motion.button
                key={item.title}
                ref={(node) => {
                  itemRefs.current[index] = node
                }}
                type="button"
                onMouseEnter={() => {
                  if (!isPhone) setActive(index)
                }}
                onClick={() => {
                  if (isPhone) setActive(isActive ? null : index)
                }}
                className={
                  isPhone
                    ? "flex w-full cursor-pointer flex-col items-start gap-[5px] py-[5px] text-left"
                    : "flex w-auto cursor-pointer flex-col items-start gap-0 py-[7px] text-left"
                }
                animate={{ paddingLeft: isActive ? 8 : 0 }}
                transition={transition}
              >
                <motion.span
                  className={
                    isPhone
                      ? "w-full select-none text-[26px] font-semibold leading-[1.14em] tracking-[-0.03em] whitespace-pre-wrap"
                      : "w-auto select-none text-[44px] font-semibold leading-[1.1em] tracking-[-0.04em] whitespace-nowrap"
                  }
                  animate={{
                    color: inactive
                      ? "var(--muted-foreground)"
                      : "var(--foreground)",
                  }}
                  transition={{
                    duration: reduceMotion ? 0 : 0.18,
                    ease: COLOR_EASE,
                  }}
                >
                  {item.title}
                </motion.span>
                <AnimatePresence initial={false}>
                  {isPhone && isActive ? (
                    <motion.p
                      initial={{ opacity: 0, height: 0 }}
                      animate={{ opacity: 1, height: "auto" }}
                      exit={{ opacity: 0, height: 0 }}
                      transition={transition}
                      className="max-w-[520px] overflow-hidden text-[17px] font-medium leading-[1.45em] tracking-[-0.018em] text-pretty text-muted-foreground"
                    >
                      {item.description}
                    </motion.p>
                  ) : null}
                </AnimatePresence>
              </motion.button>
            )
          })}
        </div>

        <div
          className={
            isPhone
              ? "hidden"
              : "min-h-[62px] w-full max-w-[520px]"
          }
        >
          <AnimatePresence mode="wait" initial={false}>
            {active != null ? (
              <motion.p
                key={current.title}
                initial={{ opacity: 0, y: 6 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: -6 }}
                transition={transition}
                className="text-[22px] font-medium leading-[1.42em] tracking-[-0.025em] text-pretty text-muted-foreground"
              >
                {current.description}
              </motion.p>
            ) : null}
          </AnimatePresence>
        </div>
      </div>
    </div>
  )
}
registry/default/example/liquid-index-canvas.tsx
"use client"

import { useEffect, useRef } from "react"

const VERTEX = `#version 300 es
in vec2 a_position;
out vec2 v_uv;
void main() {
  v_uv = a_position * 0.5 + 0.5;
  gl_Position = vec4(a_position, 0.0, 1.0);
}
`

const FRAGMENT = `#version 300 es
precision highp float;
precision highp int;

in vec2 v_uv;
out vec4 fragColor;

uniform vec2 u_resolution;
uniform float u_time;
uniform float u_pixelRatio;
uniform vec4 u_colors[8];
uniform int u_colors_length;
uniform float u_seed;
uniform float u_speed;
uniform float u_loop;
uniform float u_scale;
uniform float u_turbAmp;
uniform float u_turbFreq;
uniform float u_turbIter;
uniform float u_waveFreq;
uniform float u_distBias;
uniform float u_jellify;
uniform float u_ditherMode;
uniform float u_dither;
uniform float u_exposure;
uniform float u_contrast;
uniform float u_saturation;

const float GOLDEN_ANGLE = 2.3999632;
const float TAU = 6.28318530;

uvec3 hash3(uvec3 v) {
    v = v * 1664525u + 1013904223u;
    v.x += v.y * v.z;
    v.y += v.z * v.x;
    v.z += v.x * v.y;
    v ^= v >> 16u;
    v.x += v.y * v.z;
    v.y += v.z * v.x;
    v.z += v.x * v.y;
    return v;
}

vec3 seedRandom(float seedVal) {
    uvec3 s = uvec3(
        floatBitsToUint(seedVal),
        floatBitsToUint(seedVal * 1.5 + 7.31),
        floatBitsToUint(seedVal * 2.7 + 13.37)
    );
    s = hash3(s);
    return vec3(s) / float(0xFFFFFFFFu);
}

vec3 toLinear(vec3 c) {
    return pow(c, vec3(2.2));
}

vec3 toSrgb(vec3 c) {
    return pow(clamp(c, 0.0, 1.0), vec3(0.4545));
}

vec3 linearToOklab(vec3 c) {
    float l = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b;
    float m = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b;
    float s = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b;

    l = pow(max(l, 0.0), 1.0/3.0);
    m = pow(max(m, 0.0), 1.0/3.0);
    s = pow(max(s, 0.0), 1.0/3.0);

    return vec3(
        0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
        1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
        0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
    );
}

vec3 oklabToLinear(vec3 c) {
    float l = c.x + 0.3963377774 * c.y + 0.2158037573 * c.z;
    float m = c.x - 0.1055613458 * c.y - 0.0638541728 * c.z;
    float s = c.x - 0.0894841775 * c.y - 1.2914855480 * c.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 oklabToLch(vec3 lab) {
    return vec3(lab.x, length(lab.yz), atan(lab.z, lab.y));
}

vec3 lchToOklab(vec3 lch) {
    return vec3(lch.x, lch.y * cos(lch.z), lch.y * sin(lch.z));
}

vec3 mixLch(vec3 lab0, vec3 lab1, float t) {
    vec3 lch0 = oklabToLch(lab0);
    vec3 lch1 = oklabToLch(lab1);

    if (lch0.y < 0.05) lch0.z = lch1.z;
    if (lch1.y < 0.05) lch1.z = lch0.z;

    float dh = lch1.z - lch0.z;
    if (dh > 3.14159265) dh -= 6.28318530;
    if (dh < -3.14159265) dh += 6.28318530;

    return lchToOklab(vec3(
        mix(lch0.x, lch1.x, t),
        mix(lch0.y, lch1.y, t),
        lch0.z + dh * t
    ));
}

vec3 getColor(int idx) {
    if (u_colors_length < 1) return vec3(0.0);
    int safeIdx = clamp(idx, 0, u_colors_length - 1);
    return u_colors[safeIdx].rgb;
}

vec3 paletteN(float t, int count) {
    if (count < 1) return vec3(0.0);
    if (count < 2) return toLinear(getColor(0));

    float segmentSize = 1.0 / float(count - 1);
    t = clamp(t, 0.0, 1.0);
    int idx = min(int(floor(t / segmentSize)), count - 2);
    float localT = clamp((t - float(idx) * segmentSize) / segmentSize, 0.0, 1.0);

    vec3 lab0 = linearToOklab(toLinear(getColor(idx)));
    vec3 lab1 = linearToOklab(toLinear(getColor(idx + 1)));

    return oklabToLinear(mixLch(lab0, lab1, localT));
}

float IGN(vec2 uv) {
    return fract(52.9829189 * fract(dot(uv, vec2(0.06711056, 0.00583715))));
}

float quickNoise(vec2 I) {
    return fract(sin(dot(I, vec2(12.9898, 78.233))) * 43758.5453);
}

float getDither(vec2 I, float mode) {
    if (mode < 0.5) return 0.5;
    if (mode < 1.5) return IGN(I);
    return quickNoise(I);
}

vec3 softGamutMap(vec3 linearRgb) {
    float maxC = max(linearRgb.r, max(linearRgb.g, linearRgb.b));
    float minC = min(linearRgb.r, min(linearRgb.g, linearRgb.b));

    if (minC >= 0.0 && maxC <= 1.0) return linearRgb;

    vec3 lab = linearToOklab(max(linearRgb, 0.0));
    float L = clamp(lab.x, 0.0, 1.0);
    float C = length(lab.yz);
    float h = atan(lab.z, lab.y);

    float maxChroma = 0.4 * (1.0 - pow(abs(2.0 * L - 1.0), 2.0));

    if (C > maxChroma * 0.7) {
        float knee = maxChroma * 0.7;
        C = knee + (maxChroma - knee) * tanh((C - knee) / (maxChroma - knee + 0.001));
    }

    return clamp(oklabToLinear(vec3(L, C * cos(h), C * sin(h))), 0.0, 1.0);
}

vec3 applyContrastSaturation(vec3 linearRgb, float contrast, float saturation) {
    vec3 lab = linearToOklab(linearRgb);
    float C = length(lab.yz);
    float h = atan(lab.z, lab.y);

    lab.x = clamp((lab.x - 0.5) * contrast + 0.5, 0.0, 1.0);
    C *= saturation;
    lab.y = C * cos(h);
    lab.z = C * sin(h);

    return oklabToLinear(lab);
}

void main() {
    vec2 fragCoord = v_uv * u_resolution;
    vec2 r = u_resolution;
    vec2 p = (fragCoord * 2.0 - r) / r.y;

    int colorCount = u_colors_length;

    if (colorCount < 1) {
        fragColor = vec4(0.0, 0.0, 0.0, 1.0);
        return;
    }

    float t = u_time * 0.3;

    float looping = step(0.5, u_loop);
    float phase = TAU * u_time / max(u_loop, 0.01);
    float radius = u_loop * u_speed * 0.3 / TAU;
    float tA = sin(phase) * radius;
    float tB = (1.0 - cos(phase)) * radius;

    vec3 seedOffset = seedRandom(u_seed);
    vec3 seedOffset2 = seedRandom(u_seed + 100.0);

    float seedAngle = u_seed * GOLDEN_ANGLE;
    vec2 seedPhase = (seedOffset2.xy - 0.5) * TAU;

    float cs = cos(seedAngle);
    float sn = sin(seedAngle);
    p = mat2(cs, -sn, sn, cs) * p;

    float dither = getDither(floor(fragCoord / u_pixelRatio), u_ditherMode);

    float totalVal = 0.0;
    float totalWeight = 0.0;
    int turbIter = int(u_turbIter);

    float freq = 1.0 / max(u_turbFreq, 0.01);

    for (float i = 0.0; i < 4.0; i++) {
        float eph = i / 4.0;

        vec2 q = p * u_scale;
        float sq = eph * eph;

        if (u_jellify > 0.5) {
            q.yx *= mix(1.0, 0.5, 1.0 - exp(-sq));
        }

        float a = seedPhase.x;
        float d = seedPhase.y;

        for (int j = 2; j < 13; j++) {
            if (j >= turbIter) break;
            float fj = float(j);
            float t1 = mix(t * u_speed, tA, looping);
            float t2 = mix(t * u_speed, tB, looping);
            q += u_turbAmp * sin(q.yx / freq * fj + t1 + vec2(a, d) + seedOffset.xy * fj) / fj;
            a += cos(fj + d * 1.2 + q.x * 2.0 - t1 + seedOffset2.z + t2 * 0.3 * looping);
            d += sin(fj * q.y + a + seedOffset.z + t1 + seedOffset2.y + t2 * 0.3 * looping);
        }

        float v = 0.5 + 0.5 * sin(length(q.yx + vec2(a, d) * 0.2) * u_waveFreq + i * i + seedOffset.x);
        float weight = smoothstep(0.0, 0.5, eph) * smoothstep(1.0, 0.5, eph);
        totalVal += v * weight;
        totalWeight += weight;
    }

    float val = totalVal / totalWeight;
    val = clamp((val - 0.3) / 0.4, 0.0, 1.0);
    val = pow(val, exp(-u_distBias));
    val = clamp(val + (dither - 0.5) * u_dither, 0.0, 1.0);

    vec3 col = paletteN(val, colorCount);
    col *= u_exposure;
    col = applyContrastSaturation(col, u_contrast, u_saturation);
    col = softGamutMap(col);
    col = toSrgb(col);

    fragColor = vec4(col, 1.0);
}
`

export type ShaderConfig = {
  colors: [number, number, number][]
  seed: number
  speed?: number
  paused?: boolean
}

function 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)) {
    console.error(gl.getShaderInfoLog(shader))
    gl.deleteShader(shader)
    return null
  }
  return shader
}

export function LiquidGradientCanvas({
  colors,
  seed,
  speed = 0.45,
  paused = false,
}: ShaderConfig) {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const configRef = useRef({ colors, seed, speed, paused })
  configRef.current = { colors, seed, speed, paused }

  useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas) return

    const gl = canvas.getContext("webgl2", {
      alpha: false,
      antialias: false,
      premultipliedAlpha: false,
    })
    if (!gl) return

    const vs = compile(gl, gl.VERTEX_SHADER, VERTEX)
    const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT)
    if (!vs || !fs) return

    const program = gl.createProgram()
    if (!program) return
    gl.attachShader(program, vs)
    gl.attachShader(program, fs)
    gl.linkProgram(program)
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
      console.error(gl.getProgramInfoLog(program))
      return
    }

    const buffer = gl.createBuffer()
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
    gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array([-1, -1, 3, -1, -1, 3]),
      gl.STATIC_DRAW
    )

    const loc = gl.getAttribLocation(program, "a_position")
    gl.enableVertexAttribArray(loc)
    gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)

    const uniforms = {
      resolution: gl.getUniformLocation(program, "u_resolution"),
      time: gl.getUniformLocation(program, "u_time"),
      pixelRatio: gl.getUniformLocation(program, "u_pixelRatio"),
      colors: gl.getUniformLocation(program, "u_colors"),
      colorsLength: gl.getUniformLocation(program, "u_colors_length"),
      seed: gl.getUniformLocation(program, "u_seed"),
      speed: gl.getUniformLocation(program, "u_speed"),
      loop: gl.getUniformLocation(program, "u_loop"),
      scale: gl.getUniformLocation(program, "u_scale"),
      turbAmp: gl.getUniformLocation(program, "u_turbAmp"),
      turbFreq: gl.getUniformLocation(program, "u_turbFreq"),
      turbIter: gl.getUniformLocation(program, "u_turbIter"),
      waveFreq: gl.getUniformLocation(program, "u_waveFreq"),
      distBias: gl.getUniformLocation(program, "u_distBias"),
      jellify: gl.getUniformLocation(program, "u_jellify"),
      ditherMode: gl.getUniformLocation(program, "u_ditherMode"),
      dither: gl.getUniformLocation(program, "u_dither"),
      exposure: gl.getUniformLocation(program, "u_exposure"),
      contrast: gl.getUniformLocation(program, "u_contrast"),
      saturation: gl.getUniformLocation(program, "u_saturation"),
    }

    let raf = 0
    let start = performance.now()
    let pausedAt = 0

    const resize = () => {
      const dpr = Math.min(window.devicePixelRatio || 1, 2)
      const width = canvas.clientWidth
      const height = canvas.clientHeight
      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
      }
      gl.viewport(0, 0, w, h)
      return { w, h, dpr }
    }

    const draw = (now: number) => {
      const cfg = configRef.current
      if (cfg.paused) {
        pausedAt = now - start
      }
      const elapsed = (cfg.paused ? pausedAt : now - start) / 1000
      const { w, h, dpr } = resize()

      gl.useProgram(program)
      gl.uniform2f(uniforms.resolution, w, h)
      gl.uniform1f(uniforms.time, elapsed)
      gl.uniform1f(uniforms.pixelRatio, dpr)

      const packed = new Float32Array(32)
      cfg.colors.forEach((color, i) => {
        packed[i * 4] = color[0] / 255
        packed[i * 4 + 1] = color[1] / 255
        packed[i * 4 + 2] = color[2] / 255
        packed[i * 4 + 3] = 1
      })
      gl.uniform4fv(uniforms.colors, packed)
      gl.uniform1i(uniforms.colorsLength, cfg.colors.length)
      gl.uniform1f(uniforms.seed, cfg.seed)
      gl.uniform1f(uniforms.speed, cfg.speed)
      gl.uniform1f(uniforms.loop, 0)
      gl.uniform1f(uniforms.scale, 0.42)
      gl.uniform1f(uniforms.turbAmp, 0.55)
      gl.uniform1f(uniforms.turbFreq, 0.1)
      gl.uniform1f(uniforms.turbIter, 7)
      gl.uniform1f(uniforms.waveFreq, 3.6)
      gl.uniform1f(uniforms.distBias, 0)
      gl.uniform1f(uniforms.jellify, 0)
      gl.uniform1f(uniforms.ditherMode, 0)
      gl.uniform1f(uniforms.dither, 0.05)
      gl.uniform1f(uniforms.exposure, 1.1)
      gl.uniform1f(uniforms.contrast, 1.1)
      gl.uniform1f(uniforms.saturation, 1)

      gl.drawArrays(gl.TRIANGLES, 0, 3)
      raf = requestAnimationFrame(draw)
    }

    raf = requestAnimationFrame(draw)

    return () => {
      cancelAnimationFrame(raf)
      gl.deleteProgram(program)
      gl.deleteShader(vs)
      gl.deleteShader(fs)
      gl.deleteBuffer(buffer)
    }
  }, [])

  return (
    <canvas
      ref={canvasRef}
      className="block h-full w-full"
      draggable={false}
    />
  )
}

More from uselayouts

Browse all components in this library →