# Liquid Silk (`liquid-silk`)

> A WebGL sheet of ink-coloured fabric that folds and flows in slow motion, threads of light riding the creases — a quiet full-bleed backdrop for landing pages.

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

## AI prompt

Add a LiquidSilk background from the mellow library — a WebGL sheet of folding fabric with threads of light on the creases, swelling faintly under the pointer. Wrap page content as `children`, and tune `speed`, `intensity` and `scale`. Pass `highlight` (an "R, G, B" triplet) to colour the lit crests differently from the cloth — leave both it and `color` unset to follow the theme ink. Set `interactive={false}` for a purely ambient backdrop.

## Install

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

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `color` | `string` | — | Cloth colour as an "R, G, B" triplet — defaults to the theme ink. |
| `highlight` | `string` | — | Colour of the light riding the creases, as an "R, G, B" triplet. Defaults to `color`, keeping the sheet monochrome. |
| `speed` | `number` | `1` | Flow speed multiplier. |
| `intensity` | `number` | `1` | Overall opacity of the silk, 0–2. |
| `scale` | `number` | `1` | Pattern zoom — higher is tighter folds. |
| `interactive` | `boolean` | `true` | Let the pointer gather the cloth and catch the light under it. |
| `children` | `React.ReactNode` | — | Content rendered above the silk. |
| `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";

function useInkRgb(): string {
  const [rgb, setRgb] = useState<string>("244, 241, 236");
  useEffect(() => {
    const read = () => {
      const v = getComputedStyle(document.documentElement)
        .getPropertyValue("--ink-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 LiquidSilkProps {
  /** Cloth colour as an "R, G, B" triplet — defaults to the theme ink. */
  color?: string;
  /**
   * Colour of the light riding the creases, as an "R, G, B" triplet. Defaults
   * to `color`, which keeps the sheet monochrome.
   */
  highlight?: string;
  /** Flow speed multiplier. */
  speed?: number;
  /** Overall opacity of the silk, 0–2. */
  intensity?: number;
  /** Pattern zoom — higher is tighter folds. */
  scale?: number;
  /** Let the pointer gather the cloth and catch the light under it. */
  interactive?: boolean;
  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_color;
uniform vec3 u_highlight;
uniform float u_alpha;
uniform float u_scale;
uniform vec2 u_mouse;
uniform float u_grab;

float hash(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float noise(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
  );
}
float fbm(vec2 p) {
  float v = 0.0;
  float a = 0.5;
  for (int i = 0; i < 4; i++) {
    v += a * noise(p);
    p = p * 2.03 + vec2(1.7, 4.1);
    a *= 0.5;
  }
  return v;
}

void main() {
  vec2 uv = gl_FragCoord.xy / u_res;
  vec2 m = u_mouse;
  uv.x *= u_res.x / u_res.y;
  m.x *= u_res.x / u_res.y;
  uv *= u_scale;
  m *= u_scale;

  // The pointer breathes on the cloth — a wide, shallow swell rather than a
  // pinch. Keep these low: the sheet should read as ambient, not cursor-led.
  float pull = u_grab * exp(-dot(uv - m, uv - m) * 1.4);
  uv += (m - uv) * pull * 0.11;

  // double domain warp — the fold structure
  vec2 q = vec2(
    fbm(uv * 1.4 + u_t * 0.06),
    fbm(uv * 1.4 + vec2(5.2, 1.3) - u_t * 0.05)
  );
  vec2 r = vec2(
    fbm(uv * 1.8 + 3.4 * q + vec2(1.7, 9.2) + u_t * 0.04),
    fbm(uv * 1.8 + 3.4 * q + vec2(8.3, 2.8))
  );
  float v = fbm(uv * 1.8 + 3.0 * r);

  // broad sheen where the folds catch light
  float sheen = pow(smoothstep(0.3, 0.8, v), 2.0);
  // fine thread lines flowing along the folds
  float lines = 0.5 + 0.5 * sin(v * 16.0 - u_t * 0.5);
  lines = smoothstep(0.4, 0.98, lines);

  float a = (sheen * 0.42 + lines * sheen * 0.3) * u_alpha;
  // barely catches the light where the cursor rests
  a = clamp(a * (1.0 + pull * 0.26), 0.0, 1.0);
  // deep folds keep the cloth colour, lit crests take the highlight
  vec3 col = mix(u_color, u_highlight, smoothstep(0.35, 0.85, v));
  gl_FragColor = vec4(col * a, a);
}
`;

/**
 * Liquid silk — a WebGL sheet of ink-coloured fabric that folds and flows
 * in slow motion, threads of light riding the creases. Runs as a quiet
 * full-bleed backdrop for landing pages.
 */
export function LiquidSilk({
  color,
  highlight,
  speed = 1,
  intensity = 1,
  scale = 1,
  interactive = true,
  className,
  style,
  children,
}: LiquidSilkProps) {
  const inkRgb = useInkRgb();
  const wrapRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    if (!canvasRef.current) return;
    const canvas: HTMLCanvasElement = canvasRef.current;
    const glOrNull = canvas.getContext("webgl", { alpha: true, antialias: false });
    if (!glOrNull) return; // no WebGL — stay a quiet transparent layer
    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 uRes = gl.getUniformLocation(prog, "u_res");
    const uT = gl.getUniformLocation(prog, "u_t");
    const uColor = gl.getUniformLocation(prog, "u_color");
    const uHighlight = gl.getUniformLocation(prog, "u_highlight");
    const uAlpha = gl.getUniformLocation(prog, "u_alpha");
    const uScale = gl.getUniformLocation(prog, "u_scale");
    const uMouse = gl.getUniformLocation(prog, "u_mouse");
    const uGrab = gl.getUniformLocation(prog, "u_grab");

    const triplet = (v: string) => {
      const c = v.split(",").map((n) => parseFloat(n) / 255);
      return [c[0] ?? 1, c[1] ?? 1, c[2] ?? 1] as const;
    };
    const cloth = triplet(color ?? inkRgb);
    const lit = triplet(highlight ?? color ?? inkRgb);
    gl.uniform3f(uColor, cloth[0], cloth[1], cloth[2]);
    gl.uniform3f(uHighlight, lit[0], lit[1], lit[2]);
    gl.uniform1f(uAlpha, intensity);
    gl.uniform1f(uScale, scale);

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

    // Pointer, in 0..1 canvas space with y up. `grab` fades the whole effect
    // in and out so the cloth never snaps when the cursor enters or leaves,
    // and the eased position gives it the lag of real fabric.
    let mx = 0.5;
    let my = 0.5;
    let tx = 0.5;
    let ty = 0.5;
    let grab = 0;
    let grabTarget = 0;

    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.uniform2f(uMouse, mx, my);
      gl.uniform1f(uGrab, grab);
      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;
      mx += (tx - mx) * 0.05;
      my += (ty - my) * 0.05;
      grab += (grabTarget - grab) * 0.04;
      frame();
      rafId = requestAnimationFrame(loop);
    }

    // Reduced motion draws a single frame, so there is no loop to ease the
    // pointer — the cloth simply stays still.
    const live = interactive && !reduced ? wrapRef.current : null;
    const onMove = (e: PointerEvent) => {
      const rect = canvas.getBoundingClientRect();
      if (!rect.width || !rect.height) return;
      tx = (e.clientX - rect.left) / rect.width;
      ty = 1 - (e.clientY - rect.top) / rect.height;
      grabTarget = 1;
    };
    const onLeave = () => {
      grabTarget = 0;
    };
    if (live) {
      live.addEventListener("pointermove", onMove);
      live.addEventListener("pointerleave", onLeave);
      live.addEventListener("pointercancel", onLeave);
    }

    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();
      if (live) {
        live.removeEventListener("pointermove", onMove);
        live.removeEventListener("pointerleave", onLeave);
        live.removeEventListener("pointercancel", onLeave);
      }
      canvas.removeEventListener("webglcontextlost", onLost);
      gl.deleteProgram(prog);
      gl.deleteShader(vs);
      gl.deleteShader(fs);
      gl.deleteBuffer(buf);
    };
  }, [color, highlight, inkRgb, speed, intensity, scale, interactive]);

  return (
    <div
      ref={wrapRef}
      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 LiquidSilk;

```

## Demo

```tsx
"use client";

import React from "react";
import { LiquidSilk } from "../mellow/liquid-silk";

export default function LiquidSilkDemo() {
  return (
    <LiquidSilk 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">
          Woven in a fragment shader
        </p>
        <h2 className="max-w-md [font-family:var(--font-serif)] text-4xl leading-tight text-[var(--ink)] italic">
          Silk, poured slowly
        </h2>
        <p className="max-w-sm text-[0.875rem] leading-relaxed text-[rgba(var(--ink-rgb),0.6)]">
          Domain-warped noise folds like fabric behind your landing page —
          threads of light ride the creases, stirring faintly where the cursor
          rests.
        </p>
      </div>
    </LiquidSilk>
  );
}

```
