{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "liquid-chrome",
  "type": "registry:block",
  "title": "Liquid Chrome",
  "description": "A raymarched body of liquid metal on a white studio sweep, lit like a product shot with softboxes and black flags, so it has crisp chrome bands, lobes that mirror each other and a soft shadow on the paper. It reaches for the pointer; press and pull and a droplet stretches and pinches off.",
  "docs": "Liquid Chrome was added to public/vividsites/liquid-chrome.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/liquid-chrome",
  "categories": [
    "3d",
    "shader-backgrounds"
  ],
  "files": [
    {
      "path": "components/vividsites/liquid-chrome.html",
      "type": "registry:file",
      "target": "public/vividsites/liquid-chrome.html",
      "content": "<!--\n  vs-liquid-chrome — a raymarched body of liquid metal on a studio sweep, reaching for the cursor.\n  Five metaballs churn slowly inside one body; a sixth follows the pointer on a spring, so the\n  surface necks out toward it. Press and pull (or drag a finger) and it stretches further, until a\n  droplet pinches off and is swallowed back on release. The metal mirrors a studio lit like a\n  product shot: a paper sweep of finite width, a big overhead softbox, two tall strip boxes with\n  black flags inside them, and a fill box behind the camera. That is what gives chrome its dark\n  edges and crisp bands on a white set. Lobes mirror each other (one extra bounce), and a soft\n  area-light shadow sits on the paper. A dark stage (the same studio, lights down) is one variable away.\n\n  Fills its nearest positioned parent (position:absolute; inset:0). Put content above it.\n  CSS variables (set on .vs-liquid-chrome):\n    --vs-liquid-chrome-stage     1 = light studio sweep (default), 0 = dark studio; anything between blends\n    --vs-liquid-chrome-bg        the backdrop colour (light default #e9eaec, it sweeps to white at the floor)\n    --vs-liquid-chrome-tint      the metal: #e4e7ec chrome (default), #f2cf86 gold, #6d737c gunmetal …\n    --vs-liquid-chrome-wobble    how liquid it is 0–2: low = thick and slow, high = runny mercury (default 1)\n    --vs-liquid-chrome-size      body size, 1 = default (about half the frame height)\n    --vs-liquid-chrome-x         body centre, -1 (left) … 1 (right); default 0 (ignored on portrait screens)\n    --vs-liquid-chrome-shadow    shadow strength on the floor 0–1 (default .8)\n    --vs-liquid-chrome-film      thin-film sheen at grazing angles 0–1 (default .12)\n    --vs-liquid-chrome-key       cool light colour (left strip box)\n    --vs-liquid-chrome-rim       warm light colour (right strip box)\n    --vs-liquid-chrome-speed     churn speed, 1 = default\n  Pointer: hover to attract, press/drag to pull harder. Touch works the same with a finger.\n  Reduced motion: renders one still frame. No WebGL2: a CSS still of a metal pebble on the sweep.\n-->\n<div class=\"vs-liquid-chrome\" aria-hidden=\"true\"><canvas class=\"vs-liquid-chrome-canvas\"></canvas></div>\n<style>\n  .vs-liquid-chrome {\n    --vs-liquid-chrome-stage: 1;\n    --vs-liquid-chrome-bg: #e9eaec;\n    --vs-liquid-chrome-tint: #e4e7ec;\n    --vs-liquid-chrome-wobble: 1;\n    --vs-liquid-chrome-size: 1;\n    --vs-liquid-chrome-x: 0;\n    --vs-liquid-chrome-shadow: .8;\n    --vs-liquid-chrome-film: .12;\n    --vs-liquid-chrome-key: #a9c8ff;\n    --vs-liquid-chrome-rim: #ffb27a;\n    --vs-liquid-chrome-speed: 1;\n    position: absolute; inset: 0; overflow: hidden; pointer-events: none;\n    background: linear-gradient(var(--vs-liquid-chrome-bg), #fff);\n  }\n  /* static fallback (no WebGL2): a CSS pebble of chrome, with a horizon band and a soft shadow */\n  .vs-liquid-chrome-fallback::before, .vs-liquid-chrome-fallback::after { content: \"\"; position: absolute; left: 50%; translate: -50% -50%; }\n  .vs-liquid-chrome-fallback::before {\n    top: 45%; width: min(34vw, 54vh); aspect-ratio: 1.3; border-radius: 50%;\n    background: radial-gradient(34% 22% at 42% 24%, rgba(255, 255, 255, .95), transparent 70%),\n      linear-gradient(176deg, #eef0f3 0%, #fdfdfd 20%, #4a4e56 36%, #15171a 50%, #8a8f97 53%, #e3e6ea 64%, #ffffff 82%, #c9cdd3 100%);\n    box-shadow: inset 18px 0 34px -10px rgba(10, 11, 14, .75), inset -18px 0 34px -10px rgba(10, 11, 14, .7), inset 0 0 0 1px rgba(10, 11, 14, .18);\n  }\n  .vs-liquid-chrome-fallback::after {\n    top: 75%; width: min(30vw, 48vh); height: min(4.4vw, 7vh); border-radius: 50%;\n    background: radial-gradient(closest-side, rgba(20, 22, 28, .32), transparent);\n  }\n  .vs-liquid-chrome-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity 1s cubic-bezier(.16, 1, .3, 1); }\n  .vs-liquid-chrome-ready .vs-liquid-chrome-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  /* camera: a little above the body, looking slightly down, so the floor and its shadow read */\n  const CAM_Y = .56, CAM_Z = 4.4, LOOK_Y = -.08, FOCAL = 1.62;\n  const FRAG = `\nuniform vec2 uRes; uniform float uTime, uSize, uFilm, uFrame, uWob, uStage, uShadow, uFloor, uFit, uGrab;\nuniform vec3 uPull, uBg, uKey, uRim, uTint, uC0; out vec4 o;\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 smin(float a, float b, float k){ float h = clamp(.5 + .5 * (b - a) / k, 0., 1.); return mix(b, a, h) - k * h * (1. - h); }\nfloat map(vec3 p){\n  float t = uTime, s = uSize;\n  vec3 q = p - uC0;\n  float d = length(q) - .56 * s;\n  for (int i = 0; i < 5; i++){\n    float fi = float(i);\n    vec3 c = vec3(sin(t * (.51 + fi * .13) + fi * 1.9), cos(t * (.43 + fi * .11) + fi * 2.7) * .62, sin(t * (.37 + fi * .17) + fi * 4.1) * .6) * .56 * s;\n    d = smin(d, length(q - c) - (.33 + .07 * sin(t * .7 + fi)) * s, .36 * s);\n  }\n  // the lobe that reaches for the pointer (a little fuller while it is being pulled)\n  d = smin(d, length(p - uPull) - (.24 + .05 * uGrab) * s, .6 * s);\n  // a slow liquid shiver, stronger while it moves\n  d += (.006 + uWob * .018) * s * sin(q.x * 8. / s + t * 2.1) * sin(q.y * 7. / s - t * 1.7) * sin(q.z * 6. / s + t * 1.3);\n  return d;\n}\nvec3 nrm(vec3 p){ vec2 e = vec2(.0015, -.0015) * uSize; return normalize(e.xyy * map(p + e.xyy) + e.yyx * map(p + e.yyx) + e.yxy * map(p + e.yxy) + e.xxx * map(p + e.xxx)); }\nfloat box(float x, float a, float b, float w){ return smoothstep(a - w, a + w, x) - smoothstep(b - w, b + w, x); }\n/* the studio, as the metal sees it. ax: azimuth (0 = toward the backdrop, ±pi = behind the camera),\n   el: elevation. w: edge softness, wider at grazing angles where the mirror compresses the world */\nvec3 env(vec3 r, float w){\n  float ax = atan(r.x, -r.z), aax = abs(ax), y = clamp(r.y, -1., 1.), el = asin(y);\n  float lite = mix(.3, 1., uStage);\n  // above the horizon: the grey room out of frame, lighter toward the ceiling\n  vec3 c = mix(vec3(.045, .047, .053), vec3(.1, .104, .114), smoothstep(.0, .7, el)) * lite;\n  // the paper backdrop in front: a finite sheet, the backdrop colour\n  vec3 hi = mix(uBg * 1.2 + .004, uBg * 1.02, uStage);\n  c = mix(c, hi, smoothstep(.78 + w, .7 - w, aax) * smoothstep(.56 + w, .5 - w, el));\n  // below the horizon: the paper floor runs all round (a hard line where it meets the grey walls),\n  // greying off toward the camera, with the body's own shadow right underneath\n  // (lights down: the paper is dark, with a pool of light from the overhead box right underneath)\n  vec3 lo = mix(uBg * 1.5 + .012 + vec3(.1) * smoothstep(-.35, -1.1, el), vec3(.97), uStage) * mix(1., .55, smoothstep(-.8, -.04, el));\n  lo *= mix(1., .6, smoothstep(-.9, -1.4, el));\n  float fl = smoothstep(w * .6, -w * .6, el - .02 * smoothstep(1., .5, aax));\n  // the paper's front edge: toward the camera it ends, and the dark studio floor shows beyond it\n  float edge = mix(-.03, -.36, smoothstep(.9, 2.5, aax));\n  lo = mix(lo, vec3(.06, .062, .066) * lite, smoothstep(edge - w, edge + w, el) * smoothstep(.8, 1.3, aax));\n  c = mix(c, lo, max(fl, smoothstep(.72, .6, aax) * smoothstep(.3, .0, el)));\n  // lights down, a thin bright horizon where the dark paper turns up into the wall\n  c += (1. - uStage) * vec3(1.9, 1.94, 2.05) * exp(-pow((el - .01) * 26., 2.)) * smoothstep(1.2, .6, aax);\n  // black flags beside the backdrop: this is what draws the dark edge on a white set\n  c *= 1. - .94 * box(aax, .84, 1.2, w) * box(el, -.3, 1., w);\n  // two tall strip boxes, cool on the left, warm on the right, brighter at the middle of their height\n  float strip = box(aax, 1.34, 1.52, w * .7) * box(el, .04, .95, w) * (.72 + .28 * cos(el * 2.4 - .5));\n  c = mix(c, mix(vec3(1.), ax < 0. ? uKey : uRim, .35) * 4.4, strip);\n  // a big overhead softbox with a brighter core and a hard edge\n  vec2 sb = vec2(r.x + .1 * y, r.z - .05 * y) / max(y, .05);\n  vec2 sq = abs(sb) - vec2(.62, .44);\n  float over = smoothstep(w * 2., -w * 2., max(sq.x, sq.y)) * step(.2, y);\n  c = mix(c, vec3(3.2) + 2.4 * exp(-dot(sb, sb) * 3.), over);\n  // a fill box behind the camera, split by the camera itself\n  float fill = box(aax, 2.3, 3.2, w) * box(el, .07, .5, w) * smoothstep(.035 - w, .035 + w, abs(aax - 3.1416));\n  c = mix(c, vec3(1.9, 1.92, 2.) * mix(.6, 1., uStage), fill);\n  return c;\n}\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)); }\nvec3 aces(vec3 x){ x = max(x, 0.); return clamp((x * (2.51 * x + .03)) / (x * (2.43 * x + .59) + .14), 0., 1.); }\nvoid main(){\n  vec2 uv = gl_FragCoord.xy / uRes; float asp = uRes.x / uRes.y;\n  vec2 sp = (uv - .5) * vec2(asp, 1.);\n  vec3 ro = vec3(0., ${CAM_Y} * uFit, ${CAM_Z} * uFit);\n  vec3 fw = normalize(vec3(0., ${LOOK_Y}, 0.) - ro), rt = normalize(cross(fw, vec3(0., 1., 0.))), up = cross(rt, fw);\n  vec3 rd = normalize(fw * ${FOCAL} + rt * sp.x + up * sp.y);\n  float s = uSize;\n  // the backdrop as seen directly, in display space so the sweep is exactly the colour asked for\n  vec3 bgL = mix(vec3(1.), toSRGB(uBg), smoothstep(.12, .98, uv.y));\n  bgL *= 1. + .03 * exp(-dot((sp - vec2(0., .05)) * vec2(.6, 1.), (sp - vec2(0., .05)) * vec2(.6, 1.)) * 4.);\n  vec3 bgD = uBg + vec3(.028, .029, .033) * smoothstep(.75, -.15, length((sp - vec2(0., -.34)) * vec2(.55, 1.6)));\n  bgD += uKey * .012 * exp(-length((sp - vec2(0., .1)) * vec2(.8, 1.2)) * 2.2);\n  // the floor under the body: a soft shadow from the overhead box (cone-traced through the field),\n  // and a tight contact darkening where the metal nearly touches the paper\n  float shade = 1.;\n  if (rd.y < 0.){\n    float tf = (uFloor - ro.y) / rd.y; vec3 P = ro + rd * tf;\n    if (length(P.xz - uC0.xz) < 3. * s){\n      vec3 L = normalize(vec3(.1, 1., .22));\n      float res = 1., h = .03 * s;\n      for (int i = 0; i < 16; i++){ float d = map(P + L * h); res = min(res, d / (h * .2)); h += clamp(d, .04 * s, .3 * s); if (res < .002 || h > 2.6 * s) break; }\n      res = clamp(res, 0., 1.); res = res * res * (3. - 2. * res);\n      float ao = 0., wt = 1.;\n      for (int i = 1; i <= 3; i++){ float hh = float(i) * .12 * s; ao += wt * max(hh - map(P + vec3(0., hh, 0.)), 0.) / hh; wt *= .6; }\n      float a = clamp(ao, 0., 1.); shade = mix(1., mix(.62, 1., res) * (1. - .35 * a), uShadow);\n    }\n  }\n  vec3 shTint = mix(vec3(1.), vec3(.93, .95, 1.), 1. - shade);      // paper shadows go slightly cool\n  bgL *= mix(vec3(1.), shTint * shade, 1.); bgD *= mix(1., shade, .85);\n  vec3 col = mix(toSRGB(bgD), bgL, uStage);\n  // march, only across the sphere that can hold the body and its furthest droplet\n  float t = 2.4 * uFit, d = 0., md = 1e9, mt = 0.; bool hit = false;\n  float px = 1.5 / (uRes.y * ${FOCAL});\n  vec3 oc = ro - uC0; float bb = dot(oc, rd), bc = dot(oc, oc) - 6.8 * s * s, disc = bb * bb - bc;\n  int steps = disc > 0. ? 96 : 0;\n  if (disc > 0.) t = max(t, -bb - sqrt(disc));\n  for (int i = 0; i < 96; i++){\n    if (i >= steps) break;\n    vec3 p = ro + rd * t; d = map(p);\n    if (d / t < md){ md = d / t; mt = t; }\n    if (d < .0006 * t){ hit = true; break; }\n    t += d * .9; if (t > 9. * uFit) break;\n  }\n  float cover = hit ? 1. : smoothstep(px, 0., md);\n  if (cover > 0.){\n    if (!hit) t = mt;\n    vec3 p = ro + rd * t, n = nrm(p);\n    vec3 r = reflect(rd, n);\n    float fr = clamp(1. + dot(rd, n), 0., 1.);\n    vec3 f0 = uTint * mix(.8, .9, uStage);\n    vec3 F = f0 + (1. - f0) * pow(fr, 5.);\n    float w = .012 + .06 * fr * fr;\n    // one extra bounce: where the reflected ray meets the body again, the lobes mirror each other\n    float t2 = .03 * s; bool h2 = false; vec3 q = p;\n    for (int i = 0; i < 28; i++){ q = p + r * t2; float d2 = map(q); if (d2 < .002 * s){ h2 = true; break; } t2 += d2; if (t2 > 2.4 * s) break; }\n    vec3 c;\n    if (h2){ vec3 n2 = nrm(q); vec3 r2 = reflect(r, n2); float f2 = clamp(1. + dot(r, n2), 0., 1.); c = env(r2, w * 2.) * (f0 + (1. - f0) * pow(f2, 5.)) * .92; }\n    else {\n      // a faint prism split at grazing angles, riding on the film amount\n      vec3 b = n * (.05 * uFilm * fr);\n      c = vec3(env(normalize(r + b), w).r, env(r, w).g, env(normalize(r - b), w).b);\n    }\n    c *= F;\n    // crevice darkening where the lobes meet\n    float ao = clamp(map(p + n * .08 * s) / (.08 * s), 0., 1.) * .5 + clamp(map(p + n * .22 * s) / (.22 * s), 0., 1.) * .5;\n    c *= .55 + .45 * ao;\n    // thin-film sheen: a soft oil-on-water shift toward the rim\n    vec3 film = .5 + .5 * cos(6.2832 * (fr * 1.4 + vec3(0., .33, .67)));\n    c = mix(c, c * film * 1.6, uFilm * smoothstep(.35, .95, fr) * .6);\n    col = mix(col, toSRGB(aces(c * 1.05)), cover);\n  }\n  // a whisper of vignette on the dark stage only, and fine grain against banding on both\n  vec2 v = abs(uv - .5) * 2.; col *= 1. - (1. - uStage) * .35 * smoothstep(0., 1., length(max(v - .4, 0.)) / .8);\n  col += (h12(gl_FragCoord.xy + floor(uFrame) * vec2(17.3, 41.9)) - .5) * mix(.018, .01, uStage);\n  o = vec4(clamp(col, 0., 1.), 1.);\n}`;\n  /* small vector helpers for projecting the pointer into the scene the same way the shader does */\n  const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]], nz = (a) => { const l = Math.hypot(...a) || 1; return [a[0] / l, a[1] / l, a[2] / l]; };\n  const crs = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];\n  document.querySelectorAll('.vs-liquid-chrome:not(.vs-liquid-chrome-ready)').forEach((root) => {\n    let pr; const pull = { x: 0, y: 0, vx: 0, vy: 0, init: false }; let wob = 0, idleK = 1, grab = 0, grabT = 0;\n    VS(root, {\n      prefix: 'vs-liquid-chrome', t0: 8,\n      vars: { stage: 1, bg: '#e9eaec', tint: '#e4e7ec', wobble: 1, size: 1, x: 0, shadow: .8, film: .12, key: '#a9c8ff', rim: '#ffb27a', speed: 1 },\n      scale: () => Math.min(1, 1.4 / Math.min(window.devicePixelRatio || 1, 2)),\n      init(S) { pr = S.program(FRAG); },\n      press(S, on) { grabT = on ? 1 : 0; },\n      frame(S, dt) {\n        const P = S.ptr, asp = S.aspect, fit = Math.max(1, .95 / asp), sz = Math.max(.3, S.css.size);\n        const wv = Math.max(0, Math.min(2, S.css.wobble));\n        const cx = asp < 1 ? 0 : Math.max(-1, Math.min(1, S.css.x)) * asp * .9, cy = 0, floorY = -.9 * sz;\n        // pressing (or a finger on the glass) pulls harder: the reach grows until a droplet pinches off\n        if (P.down) grabT = 1;\n        grab += (grabT - grab) * Math.min(1, dt * (grabT ? 6 : 3));\n        // the pointer, as a ray from the camera onto the plane through the body (z = .1)\n        const ro = [0, CAM_Y * fit, CAM_Z * fit], fw = nz(sub([0, LOOK_Y, 0], ro)), rt = nz(crs(fw, [0, 1, 0])), up = crs(rt, fw);\n        const px = (P.x - .5) * asp, py = P.y - .5;\n        const rd = nz([fw[0] * FOCAL + rt[0] * px + up[0] * py, fw[1] * FOCAL + rt[1] * px + up[1] * py, fw[2] * FOCAL + rt[2] * px + up[2] * py]);\n        const tp = (.1 - ro[2]) / rd[2];\n        let tx = ro[0] + rd[0] * tp, ty = ro[1] + rd[1] * tp;\n        // follow while the pointer is around; drift on its own after a few still seconds\n        const still = (performance.now() - (P.last || -1e9)) / 1000;\n        idleK += ((still > 3.5 && !P.down) || !P.seen ? 1 - idleK : -idleK) * Math.min(1, dt * 1.6);\n        const wx = cx + Math.cos(S.t * .5) * .8 * sz, wy = cy + .1 + Math.sin(S.t * .8) * .42 * sz;\n        tx = tx * (1 - idleK) + wx * idleK; ty = ty * (1 - idleK) + wy * idleK;\n        // keep it within reach (close enough to neck, far enough to pinch off) and above the floor\n        let dx = tx - cx, dy = ty - cy; const dl = Math.hypot(dx, dy), mx = (1.2 + .9 * grab) * sz;\n        if (dl > mx) { dx *= mx / dl; dy *= mx / dl; }\n        tx = cx + dx; ty = Math.max(floorY + .3 * sz, cy + dy);\n        if (!pull.init) { pull.x = tx; pull.y = ty; pull.init = true; }\n        // the spring: thicker metal is stiffer and better damped, runnier metal overshoots and jiggles\n        const k = 70 + 40 * (1 - wv * .5), c = 15 - 5.5 * wv;\n        const n = Math.ceil(dt / (1 / 120));\n        for (let i = 0; i < n; i++) { const h = dt / n; pull.vx += ((tx - pull.x) * k - pull.vx * c) * h; pull.vy += ((ty - pull.y) * k - pull.vy * c) * h; pull.x += pull.vx * h; pull.y += pull.vy * h; }\n        wob += (Math.min(1, Math.hypot(pull.vx, pull.vy) * .4) * wv - wob) * Math.min(1, dt * 4);\n        S.draw(pr, null, { uRes: [S.w, S.h], uTime: S.t, uSize: sz, uFilm: S.css.film, uFrame: Math.floor(S.rt * 24), uWob: wob, uStage: Math.max(0, Math.min(1, S.css.stage)),\n          uShadow: Math.max(0, Math.min(1, S.css.shadow)), uFloor: floorY, uFit: fit, uGrab: grab,\n          uPull: [pull.x, pull.y, .1], uC0: [cx, cy, 0], uBg: S.css.bg, uKey: S.css.key, uRim: S.css.rim, uTint: S.css.tint });\n      },\n      resize() { pull.init = false; },\n    });\n  });\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "Shader backgrounds",
    "tags": [
      "chrome",
      "liquid metal",
      "mercury",
      "metaballs",
      "raymarching",
      "studio",
      "reflection",
      "webgl",
      "shader",
      "background",
      "interactive",
      "light"
    ],
    "tech": [
      "WebGL2",
      "GLSL",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@100,400;125,400&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#c9ced6",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/liquid-chrome/index.html",
    "thumbnail": "https://vividsites.app/api/component/liquid-chrome/thumb.jpg",
    "video": "https://vividsites.app/api/component/liquid-chrome/thumb.mp4",
    "page": "https://vividsites.app/c/liquid-chrome"
  }
}