{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "iridescent-blob",
  "type": "registry:block",
  "title": "Iridescent Blob",
  "description": "A soap-film blob whose colours come from real thin-film interference over eight wavelengths, with a film that swirls and drains. It leans toward the pointer, spins when dragged and wobbles when poked; bubble or pearl.",
  "docs": "Iridescent Blob was added to public/vividsites/iridescent-blob.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/iridescent-blob",
  "categories": [
    "3d",
    "3d-objects"
  ],
  "files": [
    {
      "path": "components/vividsites/iridescent-blob.html",
      "type": "registry:file",
      "target": "public/vividsites/iridescent-blob.html",
      "content": "<!--\n  vividsites · iridescent-blob\n  A soap-film blob. Its colours are computed, not painted: thin-film interference\n  across eight wavelengths for a film whose thickness swirls, drains toward the\n  bottom and thins at the top, just like a real bubble; it reflects a studio of\n  softboxes and lets the stage show through. It leans toward the pointer, spins when\n  dragged, and wobbles when poked (click or tap).\n\n  Customise (attributes, read at mount; all live through the JS API too):\n    data-mode    bubble | pearl                            (see-through film, or an opaque pearl; default bubble)\n    data-stage   dark | light                              (default dark)\n  CSS variables:\n    --vs-iridescent-blob-bg      the stage                  (dark #0b0d10, light #e8e6e1)\n    --vs-iridescent-blob-film    film thickness bias, 0 – 1 (shifts the palette; 0.68)\n    --vs-iridescent-blob-wobble  surface motion, 0 – 2      (1)\n    --vs-iridescent-blob-height  component height           (100svh)\n  JS:\n    el.vsIridescentBlob.set({ mode: 'pearl', film: 0.7, wobble: 1.4, stage: 'light' })\n    el.vsIridescentBlob.poke()      el.vsIridescentBlob.destroy()\n  Needs: three@0.170.0 (importmap below). Remove the importmap if your page already maps \"three\".\n-->\n<div class=\"vs-iridescent-blob\" data-mode=\"bubble\" data-stage=\"dark\" tabindex=\"0\" role=\"img\" aria-label=\"An iridescent soap-film blob. Click to poke it.\">\n  <canvas class=\"vs-iridescent-blob-canvas\"></canvas>\n  <div class=\"vs-iridescent-blob-fallback\" aria-hidden=\"true\"><i></i></div>\n</div>\n\n<style>\n  .vs-iridescent-blob {\n    --vs-iridescent-blob-bg: #0b0d10;\n    --vs-iridescent-blob-film: .68;\n    --vs-iridescent-blob-wobble: 1;\n    --vs-iridescent-blob-height: 100svh;\n    position: relative; width: 100%; height: var(--vs-iridescent-blob-height); min-height: 380px; overflow: hidden; isolation: isolate;\n    background: var(--vs-iridescent-blob-bg); cursor: grab; outline: none; touch-action: pan-y; -webkit-tap-highlight-color: transparent;\n    transition: background-color .7s cubic-bezier(.16,1,.3,1);\n  }\n  .vs-iridescent-blob[data-stage=\"light\"] { --vs-iridescent-blob-bg: #e8e6e1; }\n  .vs-iridescent-blob.is-dragging { cursor: grabbing; }\n  .vs-iridescent-blob:focus-visible { box-shadow: inset 0 0 0 2px rgba(160,200,255,.7); }\n  .vs-iridescent-blob-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transform: scale(.9);\n    transition: opacity 1.2s cubic-bezier(.16,1,.3,1), transform 1.8s cubic-bezier(.34,1.4,.64,1); }\n  .vs-iridescent-blob[data-vs-state=\"ready\"] .vs-iridescent-blob-canvas { opacity: 1; transform: none; }\n  .vs-iridescent-blob-fallback { position: absolute; inset: 0; display: none; place-items: center; }\n  .vs-iridescent-blob-fallback i { width: min(58vmin, 520px); aspect-ratio: 1; border-radius: 50%;\n    background: radial-gradient(circle at 34% 28%, rgba(255,255,255,.85) 0 2%, transparent 12%),\n      conic-gradient(from 40deg, #ff9ad5, #ffe08a, #9dffc9, #8fd3ff, #c6a6ff, #ff9ad5);\n    -webkit-mask: radial-gradient(circle, transparent 52%, #000 72%); mask: radial-gradient(circle, transparent 52%, #000 72%); opacity: .75; }\n  .vs-iridescent-blob[data-vs-state=\"nogl\"] .vs-iridescent-blob-fallback { display: grid; }\n  @media (prefers-reduced-motion: reduce) { .vs-iridescent-blob-canvas { transition: opacity .3s; transform: none; } }\n</style>\n\n<script type=\"importmap\">{ \"imports\": { \"three\": \"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js\" } }</script>\n<script type=\"module\">\nimport * as THREE from 'three';\n\nconst NOISE = /* glsl */`\n  vec3 mod289(vec3 x){ return x - floor(x * (1.0 / 289.0)) * 289.0; }\n  vec4 mod289(vec4 x){ return x - floor(x * (1.0 / 289.0)) * 289.0; }\n  vec4 permute(vec4 x){ return mod289(((x * 34.0) + 1.0) * x); }\n  vec4 taylorInvSqrt(vec4 r){ return 1.79284291400159 - 0.85373472095314 * r; }\n  float snoise(vec3 v){\n    const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0); const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);\n    vec3 i = floor(v + dot(v, C.yyy)); vec3 x0 = v - i + dot(i, C.xxx);\n    vec3 g = step(x0.yzx, x0.xyz); vec3 l = 1.0 - g; vec3 i1 = min(g.xyz, l.zxy); vec3 i2 = max(g.xyz, l.zxy);\n    vec3 x1 = x0 - i1 + C.xxx; vec3 x2 = x0 - i2 + C.yyy; vec3 x3 = x0 - D.yyy;\n    i = mod289(i);\n    vec4 p = permute(permute(permute(i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0));\n    float n_ = 0.142857142857; vec3 ns = n_ * D.wyz - D.xzx;\n    vec4 j = p - 49.0 * floor(p * ns.z * ns.z); vec4 x_ = floor(j * ns.z); vec4 y_ = floor(j - 7.0 * x_);\n    vec4 x = x_ * ns.x + ns.yyyy; vec4 y = y_ * ns.x + ns.yyyy; vec4 h = 1.0 - abs(x) - abs(y);\n    vec4 b0 = vec4(x.xy, y.xy); vec4 b1 = vec4(x.zw, y.zw);\n    vec4 s0 = floor(b0) * 2.0 + 1.0; vec4 s1 = floor(b1) * 2.0 + 1.0; vec4 sh = -step(h, vec4(0.0));\n    vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;\n    vec3 p0 = vec3(a0.xy, h.x); vec3 p1 = vec3(a0.zw, h.y); vec3 p2 = vec3(a1.xy, h.z); vec3 p3 = vec3(a1.zw, h.w);\n    vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));\n    p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;\n    vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); m = m * m;\n    return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));\n  }`;\n\nconst VERT = /* glsl */`\n  uniform float uTime, uWob, uPoke, uLean; uniform vec3 uPtr;\n  varying vec3 vW, vN, vObj;\n  ${NOISE}\n  vec3 disp(vec3 p){\n    float n = snoise(p * 0.75 + vec3(0.0, uTime * 0.2, uTime * 0.11)) * 0.11 + snoise(p * 1.6 - vec3(uTime * 0.26)) * 0.03;\n    n *= uWob;\n    n += sin(p.y * 4.0 + uTime * 8.0) * cos(p.x * 3.5 - uTime * 6.0) * 0.045 * uPoke;  // the poke: a ringing wobble\n    vec3 dp = normalize(p) - uPtr; float lean = exp(-dot(dp, dp) * 2.4) * 0.2 * uLean;     // bulge toward the pointer\n    return p * (1.0 + n + lean);\n  }\n  void main(){\n    vec3 p = normalize(position);\n    vec3 t = normalize(abs(p.y) < 0.99 ? cross(p, vec3(0.0, 1.0, 0.0)) : cross(p, vec3(1.0, 0.0, 0.0)));\n    vec3 b = cross(p, t); float e = 0.01;\n    vec3 d0 = disp(p), d1 = disp(normalize(p + t * e)), d2 = disp(normalize(p + b * e));\n    vec3 n = normalize(cross(d1 - d0, d2 - d0));\n    if (dot(n, p) < 0.0) n = -n;\n    vObj = p;\n    vec4 w = modelMatrix * vec4(d0, 1.0); vW = w.xyz;\n    vN = normalize(mat3(modelMatrix) * n);\n    gl_Position = projectionMatrix * viewMatrix * w;\n  }`;\n\nconst FRAG = /* glsl */`\n  uniform vec3 uBg; uniform float uTime, uFilm, uDark, uPearl;\n  varying vec3 vW, vN, vObj;\n  ${NOISE}\n  float pw(float x, float e){ return pow(max(x, 0.0), e); }\n  float g(float x, float m, float s){ float t = (x - m) / s; return exp(-t * t); }\n  // eight wavelengths, weighted into RGB by rough colour-matching lobes\n  vec3 film(float d, float cosI){\n    float n = 1.33;\n    float cosT = sqrt(max(1.0 - (1.0 - cosI * cosI) / (n * n), 0.0));\n    float opd = 2.0 * n * d * cosT;\n    vec3 acc = vec3(0.0), ws = vec3(0.0);\n    for (int i = 0; i < 8; i++) {\n      float l = 400.0 + float(i) * 42.857;\n      float r = 0.5 - 0.5 * cos(6.28318 * opd / l);\n      vec3 w = vec3(g(l, 600.0, 48.0) + 0.3 * g(l, 430.0, 22.0), g(l, 545.0, 44.0), g(l, 450.0, 32.0));\n      acc += r * w; ws += w;\n    }\n    vec3 c = acc / ws;\n    float m = dot(c, vec3(0.333));\n    return clamp(mix(vec3(m), c, 1.25), 0.0, 1.0);\n  }\n  // a studio: overhead softbox, a strip left, a warm kicker right, a floor\n  vec3 env(vec3 r){\n    vec3 c = mix(vec3(0.03, 0.035, 0.045), vec3(0.3, 0.3, 0.29), 1.0 - uDark);\n    c += vec3(1.4) * smoothstep(0.35, 0.6, r.y) * smoothstep(0.95, 0.4, abs(r.x + 0.15));\n    c += vec3(1.1, 1.15, 1.25) * smoothstep(-0.55, -0.8, r.x) * smoothstep(0.75, 0.25, abs(r.y));\n    c += vec3(0.5) * smoothstep(0.2, 0.6, r.z) * smoothstep(0.2, -0.3, r.y) * 0.6;\n    c += vec3(1.1, 0.8, 0.55) * 0.7 * smoothstep(0.75, 0.92, r.x) * smoothstep(0.5, 0.0, abs(r.y - 0.1));\n    c += mix(vec3(0.09, 0.08, 0.07), vec3(0.3), 1.0 - uDark) * smoothstep(-0.2, -0.6, r.y);\n    return c;\n  }\n  void main(){\n    vec3 n = normalize(vN), v = normalize(cameraPosition - vW);\n    if (!gl_FrontFacing) n = -n;\n    float cosI = clamp(dot(n, v), 0.0, 1.0);\n    // thickness: swirls, thinner at the top (the film drains), thicker at the bottom\n    vec3 q = vObj + 0.35 * vec3(snoise(vObj * 0.9 + uTime * 0.07), snoise(vObj * 0.9 + 7.0 - uTime * 0.05), 0.0);\n    float sw = snoise(q * 1.2 + vec3(0.0, -uTime * 0.09, uTime * 0.04));\n    float d = mix(160.0, 560.0, uFilm) + (-q.y) * 270.0 + sw * 110.0;\n    d = max(d, 20.0);\n    vec3 R = film(d, cosI);\n    vec3 rv = reflect(-v, n);\n    vec3 E = env(rv);\n    float F = 0.05 + 0.95 * pw(1.0 - cosI, 2.2);\n    vec3 bubble = uBg * (1.0 - R * F * mix(1.1, 0.7, uDark)) + R * (E * F * 1.7 + F * 0.1);\n    // pearl: opaque nacre, the same film over a soft body\n    vec3 body = mix(vec3(0.16, 0.16, 0.18), vec3(0.86, 0.85, 0.83), 1.0 - uDark * 0.4) * (0.5 + 0.5 * dot(n, normalize(vec3(-0.4, 0.8, 0.5))));\n    vec3 pearl = body * mix(vec3(1.0), R * 1.1 + 0.3, 0.55) + R * E * 0.35 * (0.3 + F);\n    vec3 col = mix(bubble, pearl, uPearl);\n    // the sharp window highlight\n    col += vec3(1.0) * pw(max(dot(rv, normalize(vec3(-0.45, 0.75, 0.5))), 0.0), 220.0) * 1.2;\n    col += vec3(1.0) * pw(max(dot(rv, normalize(vec3(0.6, -0.2, 0.75))), 0.0), 400.0) * 0.5;\n    gl_FragColor = vec4(col, 1.0);\n    #include <colorspace_fragment>\n  }`;\n\nclass Spring {\n  constructor(x, k = 120, c = 18) { this.x = x; this.v = 0; this.t = x; this.k = k; this.c = c; }\n  step(dt) { const n = Math.ceil(dt / (1 / 240)); const h = dt / n; for (let i = 0; i < n; i++) { this.v += (-this.k * (this.x - this.t) - this.c * this.v) * h; this.x += this.v * h; } return this.x; }\n}\nconst css = (el, n, f) => getComputedStyle(el).getPropertyValue(n).trim() || f;\n\nfunction mount(el) {\n  const canvas = el.querySelector('.vs-iridescent-blob-canvas');\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  let renderer;\n  try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' }); } catch (e) { el.dataset.vsState = 'nogl'; return null; }\n  if (!renderer.getContext()) { el.dataset.vsState = 'nogl'; return null; }\n  const bg = new THREE.Color(css(el, '--vs-iridescent-blob-bg', '#0b0d10')), bgTo = bg.clone();\n  renderer.setClearColor(bg, 1);\n  const scene = new THREE.Scene();\n  const camera = new THREE.PerspectiveCamera(30, 1, 0.1, 50);\n  const U = { uBg: { value: bg }, uTime: { value: 0 }, uFilm: { value: parseFloat(css(el, '--vs-iridescent-blob-film', '.68')) }, uDark: { value: el.dataset.stage === 'light' ? 0 : 1 },\n    uPearl: { value: el.dataset.mode === 'pearl' ? 1 : 0 }, uWob: { value: 1 }, uPoke: { value: 0 }, uLean: { value: 0 }, uPtr: { value: new THREE.Vector3(0, 0, 1) } };\n  const mat = new THREE.ShaderMaterial({ uniforms: U, vertexShader: VERT, fragmentShader: FRAG });\n  const blob = new THREE.Mesh(new THREE.IcosahedronGeometry(1, 72), mat);\n  scene.add(blob);\n\n  const S = { vis: true, raf: 0, last: 0, t: 0, drag: null, wob: parseFloat(css(el, '--vs-iridescent-blob-wobble', '1')) };\n  const sp = { dark: new Spring(U.uDark.value, 60, 15), pearl: new Spring(U.uPearl.value, 60, 14), film: new Spring(U.uFilm.value, 50, 14), wob: new Spring(S.wob, 60, 14),\n    poke: new Spring(0, 60, 3.2), lean: new Spring(0, 90, 14), px: new Spring(0, 80, 14), py: new Spring(0, 80, 14), ry: new Spring(0, 40, 9), rx: new Spring(0, 40, 9), s: new Spring(1, 200, 9) };\n\n  function resize() {\n    const w = el.clientWidth, h = el.clientHeight; if (!w || !h) return;\n    renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.setSize(w, h, false);\n    camera.aspect = w / h;\n    const fill = w < h ? 0.78 : 0.62, vh = Math.max(2.3 / fill, 2.3 / fill / camera.aspect);\n    camera.position.set(0, 0, vh / 2 / Math.tan(THREE.MathUtils.degToRad(camera.fov / 2))); camera.lookAt(0, 0, 0); camera.updateProjectionMatrix();\n  }\n  const ndc = (e) => { const r = el.getBoundingClientRect(); return { x: (e.clientX - r.left) / r.width * 2 - 1, y: -((e.clientY - r.top) / r.height * 2 - 1) }; };\n  const onMove = (e) => {\n    const p = ndc(e); sp.px.t = p.x * camera.aspect * 0.9; sp.py.t = p.y * 0.9; sp.lean.t = 1;\n    if (S.drag) { const dx = e.clientX - S.drag.x, dy = e.clientY - S.drag.y; S.drag.m += Math.abs(dx) + Math.abs(dy); sp.ry.t += dx * 0.008; sp.rx.t += dy * 0.008; S.drag.x = e.clientX; S.drag.y = e.clientY; if (S.drag.m > 5) el.classList.add('is-dragging'); }\n  };\n  const onDown = (e) => { S.drag = { x: e.clientX, y: e.clientY, m: 0 }; sp.s.t = 0.95; try { el.setPointerCapture(e.pointerId); } catch (_) {} onMove(e); };\n  const onUp = () => { if (S.drag && S.drag.m < 6) poke(); S.drag = null; sp.s.t = 1; el.classList.remove('is-dragging'); };\n  const onLeave = (e) => { if (e.pointerType === 'mouse' && !S.drag) sp.lean.t = 0; };\n  const onKey = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); poke(); } };\n  function poke() { sp.poke.v += 9; sp.s.v += 1.8; }\n  el.addEventListener('pointermove', onMove); el.addEventListener('pointerdown', onDown); el.addEventListener('pointerup', onUp);\n  el.addEventListener('pointercancel', onUp); el.addEventListener('pointerleave', onLeave); el.addEventListener('keydown', onKey);\n\n  function frame(now) {\n    S.raf = 0; if (!S.vis) return;\n    const dt = Math.min(0.05, S.last ? (now - S.last) / 1000 : 0.016); S.last = now;\n    if (!reduced) S.t += dt;\n    U.uTime.value = S.t;\n    bg.lerp(bgTo, 1 - Math.exp(-dt * 6)); renderer.setClearColor(bg, 1);\n    U.uDark.value = sp.dark.step(dt); U.uPearl.value = sp.pearl.step(dt); U.uFilm.value = sp.film.step(dt); U.uWob.value = sp.wob.step(dt) * (reduced ? 0.4 : 1);\n    U.uPoke.value = Math.max(-1.5, Math.min(1.5, sp.poke.step(dt)));\n    U.uLean.value = sp.lean.step(dt);\n    const ptr = new THREE.Vector3(sp.px.step(dt), sp.py.step(dt), 1.1).normalize();\n    blob.updateMatrixWorld(); U.uPtr.value.copy(ptr.applyQuaternion(blob.quaternion.clone().invert()));\n    if (!S.drag && !reduced) sp.ry.t += dt * 0.12;\n    blob.rotation.set(sp.rx.step(dt), sp.ry.step(dt), 0);\n    blob.scale.setScalar(sp.s.step(dt));\n    renderer.render(scene, camera);\n    loop();\n  }\n  const loop = () => { if (!S.raf && S.vis) S.raf = requestAnimationFrame(frame); };\n  const ro = new ResizeObserver(resize); ro.observe(el); resize();\n  const io = new IntersectionObserver(([e]) => { S.vis = e.isIntersecting; if (S.vis) { S.last = 0; loop(); } }, { rootMargin: '100px' }); io.observe(el);\n  const onVis = () => { S.vis = !document.hidden; if (S.vis) { S.last = 0; loop(); } };\n  document.addEventListener('visibilitychange', onVis);\n  canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); cancelAnimationFrame(S.raf); S.raf = 0; S.vis = false; });\n  canvas.addEventListener('webglcontextrestored', () => { S.vis = true; S.last = 0; resize(); loop(); });\n  renderer.compile(scene, camera);\n  requestAnimationFrame(() => { el.dataset.vsState = 'ready'; loop(); });\n\n  function set(o = {}) {\n    if (o.mode) { el.dataset.mode = o.mode; sp.pearl.t = o.mode === 'pearl' ? 1 : 0; sp.s.v += 1; }\n    if (o.stage) { el.dataset.stage = o.stage; sp.dark.t = o.stage === 'light' ? 0 : 1; requestAnimationFrame(() => bgTo.set(css(el, '--vs-iridescent-blob-bg', '#0b0d10'))); }\n    if (o.film != null) { sp.film.t = +o.film; el.style.setProperty('--vs-iridescent-blob-film', o.film); }\n    if (o.wobble != null) { sp.wob.t = +o.wobble; el.style.setProperty('--vs-iridescent-blob-wobble', o.wobble); }\n  }\n  function destroy() {\n    cancelAnimationFrame(S.raf); S.vis = false; ro.disconnect(); io.disconnect(); document.removeEventListener('visibilitychange', onVis);\n    ['pointermove', 'pointerdown', 'pointerup', 'pointercancel', 'pointerleave', 'keydown'].forEach((t, i) => el.removeEventListener(t, [onMove, onDown, onUp, onUp, onLeave, onKey][i]));\n    blob.geometry.dispose(); mat.dispose(); renderer.dispose(); delete el.vsIridescentBlob;\n  }\n  sp.wob.x = sp.wob.t = S.wob;\n  return { set, poke, destroy };\n}\n\ndocument.querySelectorAll('.vs-iridescent-blob').forEach((el) => { if (!el.vsIridescentBlob) { const api = mount(el); if (api) el.vsIridescentBlob = api; } });\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D objects",
    "tags": [
      "iridescent",
      "soap bubble",
      "thin film",
      "blob",
      "noise",
      "pearl",
      "three.js"
    ],
    "tech": [
      "three.js",
      "GLSL",
      "JS",
      "CSS"
    ],
    "deps": [
      "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js"
    ],
    "source": null,
    "free": true,
    "accent": "#8fd3ff",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/iridescent-blob/index.html",
    "thumbnail": "https://vividsites.app/api/component/iridescent-blob/thumb.jpg",
    "video": "https://vividsites.app/api/component/iridescent-blob/thumb.mp4",
    "page": "https://vividsites.app/c/iridescent-blob"
  }
}